@gotgenes/pi-subagents 7.2.4 → 7.2.5

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,16 @@ 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
+ ## [7.2.5](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.2.4...pi-subagents-v7.2.5) (2026-05-25)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * mark Phase 11 Layer 3 and Layer 4 complete ([bf71795](https://github.com/gotgenes/pi-packages/commit/bf71795649a5b3c14a2b3ff16b2109d131a0ed32))
14
+ * plan convert AgentRunner and AgentsMenuHandler to classes ([#196](https://github.com/gotgenes/pi-packages/issues/196)) ([cd0bd1f](https://github.com/gotgenes/pi-packages/commit/cd0bd1fdec0c87655bdb38f8243084df807b676a))
15
+ * **retro:** add planning stage notes for issue [#196](https://github.com/gotgenes/pi-packages/issues/196) ([677d4bf](https://github.com/gotgenes/pi-packages/commit/677d4bf6619f13eba8d17181efab04cc67e47bbd))
16
+ * **retro:** add TDD stage notes for issue [#196](https://github.com/gotgenes/pi-packages/issues/196) ([72d24ba](https://github.com/gotgenes/pi-packages/commit/72d24ba56b8dc7668ff350ad3f0ba027b996d26e))
17
+
8
18
  ## [7.2.4](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.2.3...pi-subagents-v7.2.4) (2026-05-25)
9
19
 
10
20
 
@@ -686,45 +686,46 @@ After this step, `AgentManager` structurally satisfies `AgentToolManager` and `S
686
686
  - Outcome: structural typing connects real objects to tool interfaces without adapters; 0 dead exports (fallow clean)
687
687
  - Enables: Layer 3 (class constructors accept real objects directly)
688
688
 
689
- ### Layer 3: Convert closure factories to classes ([#195][195], [#196][196])
689
+ ### Layer 3: Convert closure factories to classes ([#195][195], [#196][196]) ✓ done
690
690
 
691
- With Layers 0–2 complete, each factory is a mechanical conversion. ✓ Tool factories converted in [#195][195]:
691
+ All closure factories converted to classes. ✓ Tool factories in [#195][195]; runner and menu in [#196][196]:
692
692
 
693
- | Factory | Class | Constructor params |
694
- | -------------------------------- | ------------------------------ | --------------------------------------------------- |
695
- | `createAgentTool({...})` | `AgentTool` ✓ | `manager`, `runtime`, `settings`, `registry` |
696
- | `createGetResultTool(...)` | `GetResultTool` ✓ | `manager`, `notifications`, `registry` |
697
- | `createSteerTool(...)` | `SteerTool` ✓ | `manager`, `events` |
698
- | `createAgentRunner(runnerIO)` | `AgentRunner` (concrete class) | `io: RunnerIO` |
699
- | `createAgentsMenuHandler({...})` | `AgentsMenuHandler` | `manager`, `registry`, `settings`, `fileOps`, paths |
693
+ | Factory | Class | Constructor params |
694
+ | -------------------------------- | ----------------------- | -------------------------------------------------------------------- |
695
+ | `createAgentTool({...})` | `AgentTool` ✓ | `manager`, `runtime`, `settings`, `registry` |
696
+ | `createGetResultTool(...)` | `GetResultTool` ✓ | `manager`, `notifications`, `registry` |
697
+ | `createSteerTool(...)` | `SteerTool` ✓ | `manager`, `events` |
698
+ | `createAgentRunner(runnerIO)` | `ConcreteAgentRunner` | `io: RunnerIO` |
699
+ | `createAgentsMenuHandler({...})` | `AgentsMenuHandler`| `manager`, `registry`, `agentActivity`, `settings`, `fileOps`, paths |
700
700
 
701
701
  Each class satisfies the existing interface via structural typing.
702
702
  The `defineTool()` wrapper moves into a `toToolDefinition()` method on each tool class.
703
+ `getModelLabel` internalized into `AgentsMenuHandler` (was a 7-line closure in `index.ts`).
703
704
 
704
705
  - Target: `src/tools/*.ts`, `src/lifecycle/agent-runner.ts`, `src/ui/agent-menu.ts`
705
706
  - Smell: Category C (closure factories masquerading as classes)
706
707
  - Outcome: deps are constructor params (inspectable, testable); no captured closures
707
708
  - Enables: Layer 4 (index.ts simplification)
708
709
 
709
- ### Layer 4: Simplify index.ts (included in [#196][196])
710
+ ### Layer 4: Simplify index.ts (included in [#196][196]) ✓ done
710
711
 
711
- With real objects satisfying tool interfaces and queries living on `SubagentRuntime`, the composition root becomes pure construction:
712
+ With all factories converted to classes and `AgentManager` satisfying `AgentMenuManager` structurally:
712
713
 
713
714
  ```typescript
714
- const runtime = new SubagentRuntime();
715
- const settings = new SettingsManager(...);
716
- const manager = new AgentManager(...);
717
- const agentTool = new AgentTool(manager, runtime, settings, registry);
718
- pi.registerTool(agentTool.toToolDefinition());
715
+ const agentsMenu = new AgentsMenuHandler(
716
+ manager, registry, runtime.agentActivity,
717
+ settings, new FsAgentFileOps(),
718
+ join(getAgentDir(), "agents"),
719
+ join(process.cwd(), ".pi", "agents"),
720
+ );
719
721
  ```
720
722
 
721
- No adapter closures.
722
- No `as any`.
723
- Fan-out drops from 25 to ~15 (internal factories eliminated).
723
+ Eliminated: 4 adapter closures (3 manager method adapters + `getModelLabel`), 4 unused imports.
724
+ Remaining ~15 closures are structural (event registrations, SDK factory callbacks).
724
725
 
725
726
  - Target: `src/index.ts`
726
727
  - Smell: Category B (god file) + Category C (adapter closure density)
727
- - Outcome: index.ts shrinks from 280 to ~150 lines; churn hotspot stabilizes
728
+ - Outcome: adapter closure count reduced; `AgentManager` passed directly without wrappers; churn hotspot stabilized
728
729
 
729
730
  ### Step dependencies
730
731
 
@@ -0,0 +1,268 @@
1
+ ---
2
+ issue: 196
3
+ issue_title: "Convert AgentRunner and AgentsMenuHandler to classes, simplify index.ts"
4
+ ---
5
+
6
+ # Convert AgentRunner and AgentsMenuHandler to classes, simplify index.ts
7
+
8
+ ## Problem Statement
9
+
10
+ Two remaining closure factories in pi-subagents mask class-shaped code:
11
+
12
+ 1. `createAgentRunner(runnerIO)` captures `RunnerIO` and returns `{ run, resume }` — the `AgentRunner` interface already exists but lacks a concrete class implementation.
13
+ 2. `createAgentsMenuHandler({...})` captures an 8-field deps object and returns a handler function.
14
+
15
+ With #195 (tool factory → class conversions) complete, these are the last two closure factories.
16
+ After converting them, `index.ts` can be simplified: adapter closures drop, fan-out decreases, and the composition root becomes pure object construction.
17
+
18
+ ## Goals
19
+
20
+ - Convert `createAgentRunner` factory to a concrete `ConcreteAgentRunner` class implementing `AgentRunner`.
21
+ - Convert `createAgentsMenuHandler` factory to an `AgentsMenuHandler` class.
22
+ - Internalize the `getModelLabel` closure from `index.ts` into `AgentsMenuHandler` (it uses `resolveModel` and `getModelLabelFromConfig`, both pure functions the class can import directly).
23
+ - Pass `AgentManager` directly as the `manager` param (it structurally satisfies `AgentMenuManager`), eliminating 3 adapter closures.
24
+ - Simplify `index.ts` by removing eliminated adapter closures and unused imports.
25
+ - Update architecture doc to mark Layer 3 and Layer 4 as done.
26
+
27
+ ## Non-Goals
28
+
29
+ - Changing `runAgent()` or `resumeAgent()` function signatures — they remain as free functions called by the class.
30
+ - Removing the `AgentRunner` interface — it stays as the contract for `AgentManager`.
31
+ - Removing `RunnerIO` type — it stays as the IO boundary for `runAgent()`.
32
+ - Changing the `AgentMenuManager` interface — it stays as the narrow contract; `AgentManager` satisfies it structurally.
33
+ - Removing `AgentMenuDeps` — it is replaced by the class constructor; the type itself is removed.
34
+ - Refactoring `NotificationManager`, `SettingsManager`, or `SessionLifecycleHandler` — those are already class-shaped.
35
+ - Phase 12 work (decompose `renderWidgetLines`, consolidate test duplication).
36
+
37
+ ## Background
38
+
39
+ ### Phase 11 layer structure
40
+
41
+ Phase 11 in `docs/architecture/architecture.md` converts closure factories to classes in four layers:
42
+
43
+ - Layer 0: `SessionContext` interface (#192) ✓
44
+ - Layer 1: Runtime owns context queries (#193) ✓
45
+ - Layer 2: Align interfaces for structural typing (#194) ✓
46
+ - Layer 3: Convert closure factories to classes (#195 tools ✓, #196 runner + menu)
47
+ - Layer 4: Simplify `index.ts` (#196)
48
+
49
+ Issue #196 completes Layer 3 (runner + menu) and Layer 4 (index.ts simplification).
50
+
51
+ ### Current state
52
+
53
+ `createAgentRunner` (3 lines) wraps `runAgent`/`resumeAgent` in an object literal:
54
+
55
+ ```typescript
56
+ export function createAgentRunner(io: RunnerIO): AgentRunner {
57
+ return {
58
+ run: (snapshot, type, prompt, options) => runAgent(snapshot, type, prompt, options, io),
59
+ resume: resumeAgent,
60
+ };
61
+ }
62
+ ```
63
+
64
+ `createAgentsMenuHandler` (200+ lines) captures 8 deps and returns a handler function.
65
+ The deps bag includes `getModelLabel` (a closure built in `index.ts`) and `agentActivity` (a `Map` from runtime).
66
+
67
+ `index.ts` currently has ~23 arrow closures and 27 imports at 229 lines.
68
+
69
+ ### Structural typing confirmation
70
+
71
+ `AgentManager` already has `listAgents()`, `getRecord()`, and `spawnAndWait()` methods that structurally satisfy `AgentMenuManager`.
72
+ The `spawnAndWait` parameter type (`Omit<AgentSpawnConfig, "isBackground">`) is a superset of the menu's `{ description, maxTurns }`, so structural typing matches.
73
+
74
+ ## Design Overview
75
+
76
+ ### ConcreteAgentRunner
77
+
78
+ A minimal class that implements `AgentRunner` by delegating to the free functions:
79
+
80
+ ```typescript
81
+ export class ConcreteAgentRunner implements AgentRunner {
82
+ constructor(private readonly io: RunnerIO) {}
83
+
84
+ async run(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult> {
85
+ return runAgent(snapshot, type, prompt, options, this.io);
86
+ }
87
+
88
+ async resume(session: AgentSession, prompt: string, options?: ResumeOptions): Promise<string> {
89
+ return resumeAgent(session, prompt, options);
90
+ }
91
+ }
92
+ ```
93
+
94
+ The factory function `createAgentRunner` is removed.
95
+ The free functions `runAgent`, `resumeAgent`, `getAgentConversation`, and `normalizeMaxTurns` remain exported — they are used directly by tests and other modules.
96
+
97
+ ### AgentsMenuHandler
98
+
99
+ The class replaces `createAgentsMenuHandler` and `AgentMenuDeps`.
100
+ Constructor params are the subset of deps that are true collaborators:
101
+
102
+ ```typescript
103
+ export class AgentsMenuHandler {
104
+ constructor(
105
+ private readonly manager: AgentMenuManager,
106
+ private readonly registry: AgentTypeRegistry,
107
+ private readonly agentActivity: AgentActivityReader,
108
+ private readonly settings: AgentMenuSettings,
109
+ private readonly fileOps: AgentFileOps,
110
+ private readonly personalAgentsDir: string,
111
+ private readonly projectAgentsDir: string,
112
+ ) {}
113
+
114
+ async handle(ctx: { ui: MenuUI; modelRegistry: ModelRegistry; parentSnapshot: ParentSnapshot }): Promise<void> { ... }
115
+ }
116
+ ```
117
+
118
+ Key design decisions:
119
+
120
+ 1. **`agentActivity` stays as a constructor param** — it is a collaborator used in `viewAgentConversation`.
121
+ The issue's proposed signature omits it, but the class needs it at runtime.
122
+ 2. **`getModelLabel` is internalized** — the class imports `resolveModel` and `getModelLabelFromConfig` directly and computes the label in a private method.
123
+ This eliminates the closure from `index.ts` and removes the `getModelLabel` field from the deps interface.
124
+ 3. **`AgentMenuDeps` is removed** — the class constructor replaces it.
125
+ 4. **The `handle` method** replaces the returned function.
126
+ The inner helpers (`showAgentsMenu`, `showAllAgentsList`, etc.) become private methods.
127
+
128
+ ### index.ts simplification
129
+
130
+ After both conversions:
131
+
132
+ ```typescript
133
+ // Before (adapter closures):
134
+ const agentsMenuHandler = createAgentsMenuHandler({
135
+ manager: {
136
+ listAgents: () => manager.listAgents(),
137
+ getRecord: (id) => manager.getRecord(id),
138
+ spawnAndWait: (...) => manager.spawnAndWait(...),
139
+ },
140
+ registry,
141
+ agentActivity: runtime.agentActivity,
142
+ getModelLabel: (type, modelRegistry) => { ... }, // 7-line closure
143
+ settings,
144
+ fileOps: new FsAgentFileOps(),
145
+ personalAgentsDir: join(getAgentDir(), 'agents'),
146
+ projectAgentsDir: join(process.cwd(), '.pi', 'agents'),
147
+ });
148
+
149
+ // After:
150
+ const agentsMenu = new AgentsMenuHandler(
151
+ manager, registry, runtime.agentActivity,
152
+ settings, new FsAgentFileOps(),
153
+ join(getAgentDir(), 'agents'),
154
+ join(process.cwd(), '.pi', 'agents'),
155
+ );
156
+ ```
157
+
158
+ Eliminated closures: 4 (3 manager method adapters + 1 getModelLabel closure).
159
+ Eliminated imports: `getModelLabelFromConfig`, `resolveModel` (from index.ts), `createAgentRunner`, `type RunnerIO`, `createAgentsMenuHandler`.
160
+
161
+ Remaining adapter closures in `index.ts` (~15) are necessary: event handler registrations, SDK factory callbacks, `pi.sendMessage`/`pi.exec` adapters.
162
+ These are structural — they bridge the Pi SDK's callback-based API to the extension's object-oriented internals.
163
+
164
+ ## Module-Level Changes
165
+
166
+ ### `src/lifecycle/agent-runner.ts`
167
+
168
+ - Add `ConcreteAgentRunner` class implementing `AgentRunner`.
169
+ - Remove `createAgentRunner` factory function.
170
+ - Keep all free functions (`runAgent`, `resumeAgent`, `getAgentConversation`, `normalizeMaxTurns`) and all types exported.
171
+
172
+ ### `src/ui/agent-menu.ts`
173
+
174
+ - Replace `createAgentsMenuHandler` factory with `AgentsMenuHandler` class.
175
+ - Remove `AgentMenuDeps` interface.
176
+ - Add private `getModelLabel` method (internalizes the closure from `index.ts`).
177
+ - Convert inner functions (`showAgentsMenu`, `showAllAgentsList`, `showRunningAgents`, `viewAgentConversation`, `showSettings`) to private methods.
178
+ - Add imports: `resolveModel` from `#src/session/model-resolver`, `getModelLabelFromConfig` from `#src/tools/helpers`.
179
+ - Keep exported interfaces: `AgentMenuManager`, `AgentMenuSettings`, `AgentActivityReader`, `MenuUI`.
180
+
181
+ ### `src/index.ts`
182
+
183
+ - Replace `createAgentRunner(runnerIO)` with `new ConcreteAgentRunner(runnerIO)`.
184
+ - Replace `createAgentsMenuHandler({...})` with `new AgentsMenuHandler(...)`.
185
+ - Replace `agentsMenuHandler({...})` with `agentsMenu.handle({...})`.
186
+ - Remove adapter closures for `manager.listAgents`, `manager.getRecord`, `manager.spawnAndWait`, and `getModelLabel`.
187
+ - Remove unused imports: `createAgentRunner`, `type RunnerIO` → `ConcreteAgentRunner`; `createAgentsMenuHandler` → `AgentsMenuHandler`; `getModelLabelFromConfig`, `resolveModel`.
188
+ - Net effect: ~15 lines removed, 5 imports removed.
189
+
190
+ ### `docs/architecture/architecture.md`
191
+
192
+ - Mark Layer 3 remaining items (runner, menu) as done.
193
+ - Mark Layer 4 as done.
194
+ - Update the factory→class table entries for `createAgentRunner` and `createAgentsMenuHandler` with ✓.
195
+
196
+ ## Test Impact Analysis
197
+
198
+ ### `test/lifecycle/agent-runner.test.ts` (and siblings)
199
+
200
+ No changes needed.
201
+ Tests call `runAgent()` and `resumeAgent()` directly — they never use `createAgentRunner`.
202
+ The `ConcreteAgentRunner` class is a trivial two-method delegation wrapper tested implicitly through `index.ts` integration and explicitly through one new unit test.
203
+
204
+ ### `test/ui/agent-menu.test.ts`
205
+
206
+ Tests need updating:
207
+
208
+ 1. Replace `createAgentsMenuHandler(makeDeps())` with `new AgentsMenuHandler(...)`.
209
+ 2. Replace `handler(params)` with `handler.handle(params)`.
210
+ 3. Remove `getModelLabel` from `makeDeps()` — it is now an internal method.
211
+ 4. Remove `AgentMenuDeps` import; update `makeDeps` to construct positional args or a helper that returns a handler directly.
212
+
213
+ No test logic changes — only call-site updates for the new API shape.
214
+
215
+ ### New tests
216
+
217
+ One new unit test for `ConcreteAgentRunner`: verify it delegates `run` and `resume` to the underlying functions.
218
+
219
+ ## TDD Order
220
+
221
+ 1. **Add `ConcreteAgentRunner` class alongside factory.**
222
+ Add the class to `agent-runner.ts`, keep `createAgentRunner` temporarily.
223
+ Add a unit test verifying delegation.
224
+ `test: add ConcreteAgentRunner delegation test`
225
+
226
+ 2. **Switch `index.ts` to `ConcreteAgentRunner`, remove factory.**
227
+ Replace `createAgentRunner(runnerIO)` with `new ConcreteAgentRunner(runnerIO)`.
228
+ Remove the `createAgentRunner` factory function.
229
+ Update imports.
230
+ `refactor: replace createAgentRunner with ConcreteAgentRunner class`
231
+
232
+ 3. **Convert `createAgentsMenuHandler` to `AgentsMenuHandler` class.**
233
+ Replace factory function with class.
234
+ Move inner functions to private methods.
235
+ Internalize `getModelLabel` as a private method.
236
+ Remove `AgentMenuDeps` interface.
237
+ `refactor: convert createAgentsMenuHandler to AgentsMenuHandler class`
238
+
239
+ 4. **Update `agent-menu.test.ts` for class API.**
240
+ Replace `createAgentsMenuHandler(makeDeps())` with class construction.
241
+ Replace `handler(params)` with `handler.handle(params)`.
242
+ Remove `getModelLabel` from test deps.
243
+ All existing tests pass with updated call sites.
244
+ `test: update agent-menu tests for AgentsMenuHandler class`
245
+
246
+ 5. **Simplify `index.ts` wiring.**
247
+ Replace `createAgentsMenuHandler({...})` with `new AgentsMenuHandler(...)`.
248
+ Pass `manager` directly (structural typing).
249
+ Remove adapter closures and unused imports.
250
+ `refactor: simplify index.ts wiring for AgentsMenuHandler`
251
+
252
+ 6. **Update architecture doc.**
253
+ Mark Layer 3 remaining items and Layer 4 as done in `docs/architecture/architecture.md`.
254
+ `docs: mark Phase 11 Layer 3 and Layer 4 complete`
255
+
256
+ ## Risks and Mitigations
257
+
258
+ | Risk | Mitigation |
259
+ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
260
+ | `AgentManager` might not structurally satisfy `AgentMenuManager` | Confirmed: `listAgents()`, `getRecord()`, `spawnAndWait()` signatures are compatible. `pnpm run check` in step 5 verifies. |
261
+ | Internalized `getModelLabel` might diverge from the closure | The private method uses the same `resolveModel` and `getModelLabelFromConfig` imports — identical logic, just moved. |
262
+ | Tests that use `AgentMenuDeps` type break when removed | Step 4 updates all test call sites before step 5 changes production code. The test file is 215 lines — manageable in one step. |
263
+ | `agentActivity` missing from constructor | Included in the class constructor (diverging from the issue's proposed signature which omits it). |
264
+
265
+ ## Open Questions
266
+
267
+ None — the issue's proposed design is clear and the implementation is mechanical.
268
+ The one deviation (keeping `agentActivity` as a constructor param) is necessary and minimal.
@@ -0,0 +1,42 @@
1
+ ---
2
+ issue: 196
3
+ issue_title: "Convert AgentRunner and AgentsMenuHandler to classes, simplify index.ts"
4
+ ---
5
+
6
+ # Retro: #196 — Convert AgentRunner and AgentsMenuHandler to classes, simplify index.ts
7
+
8
+ ## Stage: Planning (2026-05-25T14:35:46Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced a 6-step TDD plan covering the final two closure-factory-to-class conversions (`createAgentRunner` → `ConcreteAgentRunner`, `createAgentsMenuHandler` → `AgentsMenuHandler`) and the subsequent `index.ts` simplification.
13
+ Confirmed that `AgentManager` structurally satisfies `AgentMenuManager`, enabling direct pass-through without adapter closures.
14
+
15
+ ### Observations
16
+
17
+ - The issue's proposed `AgentsMenuHandler` constructor omits `agentActivity`, but the class needs it for `viewAgentConversation`.
18
+ Plan includes it as a constructor param — minimal deviation from the issue.
19
+ - `getModelLabel` can be internalized into `AgentsMenuHandler` since it only uses two pure imported functions (`resolveModel`, `getModelLabelFromConfig`) plus the registry (already a constructor param).
20
+ This eliminates a 7-line closure from `index.ts`.
21
+ - Tests for `agent-runner` call `runAgent`/`resumeAgent` directly — no test uses `createAgentRunner`, so the runner conversion has zero test impact.
22
+ - The `agent-menu.test.ts` file is 215 lines and needs call-site updates (factory → class constructor + `.handle()`), but no logic changes.
23
+ - After both conversions, `index.ts` loses ~5 imports and ~4 adapter closures.
24
+ The remaining ~15 closures are structural (event registrations, SDK factory callbacks) and cannot be eliminated.
25
+
26
+ ## Stage: Implementation — TDD (2026-05-25T14:56:11Z)
27
+
28
+ ### Session summary
29
+
30
+ Completed all 6 plan steps (collapsed into 5 commits: steps 3 and 5 merged).
31
+ Baseline was 854 tests across 53 files; final suite is 856 tests across 54 files (+2 tests, +1 file for `concrete-agent-runner.test.ts`).
32
+ All type-check, lint, and dead-code gates pass clean.
33
+
34
+ ### Observations
35
+
36
+ - Plan steps 3 and 5 had to be merged into a single commit: removing `createAgentsMenuHandler` immediately broke `index.ts` imports, so the `index.ts` update could not wait for a separate commit.
37
+ This is a known coupling when a factory's only call site is in `index.ts`.
38
+ - The `AgentsMenuHandler` class constructor includes `agentActivity` as planned (the issue's proposed signature omitted it; the plan's deviation was correct).
39
+ - `getModelLabel` internalization was clean: `resolveModel` and `getModelLabelFromConfig` are pure functions the class imports directly.
40
+ - `AgentManager` structurally satisfies `AgentMenuManager` with no adapter closures — confirmed by `pnpm run check` passing immediately.
41
+ - The `agent-menu.test.ts` refactor replaced `Partial<AgentMenuDeps>` overrides with a `makeHandler(opts)` helper that returns both the handler and collaborator stubs, which is cleaner for assertion.
42
+ - `rumdl` emitted 3 warnings in `pnpm run lint` — these are pre-existing and unrelated to this change (lint passes for markdown linting, the warnings are from biome/eslint steps that auto-fixed nothing).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "7.2.4",
3
+ "version": "7.2.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/service.ts"
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
1
+ /* eslint-disable @typescript-eslint/no-unsafe-argument -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */
2
2
  /**
3
3
  * pi-agents — A pi extension providing Claude Code-style autonomous sub-agents.
4
4
  *
@@ -24,7 +24,7 @@ import { AgentTypeRegistry } from "#src/config/agent-types";
24
24
  import { loadCustomAgents } from "#src/config/custom-agents";
25
25
  import { SessionLifecycleHandler, ToolStartHandler } from "#src/handlers/index";
26
26
  import { AgentManager, type AgentManagerObserver } from "#src/lifecycle/agent-manager";
27
- import { createAgentRunner, type RunnerIO } from "#src/lifecycle/agent-runner";
27
+ import { ConcreteAgentRunner, type RunnerIO } from "#src/lifecycle/agent-runner";
28
28
  import { buildParentSnapshot } from "#src/lifecycle/parent-snapshot";
29
29
  import { GitWorktreeManager } from "#src/lifecycle/worktree";
30
30
  import { buildEventData, type NotificationDetails, NotificationManager } from "#src/observation/notification";
@@ -41,10 +41,9 @@ import { preloadSkills } from "#src/session/skill-loader";
41
41
  import { SettingsManager } from "#src/settings";
42
42
  import { AgentTool } from "#src/tools/agent-tool";
43
43
  import { GetResultTool } from "#src/tools/get-result-tool";
44
- import { getModelLabelFromConfig } from "#src/tools/helpers";
45
44
  import { SteerTool } from "#src/tools/steer-tool";
46
45
  import { FsAgentFileOps } from "#src/ui/agent-file-ops";
47
- import { createAgentsMenuHandler } from "#src/ui/agent-menu";
46
+ import { AgentsMenuHandler } from "#src/ui/agent-menu";
48
47
  import { AgentWidget } from "#src/ui/agent-widget";
49
48
 
50
49
  export default function (pi: ExtensionAPI) {
@@ -147,7 +146,7 @@ export default function (pi: ExtensionAPI) {
147
146
  };
148
147
 
149
148
  const manager = new AgentManager({
150
- runner: createAgentRunner(runnerIO),
149
+ runner: new ConcreteAgentRunner(runnerIO),
151
150
  worktrees: new GitWorktreeManager(process.cwd()),
152
151
  exec: (cmd, args, opts) => pi.exec(cmd, args, opts),
153
152
  registry,
@@ -193,33 +192,20 @@ export default function (pi: ExtensionAPI) {
193
192
 
194
193
  // ---- /agents interactive menu ----
195
194
 
196
- const agentsMenuHandler = createAgentsMenuHandler({
197
- manager: {
198
- listAgents: () => manager.listAgents(),
199
- getRecord: (id) => manager.getRecord(id),
200
- spawnAndWait: (snapshot, type, prompt, opts) => manager.spawnAndWait(snapshot, type, prompt, opts),
201
- },
195
+ const agentsMenu = new AgentsMenuHandler(
196
+ manager,
202
197
  registry,
203
- agentActivity: runtime.agentActivity,
204
- getModelLabel: (type, modelRegistry) => {
205
- const cfg = registry.resolveAgentConfig(type);
206
- if (!cfg.model) return 'inherit';
207
- if (modelRegistry) {
208
- const resolved = resolveModel(cfg.model, modelRegistry);
209
- if (typeof resolved === 'string') return 'inherit';
210
- }
211
- return getModelLabelFromConfig(cfg.model);
212
- },
198
+ runtime.agentActivity,
213
199
  settings,
214
- fileOps: new FsAgentFileOps(),
215
- personalAgentsDir: join(getAgentDir(), 'agents'),
216
- projectAgentsDir: join(process.cwd(), '.pi', 'agents'),
217
- });
200
+ new FsAgentFileOps(),
201
+ join(getAgentDir(), "agents"),
202
+ join(process.cwd(), ".pi", "agents"),
203
+ );
218
204
 
219
- pi.registerCommand('agents', {
220
- description: 'Manage agents',
205
+ pi.registerCommand("agents", {
206
+ description: "Manage agents",
221
207
  handler: async (_args, ctx) => {
222
- await agentsMenuHandler({
208
+ await agentsMenu.handle({
223
209
  ui: ctx.ui,
224
210
  modelRegistry: ctx.modelRegistry,
225
211
  parentSnapshot: buildParentSnapshot(ctx),
@@ -202,17 +202,23 @@ export interface AgentRunner {
202
202
  }
203
203
 
204
204
  /**
205
- * Create an AgentRunner backed by the given IO boundary.
205
+ * Concrete AgentRunner backed by a RunnerIO boundary.
206
206
  *
207
207
  * Captures io at construction time so AgentManager remains IO-unaware.
208
208
  */
209
- export function createAgentRunner(io: RunnerIO): AgentRunner {
210
- return {
211
- run: (snapshot, type, prompt, options) => runAgent(snapshot, type, prompt, options, io),
212
- resume: resumeAgent,
213
- };
209
+ export class ConcreteAgentRunner implements AgentRunner {
210
+ constructor(private readonly io: RunnerIO) {}
211
+
212
+ run(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult> {
213
+ return runAgent(snapshot, type, prompt, options, this.io);
214
+ }
215
+
216
+ resume(session: AgentSession, prompt: string, options?: ResumeOptions): Promise<string> {
217
+ return resumeAgent(session, prompt, options);
218
+ }
214
219
  }
215
220
 
221
+
216
222
  // ── Private helpers ───────────────────────────────────────────────────────────
217
223
 
218
224
  /**
@@ -2,7 +2,8 @@
2
2
  import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
3
  import { AgentTypeRegistry } from "#src/config/agent-types";
4
4
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
5
- import type { ModelRegistry } from "#src/session/model-resolver";
5
+ import { type ModelRegistry, resolveModel } from "#src/session/model-resolver";
6
+ import { getModelLabelFromConfig } from "#src/tools/helpers";
6
7
  import type { AgentConfig, AgentRecord } from "#src/types";
7
8
  import type { AgentActivityTracker } from "#src/ui/agent-activity-tracker";
8
9
  import { createAgentConfigEditor } from "#src/ui/agent-config-editor";
@@ -10,7 +11,7 @@ import { createAgentCreationWizard } from "#src/ui/agent-creation-wizard";
10
11
  import type { AgentFileOps } from "#src/ui/agent-file-ops";
11
12
  import { formatDuration, getDisplayName } from "#src/ui/display";
12
13
 
13
- // ---- Deps interface ----
14
+ // ---- Narrow interfaces ----
14
15
 
15
16
  /** Narrow manager interface for menu operations. */
16
17
  export interface AgentMenuManager {
@@ -43,19 +44,6 @@ export interface AgentActivityReader {
43
44
  get(id: string): AgentActivityTracker | undefined;
44
45
  }
45
46
 
46
- export interface AgentMenuDeps {
47
- manager: AgentMenuManager;
48
- registry: AgentTypeRegistry;
49
- agentActivity: AgentActivityReader;
50
- /** Resolve model label for a given agent type + registry. */
51
- getModelLabel: (type: string, registry?: ModelRegistry) => string;
52
- /** Settings manager — owns in-memory values and persistence. */
53
- settings: AgentMenuSettings;
54
- fileOps: AgentFileOps;
55
- personalAgentsDir: string;
56
- projectAgentsDir: string;
57
- }
58
-
59
47
  // ---- Narrow UI context types ----
60
48
 
61
49
  /** Narrow UI interface — only the ctx.ui methods menu handlers actually call. */
@@ -68,48 +56,74 @@ export interface MenuUI {
68
56
  custom<R>(component: any, options?: any): Promise<R>;
69
57
  }
70
58
 
71
- // ---- Factory ----
59
+ // ---- Class ----
72
60
 
73
61
  /**
74
- * Create the `/agents` command handler.
75
- * Returns a function suitable for `pi.registerCommand("agents", { handler })`.
62
+ * Handler for the `/agents` slash command.
63
+ *
64
+ * Call `handle(ctx)` from the Pi command registration to open the interactive menu.
76
65
  */
77
- export function createAgentsMenuHandler({
78
- manager,
79
- registry,
80
- agentActivity,
81
- getModelLabel,
82
- settings,
83
- fileOps,
84
- personalAgentsDir,
85
- projectAgentsDir,
86
- }: AgentMenuDeps) {
87
- const editor = createAgentConfigEditor(
88
- fileOps,
89
- registry,
90
- personalAgentsDir,
91
- projectAgentsDir,
92
- );
93
-
94
- const wizard = createAgentCreationWizard({
95
- fileOps,
96
- manager,
97
- registry,
98
- personalAgentsDir,
99
- projectAgentsDir,
100
- });
101
-
102
- async function showAgentsMenu(
66
+ export class AgentsMenuHandler {
67
+ private readonly editor: ReturnType<typeof createAgentConfigEditor>;
68
+ private readonly wizard: ReturnType<typeof createAgentCreationWizard>;
69
+
70
+ constructor(
71
+ private readonly manager: AgentMenuManager,
72
+ private readonly registry: AgentTypeRegistry,
73
+ private readonly agentActivity: AgentActivityReader,
74
+ private readonly settings: AgentMenuSettings,
75
+ private readonly fileOps: AgentFileOps,
76
+ private readonly personalAgentsDir: string,
77
+ private readonly projectAgentsDir: string,
78
+ ) {
79
+ this.editor = createAgentConfigEditor(
80
+ fileOps,
81
+ registry,
82
+ personalAgentsDir,
83
+ projectAgentsDir,
84
+ );
85
+ this.wizard = createAgentCreationWizard({
86
+ fileOps,
87
+ manager,
88
+ registry,
89
+ personalAgentsDir,
90
+ projectAgentsDir,
91
+ });
92
+ }
93
+
94
+ async handle({
95
+ ui,
96
+ modelRegistry,
97
+ parentSnapshot,
98
+ }: {
99
+ ui: MenuUI;
100
+ modelRegistry: ModelRegistry;
101
+ parentSnapshot: ParentSnapshot;
102
+ }): Promise<void> {
103
+ await this.showAgentsMenu(ui, modelRegistry, parentSnapshot);
104
+ }
105
+
106
+ private getModelLabel(type: string, modelRegistry?: ModelRegistry): string {
107
+ const cfg = this.registry.resolveAgentConfig(type);
108
+ if (!cfg.model) return "inherit";
109
+ if (modelRegistry) {
110
+ const resolved = resolveModel(cfg.model, modelRegistry);
111
+ if (typeof resolved === "string") return "inherit";
112
+ }
113
+ return getModelLabelFromConfig(cfg.model);
114
+ }
115
+
116
+ private async showAgentsMenu(
103
117
  ui: MenuUI,
104
118
  modelRegistry: ModelRegistry,
105
119
  parentSnapshot: ParentSnapshot,
106
- ) {
107
- registry.reload();
108
- const allNames = registry.getAllTypes();
120
+ ): Promise<void> {
121
+ this.registry.reload();
122
+ const allNames = this.registry.getAllTypes();
109
123
 
110
124
  const options: string[] = [];
111
125
 
112
- const agents = manager.listAgents();
126
+ const agents = this.manager.listAgents();
113
127
  if (agents.length > 0) {
114
128
  const running = agents.filter(
115
129
  (a) => a.status === "running" || a.status === "queued",
@@ -144,21 +158,21 @@ export function createAgentsMenuHandler({
144
158
  if (!choice) return;
145
159
 
146
160
  if (choice.startsWith("Running agents (")) {
147
- await showRunningAgents(ui);
148
- await showAgentsMenu(ui, modelRegistry, parentSnapshot);
161
+ await this.showRunningAgents(ui);
162
+ await this.showAgentsMenu(ui, modelRegistry, parentSnapshot);
149
163
  } else if (choice.startsWith("Agent types (")) {
150
- await showAllAgentsList(ui, modelRegistry);
151
- await showAgentsMenu(ui, modelRegistry, parentSnapshot);
164
+ await this.showAllAgentsList(ui, modelRegistry);
165
+ await this.showAgentsMenu(ui, modelRegistry, parentSnapshot);
152
166
  } else if (choice === "Create new agent") {
153
- await wizard.showCreateWizard(ui, parentSnapshot);
167
+ await this.wizard.showCreateWizard(ui, parentSnapshot);
154
168
  } else if (choice === "Settings") {
155
- await showSettings(ui);
156
- await showAgentsMenu(ui, modelRegistry, parentSnapshot);
169
+ await this.showSettings(ui);
170
+ await this.showAgentsMenu(ui, modelRegistry, parentSnapshot);
157
171
  }
158
172
  }
159
173
 
160
- async function showAllAgentsList(ui: MenuUI, modelRegistry: ModelRegistry) {
161
- const allNames = registry.getAllTypes();
174
+ private async showAllAgentsList(ui: MenuUI, modelRegistry: ModelRegistry): Promise<void> {
175
+ const allNames = this.registry.getAllTypes();
162
176
  if (allNames.length === 0) {
163
177
  ui.notify("No agents.", "info");
164
178
  return;
@@ -173,9 +187,9 @@ export function createAgentsMenuHandler({
173
187
  };
174
188
 
175
189
  const entries = allNames.map((name) => {
176
- const cfg = registry.resolveAgentConfig(name);
190
+ const cfg = this.registry.resolveAgentConfig(name);
177
191
  const disabled = cfg.enabled === false;
178
- const model = getModelLabel(name, modelRegistry);
192
+ const model = this.getModelLabel(name, modelRegistry);
179
193
  const indicator = sourceIndicator(cfg);
180
194
  const prefix = `${indicator}${name} · ${model}`;
181
195
  const desc = disabled ? "(disabled)" : cfg.description;
@@ -184,11 +198,11 @@ export function createAgentsMenuHandler({
184
198
  const maxPrefix = Math.max(...entries.map((e) => e.prefix.length));
185
199
 
186
200
  const hasCustom = allNames.some((n) => {
187
- const c = registry.resolveAgentConfig(n);
201
+ const c = this.registry.resolveAgentConfig(n);
188
202
  return !c.isDefault && c.enabled !== false;
189
203
  });
190
204
  const hasDisabled = allNames.some(
191
- (n) => registry.resolveAgentConfig(n).enabled === false,
205
+ (n) => this.registry.resolveAgentConfig(n).enabled === false,
192
206
  );
193
207
  const legendParts: string[] = [];
194
208
  if (hasCustom) legendParts.push("• = project ◦ = global");
@@ -207,21 +221,21 @@ export function createAgentsMenuHandler({
207
221
  .split(" · ")[0]
208
222
  .replace(/^[•◦✕\s]+/, "")
209
223
  .trim();
210
- if (registry.resolveType(agentName) != null) {
211
- await editor.showAgentDetail(ui, agentName);
212
- await showAllAgentsList(ui, modelRegistry);
224
+ if (this.registry.resolveType(agentName) != null) {
225
+ await this.editor.showAgentDetail(ui, agentName);
226
+ await this.showAllAgentsList(ui, modelRegistry);
213
227
  }
214
228
  }
215
229
 
216
- async function showRunningAgents(ui: MenuUI) {
217
- const agents = manager.listAgents();
230
+ private async showRunningAgents(ui: MenuUI): Promise<void> {
231
+ const agents = this.manager.listAgents();
218
232
  if (agents.length === 0) {
219
233
  ui.notify("No agents.", "info");
220
234
  return;
221
235
  }
222
236
 
223
237
  const options = agents.map((a) => {
224
- const dn = getDisplayName(a.type, registry);
238
+ const dn = getDisplayName(a.type, this.registry);
225
239
  const dur = formatDuration(a.startedAt, a.completedAt);
226
240
  return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`;
227
241
  });
@@ -233,11 +247,11 @@ export function createAgentsMenuHandler({
233
247
  if (idx < 0) return;
234
248
  const record = agents[idx];
235
249
 
236
- await viewAgentConversation(ui, record);
237
- await showRunningAgents(ui);
250
+ await this.viewAgentConversation(ui, record);
251
+ await this.showRunningAgents(ui);
238
252
  }
239
253
 
240
- async function viewAgentConversation(ui: MenuUI, record: AgentRecord) {
254
+ private async viewAgentConversation(ui: MenuUI, record: AgentRecord): Promise<void> {
241
255
  const session = record.session;
242
256
  if (!session) {
243
257
  ui.notify(
@@ -250,7 +264,7 @@ export function createAgentsMenuHandler({
250
264
  const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import(
251
265
  "./conversation-viewer"
252
266
  );
253
- const activity = agentActivity.get(record.id);
267
+ const activity = this.agentActivity.get(record.id);
254
268
 
255
269
  await ui.custom<undefined>(
256
270
  (tui: any, theme: any, _keybindings: any, done: any) => {
@@ -261,7 +275,7 @@ export function createAgentsMenuHandler({
261
275
  activity,
262
276
  theme,
263
277
  done,
264
- registry,
278
+ registry: this.registry,
265
279
  wrapText: wrapTextWithAnsi,
266
280
  });
267
281
  },
@@ -276,23 +290,23 @@ export function createAgentsMenuHandler({
276
290
  );
277
291
  }
278
292
 
279
- async function showSettings(ui: MenuUI) {
293
+ private async showSettings(ui: MenuUI): Promise<void> {
280
294
  const choice = await ui.select("Settings", [
281
- `Max concurrency (current: ${settings.maxConcurrent})`,
282
- `Default max turns (current: ${settings.defaultMaxTurns ?? "unlimited"})`,
283
- `Grace turns (current: ${settings.graceTurns})`,
295
+ `Max concurrency (current: ${this.settings.maxConcurrent})`,
296
+ `Default max turns (current: ${this.settings.defaultMaxTurns ?? "unlimited"})`,
297
+ `Grace turns (current: ${this.settings.graceTurns})`,
284
298
  ]);
285
299
  if (!choice) return;
286
300
 
287
301
  if (choice.startsWith("Max concurrency")) {
288
302
  const val = await ui.input(
289
303
  "Max concurrent background agents",
290
- String(settings.maxConcurrent),
304
+ String(this.settings.maxConcurrent),
291
305
  );
292
306
  if (val) {
293
307
  const n = parseInt(val, 10);
294
308
  if (n >= 1) {
295
- const toast = settings.applyMaxConcurrent(n);
309
+ const toast = this.settings.applyMaxConcurrent(n);
296
310
  ui.notify(toast.message, toast.level);
297
311
  } else {
298
312
  ui.notify("Must be a positive integer.", "warning");
@@ -301,12 +315,12 @@ export function createAgentsMenuHandler({
301
315
  } else if (choice.startsWith("Default max turns")) {
302
316
  const val = await ui.input(
303
317
  "Default max turns before wrap-up (0 = unlimited)",
304
- String(settings.defaultMaxTurns ?? 0),
318
+ String(this.settings.defaultMaxTurns ?? 0),
305
319
  );
306
320
  if (val) {
307
321
  const n = parseInt(val, 10);
308
322
  if (n >= 0) {
309
- const toast = settings.applyDefaultMaxTurns(n);
323
+ const toast = this.settings.applyDefaultMaxTurns(n);
310
324
  ui.notify(toast.message, toast.level);
311
325
  } else {
312
326
  ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
@@ -315,12 +329,12 @@ export function createAgentsMenuHandler({
315
329
  } else if (choice.startsWith("Grace turns")) {
316
330
  const val = await ui.input(
317
331
  "Grace turns after wrap-up steer",
318
- String(settings.graceTurns),
332
+ String(this.settings.graceTurns),
319
333
  );
320
334
  if (val) {
321
335
  const n = parseInt(val, 10);
322
336
  if (n >= 1) {
323
- const toast = settings.applyGraceTurns(n);
337
+ const toast = this.settings.applyGraceTurns(n);
324
338
  ui.notify(toast.message, toast.level);
325
339
  } else {
326
340
  ui.notify("Must be a positive integer.", "warning");
@@ -328,16 +342,4 @@ export function createAgentsMenuHandler({
328
342
  }
329
343
  }
330
344
  }
331
-
332
- return async ({
333
- ui,
334
- modelRegistry,
335
- parentSnapshot,
336
- }: {
337
- ui: MenuUI;
338
- modelRegistry: ModelRegistry;
339
- parentSnapshot: ParentSnapshot;
340
- }) => {
341
- await showAgentsMenu(ui, modelRegistry, parentSnapshot);
342
- };
343
345
  }