@gotgenes/pi-subagents 6.16.3 → 6.17.1

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/docs/architecture/architecture.md +588 -535
  3. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  4. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  5. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  6. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  7. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  8. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  9. package/docs/architecture/history/phase-8-testability.md +103 -0
  10. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  11. package/docs/plans/0147-inject-wrap-text-into-conversation-viewer.md +166 -0
  12. package/docs/retro/0147-inject-wrap-text-into-conversation-viewer.md +90 -0
  13. package/package.json +1 -1
  14. package/src/agent-manager.ts +11 -11
  15. package/src/agent-record.ts +6 -6
  16. package/src/agent-runner.ts +6 -6
  17. package/src/agent-types.ts +2 -2
  18. package/src/custom-agents.ts +3 -3
  19. package/src/default-agents.ts +1 -1
  20. package/src/env.ts +2 -2
  21. package/src/handlers/index.ts +2 -2
  22. package/src/index.ts +26 -26
  23. package/src/invocation-config.ts +1 -1
  24. package/src/memory.ts +2 -2
  25. package/src/notification.ts +4 -4
  26. package/src/parent-snapshot.ts +1 -1
  27. package/src/prompts.ts +2 -2
  28. package/src/record-observer.ts +2 -2
  29. package/src/renderer.ts +2 -2
  30. package/src/runtime.ts +2 -2
  31. package/src/service-adapter.ts +5 -5
  32. package/src/service.ts +1 -1
  33. package/src/session-config.ts +5 -5
  34. package/src/skill-loader.ts +2 -2
  35. package/src/tools/agent-tool.ts +11 -11
  36. package/src/tools/background-spawner.ts +8 -8
  37. package/src/tools/foreground-runner.ts +9 -9
  38. package/src/tools/get-result-tool.ts +5 -5
  39. package/src/tools/helpers.ts +4 -4
  40. package/src/tools/spawn-config.ts +6 -6
  41. package/src/tools/steer-tool.ts +3 -3
  42. package/src/types.ts +1 -1
  43. package/src/ui/agent-activity-tracker.ts +1 -1
  44. package/src/ui/agent-config-editor.ts +4 -4
  45. package/src/ui/agent-creation-wizard.ts +5 -5
  46. package/src/ui/agent-menu.ts +12 -10
  47. package/src/ui/agent-widget.ts +5 -5
  48. package/src/ui/conversation-viewer.ts +33 -21
  49. package/src/ui/display.ts +2 -2
  50. package/src/ui/ui-observer.ts +1 -1
  51. package/src/ui/widget-renderer.ts +5 -5
  52. package/src/worktree-state.ts +1 -1
  53. package/src/worktree.ts +1 -1
  54. package/vitest.config.ts +14 -0
@@ -0,0 +1,8 @@
1
+ # Phase 1: Export SubagentsService from this package
2
+
3
+ Added the `SubagentsService` interface, serializable types, `Symbol.for()` accessor functions, and `SUBAGENT_EVENTS` constants as public exports.
4
+ Wired `service-adapter.ts` to wrap `AgentManager` and call `publishSubagentsService()` at extension init.
5
+
6
+ ## Related issues
7
+
8
+ - #48 — Export SubagentsService from this package
@@ -0,0 +1,9 @@
1
+ # Phase 2: Remove scheduling
2
+
3
+ Deleted `schedule.ts`, `schedule-store.ts`, `ui/schedule-menu.ts`.
4
+ Removed the `schedule` parameter from the `Agent` tool schema.
5
+ Removed scheduler setup and lifecycle hooks from `index.ts`.
6
+
7
+ ## Related issues
8
+
9
+ - #52 — Remove scheduling
@@ -0,0 +1,11 @@
1
+ # Phase 3: Remove group-join, ad-hoc RPC; replace output-file
2
+
3
+ Deleted `group-join.ts`, `cross-extension-rpc.ts` (#49).
4
+ Replaced `output-file.ts` with `SessionManager.create()` + `session-dir.ts` (#61).
5
+ Simplified `index.ts` to use direct individual notifications.
6
+ Lifecycle events emitted on `pi.events` for external consumers.
7
+
8
+ ## Related issues
9
+
10
+ - #49 — Remove group-join and ad-hoc RPC
11
+ - #61 — Replace output-file with JSONL session transcripts
@@ -0,0 +1,8 @@
1
+ # Phase 4: Implement and publish SubagentsService
2
+
3
+ Wired `service-adapter.ts` to wrap `AgentManager` and call `publishSubagentsService()` at extension init.
4
+ Model strings are resolved inside the adapter.
5
+
6
+ ## Related issues
7
+
8
+ - #48 — Implement and publish SubagentsService
@@ -0,0 +1,42 @@
1
+ # Phase 5: Decompose index.ts
2
+
3
+ Extracted tools, notifications, activity tracking, event handlers, and the `/agents` command into separate modules.
4
+ Created `SubagentRuntime` factory to hold session-scoped state.
5
+
6
+ ## index.ts decomposition
7
+
8
+ The original monolithic `index.ts` has been decomposed into focused modules:
9
+
10
+ ```text
11
+ src/
12
+ ├── index.ts - slimmed entry point: init, tool registration
13
+ ├── runtime.ts - SubagentRuntime: session-scoped state + methods
14
+ ├── tools/
15
+ │ ├── agent-tool.ts - Agent tool definition, parameter validation, dispatch
16
+ │ ├── foreground-runner.ts - foreground execution loop (spinner, streaming, result)
17
+ │ ├── background-spawner.ts - background spawn (activity setup, notification wiring)
18
+ │ ├── get-result-tool.ts - get_subagent_result tool
19
+ │ ├── steer-tool.ts - steer_subagent tool
20
+ │ └── helpers.ts - shared tool utilities (textResult, buildDetails, getStatusNote, ...)
21
+ ├── handlers/
22
+ │ ├── lifecycle.ts - session_start, session_before_switch, session_shutdown
23
+ │ └── tool-start.ts - tool_execution_start handler
24
+ ├── notification.ts - completion nudges, custom renderer
25
+ ├── renderer.ts - notification TUI component
26
+ ├── ui/agent-menu.ts - /agents slash command menu (orchestration, listing, settings)
27
+ ├── ui/agent-config-editor.ts - agent detail view (edit/delete/eject/disable/enable)
28
+ ├── ui/agent-creation-wizard.ts - agent creation (AI-generation and manual-form)
29
+ ├── ui/agent-file-ops.ts - AgentFileOps interface + FsAgentFileOps implementation
30
+ ├── service-adapter.ts - SubagentsService implementation wrapping AgentManager
31
+ └── (existing domain modules unchanged)
32
+ ```
33
+
34
+ Each extracted module receives narrow constructor-injected dependencies rather than closing over module-level state.
35
+ Handlers call methods on narrow runtime interfaces - no raw field writes, no `widget!` reach-throughs.
36
+
37
+ ## Related issues
38
+
39
+ - #54 — Decompose index.ts
40
+ - #69 — SubagentRuntime factory
41
+ - #70 — Handler extraction
42
+ - #87 — Runtime methods
@@ -0,0 +1,173 @@
1
+ # Phase 7: Encapsulation and dependency narrowing
2
+
3
+ Every mutable state bag became a class, every dependency bag narrowed to what its consumer uses, every callback became either a method on a collaborator or an event on an observable.
4
+
5
+ ## AgentManager decomposition
6
+
7
+ AgentManager was decomposed in three steps to untangle record management, concurrency control, and execution orchestration.
8
+
9
+ ### Step 1: Record state machine (#98, #102)
10
+
11
+ Extracted status-transition methods (`markRunning`, `markCompleted`, `markAborted`, `markSteered`, `markError`, `markStopped`, `resetForResume`) onto `AgentRecord`.
12
+ Replaced scattered field writes across 6 sites with encapsulated transition methods.
13
+ Issue #102 consolidated test `AgentRecord` construction into a shared factory.
14
+
15
+ ### Step 2: Parent snapshot (#99)
16
+
17
+ Replaced live `ctx: ExtensionContext` capture in `SpawnArgs` with an immutable `ParentSnapshot` data object.
18
+ The snapshot is taken once at spawn time; queued agents execute against frozen state rather than a potentially stale session reference.
19
+ `runAgent()` accepts `ParentSnapshot` instead of `ctx`.
20
+ `pi: ExtensionAPI` was removed from `SpawnArgs` - `runAgent()` accepts a `ShellExec` function instead.
21
+
22
+ ### Step 3: Session-event observation (#100)
23
+
24
+ Replaced three-layer callback threading with direct session subscriptions.
25
+ `record-observer.ts` subscribes to the session to update record statistics (tool uses, lifetime usage, compaction count).
26
+ `ui/ui-observer.ts` subscribes to the session to stream UI state (active tools, response text, turn count).
27
+ `SpawnOptions` and `RunOptions` dropped all `on*` callback fields except `onSessionCreated` (which delivers the session object to enable external subscriptions).
28
+
29
+ ### Realized impact
30
+
31
+ | Metric | Before | After |
32
+ | --------------------------------- | ------ | ----------------------- |
33
+ | `SpawnOptions` callback fields | 6 | 1 (`onSessionCreated`) |
34
+ | `RunOptions` callback fields | 6 | 1 (`onSessionCreated`) |
35
+ | Callback layers | 3 | 0 (direct subscription) |
36
+ | Live `ctx` references in queue | 1 | 0 (snapshot) |
37
+ | Scattered status-transition sites | 6 | 1 (state machine) |
38
+
39
+ ## Encapsulation roadmap
40
+
41
+ Phase 7 encapsulated mutable state into classes, replaced callbacks with semantic components, and narrowed dependency bags.
42
+
43
+ Each step was sequenced so it made the next step easier.
44
+
45
+ ### Resolved smells
46
+
47
+ All nine smells identified at the start of Phase 7 were resolved:
48
+
49
+ | Smell | Resolution |
50
+ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
51
+ | Global mutable state | `AgentTypeRegistry` class (#108); `reloadCustomAgents` callback removed from dep bags |
52
+ | Closure bag as class | `NotificationManager` class (#116); `pendingNudges` and timer state are private fields |
53
+ | Mutable state bag | `AgentActivityTracker` class (#110); transition methods replace external writes |
54
+ | Settings relay | `SettingsManager` class (#109); 6 callback fields collapsed to one object |
55
+ | Post-construction mutation | `ExecutionState`, `WorktreeState`, `NotificationState` collaborators (#111); stats behind mutation methods |
56
+ | Fire-and-forget callbacks | `AgentManagerObserver` interface (#112); one observer object replaces 3 closure lambdas |
57
+ | Duplicate `SpawnOptions` | Internal type renamed to `AgentSpawnConfig` (#113); public `SpawnOptions` unchanged |
58
+ | Type dumping ground | `NotificationDetails`, `ParentSnapshot`, `EnvInfo` moved to their natural modules (#116); narrow subsets defined |
59
+ | Wide dependency bags | `AgentToolDeps` 9 → 6, `AgentMenuDeps` 8 → 7 (#114); `emitEvent` removed; description text derived from registry; `agentActivity` narrowed |
60
+
61
+ ### Step A: Extract state into classes (foundation, parallel)
62
+
63
+ These three extractions are independent and can proceed in any order.
64
+ Each eliminates a category of global/closure state and gives orphaned callbacks a natural home.
65
+
66
+ #### A1. AgentTypeRegistry class (#108)
67
+
68
+ Wrapped the module-scoped `agents` Map and free functions in `agent-types.ts` into an injectable class.
69
+ `reloadCustomAgents` callback removed from `AgentToolDeps` and `AgentMenuDeps`; replaced by `registry.reload()`.
70
+ `DEFAULT_AGENT_NAMES` moved from `types.ts` to the registry.
71
+
72
+ #### A2. SettingsManager class (#109, #118)
73
+
74
+ Encapsulated settings load/save/apply cycle into `SettingsManager` (in `settings.ts`).
75
+ Owns `defaultMaxTurns`, `graceTurns`, `maxConcurrent` with normalizing property accessors.
76
+ Added `applyMaxConcurrent(n)`, `applyDefaultMaxTurns(n)`, `applyGraceTurns(n)` - each owns the full consequence chain: normalize → set in memory → notify callback → persist → emit event → return toast.
77
+ The 6 settings-related fields in `AgentMenuDeps` collapsed to `settings: AgentMenuSettings`.
78
+
79
+ #### A3. AgentActivityTracker class (#110)
80
+
81
+ Wrapped the 7-field mutable `AgentActivity` interface in an `AgentActivityTracker` class (`src/ui/agent-activity-tracker.ts`).
82
+ `ui-observer.ts` calls transition methods; consumers use read-only accessors.
83
+ The shared map on `SubagentRuntime` is `Map<string, AgentActivityTracker>`.
84
+
85
+ ### Step B: Split AgentRecord lifecycle state (#111)
86
+
87
+ Split post-construction mutation into phase-specific collaborators, each born complete:
88
+
89
+ - **`ExecutionState`** (`session`, `outputFile`) - constructed in `onSessionCreated`.
90
+ - **`WorktreeState`** (`path`, `branch`, `cleanupResult`) - constructed at worktree setup.
91
+ - **`NotificationState`** (`toolCallId`, `resultConsumed`) - constructed by `AgentManager.spawn()` when `toolCallId` is provided.
92
+ - **`pendingSteers`** moved to `Map<string, string[]>` on `AgentManager`.
93
+ - Stats encapsulated behind mutation methods with read-only getters.
94
+ - `AgentRecordInit` trimmed from 19 optional fields to 4 construction-time fields.
95
+
96
+ ### Step C: Replace AgentManager callbacks with observer (#112)
97
+
98
+ `AgentManagerObserver` interface replaces `onStart`/`onComplete`/`onCompact`.
99
+ `index.ts` constructs one observer object instead of 3 closure lambdas.
100
+ `AgentManagerOptions` drops from 9 → 7 fields.
101
+
102
+ ### Step D: Disambiguate SpawnOptions and narrow dependency bags
103
+
104
+ #### D1. Disambiguate SpawnOptions (#113)
105
+
106
+ Internal `SpawnOptions` in `agent-manager.ts` renamed to `AgentSpawnConfig`.
107
+ Public `SpawnOptions` in `service.ts` unchanged.
108
+
109
+ #### D2. Narrow AgentToolDeps and AgentMenuDeps (#114)
110
+
111
+ | Bag | Before | After | How |
112
+ | --------------- | -------- | ----- | ----------------------------------------------------------------------------------------------------------- |
113
+ | `AgentToolDeps` | 9 fields | 6 | `emitEvent` → observer; `typeListText`/`availableTypesText` derived from registry; `agentActivity` narrowed |
114
+ | `AgentMenuDeps` | 8 fields | 7 | Dead `emitEvent` removed; `agentActivity` narrowed to read-only `AgentActivityReader` |
115
+
116
+ ### Step E: Decompose large files and relocate types
117
+
118
+ #### E1. Split agent-tool.ts foreground/background (#115)
119
+
120
+ Extracted `foreground-runner.ts` (~175 lines) and `background-spawner.ts` (~116 lines).
121
+ `agent-tool.ts` reduced from 579 → 411 lines.
122
+
123
+ #### E2. Type housekeeping (#116)
124
+
125
+ - Moved `NotificationDetails`, `ParentSnapshot`, `EnvInfo` to their natural modules.
126
+ - Converted `createNotificationSystem` closure to `NotificationManager` class.
127
+ - Converted `ConversationViewer` constructor from 7 positional parameters to `ConversationViewerOptions` bag.
128
+ - Defined `AgentIdentity` and `AgentPromptConfig` narrow subsets; `buildAgentPrompt` narrowed to `AgentPromptConfig`.
129
+
130
+ ### Phase 7 results
131
+
132
+ | Metric | Before | After |
133
+ | ------------------------------------------ | ------ | ----- |
134
+ | Module-scoped mutable state | 1 | 0 |
135
+ | Closure-bag "classes" | 2 | 0 |
136
+ | Externally-mutated state bags | 2 | 0 |
137
+ | `AgentManagerOptions` fields | 9 | 7 |
138
+ | `AgentToolDeps` fields | 9 | 6 |
139
+ | `AgentMenuDeps` fields | 13 | 7 |
140
+ | `SpawnOptions` callback fields | 6 | 1 |
141
+ | `RunOptions` callback fields | 6 | 1 |
142
+ | Callbacks threaded through deps | 8 | 0 |
143
+ | Types in `types.ts` without a natural home | 4 | 0 |
144
+
145
+ ### Dependency graph
146
+
147
+ ```mermaid
148
+ flowchart LR
149
+ A1["A1: Registry"] --> D2["D2: Narrow deps"]
150
+ A2["A2: Settings"] --> A2b["A2b: Apply"] --> D2
151
+ A3["A3: Activity Tracker"] --> D2
152
+ B["B: Record lifecycle"] --> D2
153
+ B --> C["C: Observer"] --> D1["D1: SpawnOptions"] --> D2
154
+ D2 --> E1["E1: agent-tool split"]
155
+ A1 --> E2["E2: Type housekeeping"]
156
+ ```
157
+
158
+ ## Related issues
159
+
160
+ - #98 — Record state machine
161
+ - #99 — Parent snapshot
162
+ - #100 — Session-event observation
163
+ - #102 — Consolidated test AgentRecord construction
164
+ - #108 — AgentTypeRegistry class
165
+ - #109 — SettingsManager class
166
+ - #110 — AgentActivityTracker class
167
+ - #111 — Split AgentRecord lifecycle state
168
+ - #112 — Replace AgentManager callbacks with observer
169
+ - #113 — Disambiguate SpawnOptions
170
+ - #114 — Narrow AgentToolDeps and AgentMenuDeps
171
+ - #115 — Split agent-tool.ts foreground/background
172
+ - #116 — Type housekeeping
173
+ - #118 — SettingsManager apply methods
@@ -0,0 +1,103 @@
1
+ # Phase 8: Testability, display extraction, and menu decomposition
2
+
3
+ Eliminated `vi.mock()` module mocking in the two most fragile test suites by injecting IO-touching collaborators; consolidated shared test fixtures; extracted display helpers into a reusable module; decomposed the largest UI file.
4
+
5
+ Steps G and H eliminated 11 of the original 12 `vi.mock()` calls in the runner tests, removing fragile call-sequence assertions in favour of injected stubs.
6
+ Step G resolved `session-config.test.ts`; Step H resolved both `agent-runner.test.ts` and `agent-runner-extension-tools.test.ts`.
7
+
8
+ The display and menu improvements were identified during Phase 7 but deferred because they did not gate encapsulation work.
9
+ The display extraction unblocked menu decomposition.
10
+
11
+ ## Test pain points (resolved)
12
+
13
+ | Symptom | Resolution |
14
+ | ------------------------------------------------------------- | -------------------------------------------------------------- |
15
+ | 7 `vi.mock()` calls in `agent-runner.test.ts` | Step H (#133): injected `RunnerIO` stubs |
16
+ | 7 `vi.mock()` calls in `agent-runner-extension-tools.test.ts` | Step H (#133): same |
17
+ | 52 `as any` casts across test suite | Step I (#134): reduced to 15 |
18
+ | 3× duplicated `mockSession()` | Step F (#131): shared `createMockSession()` in `test/helpers/` |
19
+ | 3× duplicated `makeDeps()` | Step F (#131): shared `createToolDeps()` in `test/helpers/` |
20
+
21
+ The well-designed test suites - `agent-manager.test.ts` (1 mock, DI via `AgentRunner` interface), `notification.test.ts` (0 mocks, pure functions + DI), and `agent-tool.test.ts` (0 mocks, tests via deps bag) - confirmed the pattern: modules that accept collaborators through injection produce resilient tests; modules that import collaborators directly produce fragile mock-heavy tests.
22
+
23
+ ## Step F: Shared test fixtures (#131)
24
+
25
+ Consolidated duplicated mock factories into `test/helpers/`.
26
+
27
+ 1. `createMockSession()` - subscribable event bus with `emit()` helper; replaced 3 hand-rolled copies.
28
+ 2. `createToolDeps()` - builds `AgentToolDeps` with sensible defaults and override support; replaced 3 `makeDeps()` copies.
29
+ 3. `makeRecord()` - `AgentRecord` factory with sensible defaults; replaced scattered inline construction.
30
+ 4. `STUB_CTX` - shared stub `ExtensionContext` constant; centralised unavoidable bridge casts.
31
+
32
+ Impact: reduced test boilerplate; single source of truth for mock shapes; changes to dep interfaces propagate automatically.
33
+
34
+ ## Step G: Inject IO collaborators into session-config (#132)
35
+
36
+ `assembleSessionConfig` now accepts `io: AssemblerIO` as a required parameter.
37
+ `index.ts` constructs the real `AssemblerIO` from direct imports via the `RunnerIO.assemblerIO` field (wired in Step H).
38
+ `session-config.test.ts` injects stubs - all 4 `vi.mock()` calls eliminated, assertions shifted to `SessionConfig` output properties.
39
+
40
+ ## Step H: Inject SDK boundary into agent-runner (#133)
41
+
42
+ `runAgent()` now accepts `io: RunnerIO` as a required parameter bundling all IO collaborators: `detectEnv`, `getAgentDir`, `createResourceLoader`, `deriveSessionDir`, `createSessionManager`, `createSettingsManager`, `createSession`, and `assemblerIO`.
43
+
44
+ `createAgentRunner(io: RunnerIO): AgentRunner` factory captures the boundary at construction time so `AgentManager` and the `AgentRunner` interface remain unchanged.
45
+ `index.ts` constructs the real `RunnerIO` from Pi SDK imports and sibling modules.
46
+
47
+ Impact: all 7 `vi.mock()` calls eliminated from both `agent-runner.test.ts` and `agent-runner-extension-tools.test.ts`; tests verify behavior (turn limits, tool filtering, response collection) through injected stubs; SDK imports moved to the extension entry point.
48
+
49
+ ## Step I: Reduce `as any` casts in tests (#134)
50
+
51
+ Reduced `as any` count from 93 to 15 (plus 13 explicit `as unknown as T` bridge casts).
52
+
53
+ Production changes:
54
+
55
+ - `ResourceLoaderOptions.appendSystemPromptOverride` typed to match `DefaultResourceLoaderOptions`; `createResourceLoader` factory cast removed from `index.ts`.
56
+ - `CreateSessionOptions.settingsManager` / `RunnerIO.createSettingsManager` typed as `SettingsManager`.
57
+ - `WidgetLike` interface in `runtime.ts` narrows the widget field.
58
+ - Local `ToolCallContent` / `BashExecutionMessage` type guards replace `as any` duck-typing in `conversation-viewer.ts` and `agent-runner.ts`.
59
+ - `textResult()` return no longer casts `details as any`.
60
+ - `toAgentSession()` helper and `STUB_CTX` constant centralise unavoidable bridge casts.
61
+
62
+ Remaining 15 `as any` casts are: 8 menu-handler `ctx as any` (deferred - requires `AgentManager.spawn` to accept `ParentSnapshot` directly), 2 `print-mode.test.ts` (same ExtensionContext/API pattern), 2 private-field test access, 1 `createSession` SDK bridge in `index.ts`, 1 `foreground-runner.ts` `AgentToolResult<any>` detail, 1 `stub-ctx.ts` comment.
63
+
64
+ ## Step J: Extract display helpers (#135)
65
+
66
+ `ui/display.ts` now contains all pure formatters, display helpers, constants, and shared types (`Theme`, `AgentDetails`).
67
+ `agent-widget.ts` dropped from 522 → ~340 lines.
68
+ All consumer modules (menu, tools, renderer, conversation viewer) import from `ui/display.ts` directly.
69
+ `test/agent-widget.test.ts` renamed to `test/display.test.ts`.
70
+
71
+ ## Step K: Decompose agent-menu.ts (#136)
72
+
73
+ `agent-menu.ts` (668 lines) decomposed into four modules:
74
+
75
+ 1. `ui/agent-file-ops.ts` - `AgentFileOps` interface (`exists`, `read`, `write`, `remove`, `ensureDir`, `findAgentFile`) + `FsAgentFileOps` production implementation.
76
+ 2. `ui/agent-config-editor.ts` - `showAgentDetail` with edit/delete/reset/eject/disable/enable transitions (~200 lines).
77
+ 3. `ui/agent-creation-wizard.ts` - AI-generation and manual-form creation paths (~250 lines).
78
+ 4. `ui/agent-menu.ts` - menu orchestration, agent listing, running-agent viewer, settings form (~300 lines).
79
+
80
+ Impact: `agent-menu.ts` dropped from 668 → 296 lines; extracted modules receive `AgentFileOps` via injection; `vi.mock("node:fs")` eliminated from `agent-menu.test.ts`.
81
+
82
+ ## Step dependencies
83
+
84
+ ```mermaid
85
+ flowchart LR
86
+ subgraph testability["Testability track"]
87
+ F["F: Shared fixtures"] --> G["G: session-config IO"] --> H["H: agent-runner SDK"] --> I["I: Reduce as-any"]
88
+ end
89
+ subgraph display["Display track"]
90
+ J["J: Display extraction"] --> K["K: Menu decomposition"]
91
+ end
92
+ ```
93
+
94
+ The two tracks are independent and can proceed in parallel.
95
+
96
+ ## Related issues
97
+
98
+ - #131 — Shared test fixtures
99
+ - #132 — Inject IO collaborators into session-config
100
+ - #133 — Inject SDK boundary into agent-runner
101
+ - #134 — Reduce as-any casts in tests
102
+ - #135 — Extract display helpers
103
+ - #136 — Decompose agent-menu.ts
@@ -0,0 +1,122 @@
1
+ # Phase 9: Observation consolidation, ctx elimination, and remaining mocks
2
+
3
+ Target: consolidate the dual observation model so stats live in one place; remove `ExtensionContext` from all internal APIs; eliminate remaining `vi.mock()` calls and `as any` casts; split widget rendering from lifecycle; apply dependency bag convention.
4
+
5
+ ## Current smells
6
+
7
+ | Smell | Location | Evidence | Severity |
8
+ | ------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------- |
9
+ | `execute` does config resolution for its callees | `agent-tool.ts` (145-line `execute`) | ~60 lines unpack config, resolve model, compute metadata, repack into 16-field bags for spawners; `ctx` threaded 4 layers deep | Medium |
10
+ | ~~Wide `ctx` in menu handlers~~ | ~~`agent-menu.ts`, `agent-config-editor.ts`, `agent-creation-wizard.ts`~~ | Resolved by #146: `MenuUI` interface introduced; 42 `ctx as any` casts eliminated across 5 test files | Done |
11
+ | ~~Direct SDK import in `conversation-viewer.ts`~~ | ~~`conversation-viewer.test.ts`~~ | Resolved by #147: `wrapText` injected via `ConversationViewerOptions`; `vi.mock("@earendil-works/pi-tui")` eliminated | Done |
12
+ | ~~Widget mixes rendering, lifecycle, and state~~ | ~~`agent-widget.ts` (370 lines)~~ | Resolved by #148: rendering extracted to `widget-renderer.ts`; widget is now 198 lines | Done |
13
+ | `deps.` prefix noise in function bodies | remaining modules across tools, UI, service-adapter | Functions accept a `deps` bag and access every field as `deps.foo`; hides real dependencies and lengthens every call line | Low |
14
+
15
+ ## Dependency bag convention
16
+
17
+ Applied incrementally as each step touches a module:
18
+
19
+ - **≤4 fields** - accept as plain parameters; drop the interface.
20
+ - **≥5 fields** - keep a named interface but destructure in the function signature (`{ manager, widget }: ForegroundDeps`) so the function body uses bare names, not `deps.foo`.
21
+
22
+ This eliminates the `deps.` prefix noise across ~124 callsites in 12 modules.
23
+
24
+ ## Step L: Consolidate observation model (#144)
25
+
26
+ Removed `_toolUses` and `_lifetimeUsage` from `AgentActivityTracker`.
27
+ UI consumers read stats from `AgentRecord` instead of the tracker.
28
+ The UI observer retains event subscriptions for re-render triggers but no longer accumulates stats independently.
29
+
30
+ Add `session` and `outputFile` convenience getters on `AgentRecord` to hide the `execution?.` traversal.
31
+ The 15+ callsites that navigate `record.execution?.session` simplify to `record.session`.
32
+
33
+ Apply the dependency bag convention to touched modules: `NotificationDeps` (4 fields) becomes plain parameters on `NotificationManager` constructor.
34
+
35
+ Impact: eliminates dual counting; removes `??` fallback pattern from widget and conversation viewer; hides `ExecutionState` structure from consumers.
36
+
37
+ ## Step M: Decompose execute and push ExtensionContext to the boundary (#145)
38
+
39
+ Extracted config resolution into `resolveSpawnConfig` (pure function in `spawn-config.ts`).
40
+ Injected three collaborators (`buildSnapshot`, `getModelInfo`, `getSessionInfo`) into `createAgentTool` so `execute` no longer reads `ctx` beyond `ctx.ui` (already delegated to `widget.setUICtx`).
41
+ `AgentManager.spawn()` and `spawnAndWait()` accept `ParentSnapshot` instead of `ExtensionContext`.
42
+ `service-adapter.ts` calls `buildParentSnapshot(session.ctx)` at its boundary.
43
+ `foreground-runner` and `background-spawner` receive `ResolvedSpawnConfig` + domain values (`snapshot`, `parentSessionFile`, `parentSessionId`) instead of `ctx`.
44
+
45
+ Dissolved `ForegroundDeps`, `BackgroundDeps`, and `AdapterDeps` into plain parameters.
46
+ `AgentToolDeps` is destructured in the `createAgentTool` signature.
47
+
48
+ After this step, `ExtensionContext` appears only in:
49
+
50
+ - `index.ts` closures (wired at extension startup)
51
+ - `service-adapter.ts` (cross-extension boundary)
52
+ - Menu handlers (addressed by Step N)
53
+
54
+ Impact: `execute` dropped from ~145 to ~25 lines; eliminated 16-field parameter bags; eliminated `vi.mock("../src/parent-snapshot.js")` in `agent-manager.test.ts`; foreground/background runner tests no longer need `ctx` mocks; `AgentManager` operates entirely on domain types.
55
+
56
+ ## Step N: Narrow UI context for menu handlers (#146)
57
+
58
+ Defined `MenuUI` interface (exported from `agent-menu.ts`) with `select`, `confirm`, `input`, `notify`, `editor`, and `custom` methods — the exact subset `ctx.ui` methods used by menu handlers.
59
+ All inner functions in `agent-menu.ts`, `agent-config-editor.ts`, and `agent-creation-wizard.ts` now accept `(ui: MenuUI)` instead of `(ctx: ExtensionContext)`.
60
+ `index.ts` passes `ctx.ui`, `ctx.modelRegistry`, and `buildParentSnapshot(ctx)` to the handler.
61
+
62
+ `AgentMenuManager.spawnAndWait` and `WizardManager.spawnAndWait` both accept `ParentSnapshot` (enabled by Step M).
63
+ Creation wizard threads `parentSnapshot` from `showCreateWizard(ui, parentSnapshot)` → `showGenerateWizard(ui, parentSnapshot, targetDir)` → `manager.spawnAndWait(parentSnapshot, ...)`.
64
+
65
+ Applied the dependency bag convention:
66
+
67
+ - `AgentConfigEditorDeps` (4 fields), `GetResultDeps` (4 fields), `SteerToolDeps` (4 fields) dissolved into plain parameters.
68
+ - `AgentMenuDeps` (8 fields) and `AgentCreationWizardDeps` (5 fields) kept as interfaces, destructured in the function signature.
69
+
70
+ After Steps M and N, `ExtensionContext` appears only at true boundaries: `index.ts` closures and `service-adapter.ts` (cross-extension bridge).
71
+
72
+ Impact: eliminated 42 `ctx as any` casts across 5 test files (`agent-menu.test.ts`: 8, `agent-config-editor.test.ts`: 20, `agent-creation-wizard.test.ts`: 14); tests construct plain `MenuUI`-shaped objects with no cast.
73
+
74
+ ## Step O: Inject text wrapping into ConversationViewer (#147)
75
+
76
+ Accepted `wrapText: (text: string, width: number) => string[]` via `ConversationViewerOptions`.
77
+ `agent-menu.ts` passes the real `wrapTextWithAnsi` import at the `ConversationViewer` construction site.
78
+ Tests inject a stub or the real function directly via options — no module-level mock needed.
79
+
80
+ Applied the dependency bag convention: `ConversationViewerOptions` is now destructured in the constructor signature.
81
+
82
+ Impact: eliminated the hoisted `vi.mock("@earendil-works/pi-tui")` from `conversation-viewer.test.ts`; 1 test deleted (mock-mechanism sentinel); net −1 test.
83
+
84
+ ## Step P: Split AgentWidget rendering (#148)
85
+
86
+ Extracted pure rendering functions (`renderWidgetLines`, `renderFinishedLine`, `renderRunningLines`) from `AgentWidget` into `ui/widget-renderer.ts`.
87
+ The widget is now a thin lifecycle/polling wrapper (198 lines, down from 374) that delegates to pure render functions.
88
+ Rendering functions receive data (agent list, activity map, registry) and return formatted strings - testable without widget lifecycle. 23 new unit tests cover all status variants, overflow, tree connectors, and empty states.
89
+
90
+ ## Step dependencies
91
+
92
+ ```mermaid
93
+ flowchart LR
94
+ subgraph observation["Observation track"]
95
+ L["L: Consolidate observation #144"] --> P["P: Split widget rendering #148"]
96
+ end
97
+ subgraph ctx["ctx elimination track"]
98
+ M["M: Decompose execute / push ctx #145"] --> N["N: Narrow UI context #146"]
99
+ end
100
+ O["O: Inject text wrapping #147"]
101
+ ```
102
+
103
+ The three tracks are independent of each other.
104
+
105
+ ## Projected impact
106
+
107
+ | Metric | Before | After |
108
+ | ---------------------------------- | ------------------------ | ------------------------ |
109
+ | `vi.mock()` calls remaining | 4 | 1 (`print-mode.test.ts`) |
110
+ | `as any` casts remaining | 45 | ~5 |
111
+ | Independent tool-use counters | 2 | 1 |
112
+ | `record.execution?.` traversals | 15+ | 0 |
113
+ | `ExtensionContext` in domain types | 1 (`AgentManager.spawn`) | 0 |
114
+ | `deps.` prefix accesses | ~124 | 0 |
115
+
116
+ ## Related issues
117
+
118
+ - #144 — Consolidate observation model
119
+ - #145 — Decompose execute and push ExtensionContext to boundary
120
+ - #146 — Narrow UI context for menu handlers
121
+ - #147 — Inject text wrapping into ConversationViewer
122
+ - #148 — Split AgentWidget rendering
@@ -0,0 +1,166 @@
1
+ ---
2
+ issue: 147
3
+ issue_title: "Inject text wrapping into ConversationViewer (Phase 9, Step O)"
4
+ ---
5
+
6
+ # Inject text wrapping into ConversationViewer
7
+
8
+ ## Problem Statement
9
+
10
+ `ConversationViewer` calls `wrapTextWithAnsi` directly from `@earendil-works/pi-tui` in four places inside `buildContentLines`.
11
+ Because the function is a module-level binding, tests must intercept it via a hoisted `vi.mock("@earendil-works/pi-tui")` factory that replaces the entire TUI module.
12
+ This is the last `vi.mock` on an SDK module in the test suite, added specifically to exercise the overwidth-clamping safety net.
13
+
14
+ ## Goals
15
+
16
+ - Accept `wrapText: (text: string, width: number) => string[]` via `ConversationViewerOptions`.
17
+ - Destructure `ConversationViewerOptions` in the constructor signature (dependency bag convention).
18
+ - Replace all four `wrapTextWithAnsi` calls in `buildContentLines` with `this.wrapText`.
19
+ - Remove `wrapTextWithAnsi` from `conversation-viewer.ts`'s `@earendil-works/pi-tui` import.
20
+ - Pass `wrapTextWithAnsi` at the production call site in `agent-menu.ts`.
21
+ - Eliminate the hoisted `vi.mock("@earendil-works/pi-tui")` from `conversation-viewer.test.ts`.
22
+
23
+ ## Non-Goals
24
+
25
+ - Injecting `truncateToWidth` or any other TUI function (only `wrapTextWithAnsi` is relevant here).
26
+ - Changing the overwidth-clamping behavior of `buildContentLines`.
27
+ - Touching `agent-widget.ts` (tracked separately as Issue #148, Step P — already closed).
28
+
29
+ ## Background
30
+
31
+ `ui/conversation-viewer.ts` is the live conversation overlay rendered when a user selects an agent in the `/agents` menu.
32
+ Its `buildContentLines` method formats messages from the agent session into displayable lines, calling `wrapTextWithAnsi` to soft-wrap text to the available terminal width.
33
+
34
+ The overwidth-clamping safety net (`truncateToWidth` applied after `wrapTextWithAnsi`) exists because a prior upstream bug returned lines wider than the requested width.
35
+ The `vi.mock` in the test is the mechanism for simulating that bug by returning overwidth strings from `wrapTextWithAnsi`.
36
+
37
+ The only production call site for `new ConversationViewer(...)` is in the `viewAgentConversation` closure inside `createAgentsMenuHandler` in `ui/agent-menu.ts`.
38
+
39
+ Architecture reference: `docs/architecture/architecture.md` § Phase 9, Step O.
40
+
41
+ ## Design Overview
42
+
43
+ ### `ConversationViewerOptions` — add `wrapText`
44
+
45
+ ```typescript
46
+ export interface ConversationViewerOptions {
47
+ tui: TUI;
48
+ session: AgentSession;
49
+ record: AgentRecord;
50
+ activity: AgentActivityTracker | undefined;
51
+ theme: Theme;
52
+ done: (result: undefined) => void;
53
+ registry: AgentConfigLookup;
54
+ wrapText: (text: string, width: number) => string[];
55
+ }
56
+ ```
57
+
58
+ The field is **required** — it must be supplied at every construction site.
59
+ No default is provided; the default would be an invisible SDK dependency hidden inside the class.
60
+
61
+ ### Constructor destructuring
62
+
63
+ The constructor adopts the dependency bag convention — destructure the options object rather than accessing via `options.*`:
64
+
65
+ ```typescript
66
+ constructor({
67
+ tui, session, record, activity, theme, done, registry, wrapText,
68
+ }: ConversationViewerOptions) {
69
+ this.tui = tui;
70
+ this.session = session;
71
+ // … etc.
72
+ this.wrapText = wrapText;
73
+ this.unsubscribe = session.subscribe(() => { … });
74
+ }
75
+ ```
76
+
77
+ ### Production wiring — `agent-menu.ts`
78
+
79
+ `agent-menu.ts` statically imports `wrapTextWithAnsi` from `@earendil-works/pi-tui` and passes it when constructing `ConversationViewer`:
80
+
81
+ ```typescript
82
+ import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
83
+ // …
84
+ return new ConversationViewer({
85
+ tui, session, record, activity, theme, done, registry,
86
+ wrapText: wrapTextWithAnsi,
87
+ });
88
+ ```
89
+
90
+ Adding `wrapText` to `AgentMenuDeps` and threading it through the closure would violate the Law of Demeter — `AgentMenuDeps` has no conceptual ownership of a text-wrapping function.
91
+ The `viewAgentConversation` closure is the direct consumer; the import belongs there.
92
+
93
+ ### Test strategy
94
+
95
+ After DI, tests pass `wrapText` directly in `ConversationViewerOptions`:
96
+
97
+ - **"render width safety" tests**: pass `wrapText: wrapTextWithAnsi` (real function, statically imported — no mock).
98
+ - **"safety net" tests**: pass a stub `wrapText: () => ["X".repeat(width + 50)]` inline in options — no `vi.mock` needed.
99
+ - The "mock is intercepting wrapTextWithAnsi" test is removed (it verified the mock mechanism, not viewer behavior).
100
+ - The module-level `wrapOverride` variable and the `vi.mock` block are removed entirely.
101
+ - The `await import("@earendil-works/pi-tui")` and `await import("../src/ui/conversation-viewer.js")` dynamic imports are converted to ordinary top-level `import` statements.
102
+
103
+ ## Module-Level Changes
104
+
105
+ | File | Change |
106
+ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
107
+ | `src/ui/conversation-viewer.ts` | Add `wrapText` field to `ConversationViewerOptions`; add `private wrapText` field; destructure options in constructor; replace 4× `wrapTextWithAnsi(...)` with `this.wrapText(...)`; remove `wrapTextWithAnsi` from the `@earendil-works/pi-tui` import line |
108
+ | `src/ui/agent-menu.ts` | Add `import { wrapTextWithAnsi } from "@earendil-works/pi-tui"`; pass `wrapText: wrapTextWithAnsi` in `viewAgentConversation` |
109
+ | `test/conversation-viewer.test.ts` | Convert top-level dynamic `await import()` to static imports; remove `vi.mock`, `wrapOverride`, and "mock is intercepting" test; add `wrapText` to every `new ConversationViewer({…})` call; update safety-net tests to pass inline stub `wrapText` |
110
+
111
+ No symbols are removed from exported API (`ConversationViewerOptions`, `ConversationViewer`, `VIEWPORT_HEIGHT_PCT` all remain).
112
+ The `wrapText` addition to `ConversationViewerOptions` is a breaking change for any external consumers that construct `ConversationViewer` — check for usages outside this package.
113
+
114
+ Grep check: `ConversationViewer` appears only in `src/ui/conversation-viewer.ts`, `src/ui/agent-menu.ts`, and `test/conversation-viewer.test.ts` — no other files construct it.
115
+
116
+ ## Test Impact Analysis
117
+
118
+ 1. **New unit-test capability**: The safety-net tests can now inject exactly the stub they need without patching a module.
119
+ Each test is self-contained and immediately legible — the stub is declared inline at the call site.
120
+
121
+ 2. **Tests that become redundant**: The "mock is intercepting wrapTextWithAnsi" test verified the test mechanism, not production behavior.
122
+ It is deleted.
123
+ The `wrapOverride` reset in `beforeEach` is no longer needed.
124
+
125
+ 3. **Tests that stay**: All "render width safety" tests and all overwidth-clamping tests remain; they exercise real viewer behavior with real or controlled inputs.
126
+
127
+ ## TDD Order
128
+
129
+ ### Cycle 1 — Add `wrapText` field and update production wiring
130
+
131
+ 1. **Red**: Update one `new ConversationViewer({…})` in the test to include `wrapText: vi.fn()` — TypeScript rejects the unknown field.
132
+ 2. **Green**:
133
+ - Add `wrapText: (text: string, width: number) => string[]` to `ConversationViewerOptions`.
134
+ - Add `private wrapText: (text: string, width: number) => string[]` field to the class.
135
+ - Destructure options in the constructor; assign `this.wrapText = wrapText`.
136
+ - Replace all four `wrapTextWithAnsi(...)` calls with `this.wrapText(...)` in `buildContentLines`.
137
+ - Remove `wrapTextWithAnsi` from the `@earendil-works/pi-tui` import in `conversation-viewer.ts`.
138
+ - In `agent-menu.ts`: add static import of `wrapTextWithAnsi` from `@earendil-works/pi-tui`; pass `wrapText: wrapTextWithAnsi`.
139
+ - Update **all** `new ConversationViewer({…})` calls in `conversation-viewer.test.ts` to include `wrapText`.
140
+ "Render width safety" tests pass `wrapText: wrapTextWithAnsi` (real function, still imported via the existing `vi.mock` shim for now).
141
+ Safety-net tests pass a stub inline: `wrapText: () => ["X".repeat(w + 50)]`.
142
+ - Delete the "mock is intercepting wrapTextWithAnsi" test.
143
+ 3. **Verify**: `pnpm vitest run test/conversation-viewer.test.ts` passes.
144
+ 4. **Commit**: `feat: inject wrapText into ConversationViewer (Phase 9, Step O) (#147)`
145
+
146
+ ### Cycle 2 — Remove the module mock
147
+
148
+ 1. **Red**: Remove the `vi.mock("@earendil-works/pi-tui", …)` block, the `wrapOverride` variable, and the `beforeEach(() => { wrapOverride = null; })` reset.
149
+ Convert `const { visibleWidth } = await import("@earendil-works/pi-tui")` to `import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui"`.
150
+ Convert `const { ConversationViewer } = await import("../src/ui/conversation-viewer.js")` to `import { ConversationViewer } from "../src/ui/conversation-viewer.js"`.
151
+ Run tests — they should still pass (the mock was no longer needed after Cycle 1).
152
+ 2. **Green**: Tests pass without any module mock.
153
+ 3. **Verify**: `pnpm vitest run test/conversation-viewer.test.ts` and `pnpm run check` both pass.
154
+ 4. **Commit**: `refactor: remove vi.mock from conversation-viewer tests (#147)`
155
+
156
+ ## Risks and Mitigations
157
+
158
+ | Risk | Mitigation |
159
+ | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
160
+ | Missing a `new ConversationViewer({…})` call site in tests | Grep confirms only `test/conversation-viewer.test.ts` constructs the viewer; all instances are in-file |
161
+ | TypeScript missing the `private wrapText` field type | Use `pnpm run check` after Cycle 1 to verify no type errors |
162
+ | The dynamic `await import()` pattern in tests was necessary for some other reason | The only purpose was to run after `vi.mock` hoisting; once the mock is gone, static imports work fine |
163
+
164
+ ## Open Questions
165
+
166
+ None — the issue's "Changes" section is unambiguous and the call-site inventory is small.