@gotgenes/pi-subagents 6.3.1 → 6.4.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.
@@ -0,0 +1,322 @@
1
+ ---
2
+ issue: 108
3
+ issue_title: "refactor(pi-subagents): extract AgentTypeRegistry class from module-scoped state"
4
+ ---
5
+
6
+ # Extract AgentTypeRegistry class
7
+
8
+ ## Problem Statement
9
+
10
+ `agent-types.ts` manages a module-scoped `Map<string, AgentConfig>` mutated by `registerAgents()` and read by 12+ call sites across 6 files.
11
+ This is global mutable state hidden behind free functions — tests must call `registerAgents(new Map())` in `beforeEach` to reset it, and `reloadCustomAgents` is threaded as a callback through `AgentToolDeps` and `AgentMenuDeps` because there is no object to own the reload.
12
+
13
+ ## Goals
14
+
15
+ - Wrap the module-scoped `agents` Map and its free functions into an injectable `AgentTypeRegistry` class.
16
+ - Replace the `reloadCustomAgents` callback (threaded through 2 dependency bags) with `registry.reload()`.
17
+ - Move `DEFAULT_AGENT_NAMES` from `types.ts` to the registry (it is a constant, not a type).
18
+ - Enable test isolation without module resets — each test creates its own registry instance.
19
+ - Use lift-and-shift: introduce the class alongside the free functions, migrate consumers incrementally, then remove the free functions.
20
+
21
+ ## Non-Goals
22
+
23
+ - `SettingsManager` extraction (#109) — separate Phase 7 step.
24
+ - `AgentActivityTracker` extraction (#110) — separate Phase 7 step.
25
+ - Splitting `AgentRecord` lifecycle state (#111) — separate Phase 7 step.
26
+ - Narrowing `AgentConfig` (21 fields) — tracked in the architecture doc but out of scope.
27
+ - Moving `BUILTIN_TOOL_NAMES` — it is a constant with no Map dependency, stays as a module export.
28
+
29
+ ## Background
30
+
31
+ ### Architecture reference
32
+
33
+ Phase 7, Step A1 in `docs/architecture/architecture.md`.
34
+ Steps A1–A3 are independent and can proceed in any order.
35
+ This plan addresses A1 only.
36
+
37
+ ### Relevant modules
38
+
39
+ | Module | Role | agent-types dependency |
40
+ | --------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
41
+ | `agent-types.ts` | Module-scoped `agents` Map + 11 free functions | Owns the state |
42
+ | `default-agents.ts` | `DEFAULT_AGENTS` Map constant | Read by `registerAgents` |
43
+ | `custom-agents.ts` | `loadCustomAgents()` → disk scan | Imports `BUILTIN_TOOL_NAMES` |
44
+ | `session-config.ts` | `assembleSessionConfig()` | Imports `resolveAgentConfig`, `getToolNamesForType`, `getMemoryToolNames`, `getReadOnlyMemoryToolNames` |
45
+ | `agent-runner.ts` | `runAgent()` → calls `assembleSessionConfig` | No direct import (transitive via session-config) |
46
+ | `agent-manager.ts` | `AgentManager` → calls runner | No direct import |
47
+ | `tools/agent-tool.ts` | Agent tool definition | Imports `resolveAgentConfig`, `resolveType`; receives `reloadCustomAgents` via `AgentToolDeps` |
48
+ | `ui/agent-menu.ts` | `/agents` command handler | Imports `BUILTIN_TOOL_NAMES`, `getAllTypes`, `resolveAgentConfig`, `resolveType`; receives `reloadCustomAgents` via `AgentMenuDeps` |
49
+ | `ui/agent-widget.ts` | Widget + `getDisplayName` / `getPromptModeLabel` helpers | Imports `resolveAgentConfig` |
50
+ | `ui/conversation-viewer.ts` | Live conversation overlay | Imports `getDisplayName`, `getPromptModeLabel` from `agent-widget.ts` |
51
+ | `tools/get-result-tool.ts` | `get_subagent_result` tool | Imports `getDisplayName` from `agent-widget.ts` |
52
+ | `index.ts` | Extension entry point, wiring | Imports `registerAgents`, `getAvailableTypes`, `getDefaultAgentNames`, `getUserAgentNames`, `resolveAgentConfig`; defines `reloadCustomAgents` closure |
53
+
54
+ ### Test files affected
55
+
56
+ | Test file | Current agent-types coupling | Change needed |
57
+ | -------------------------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
58
+ | `agent-types.test.ts` (333 lines) | Tests free functions directly, calls `registerAgents()` in `beforeEach` | Add class tests; eventually migrate free-function tests to class tests |
59
+ | `session-config.test.ts` (601 lines) | `vi.mock("../src/agent-types.js")` | Pass mock registry param, remove module mock |
60
+ | `agent-runner.test.ts` (302 lines) | `vi.mock("../src/agent-types.js")` | Provide mock registry in `RunOptions`, remove module mock |
61
+ | `agent-runner-extension-tools.test.ts` (307 lines) | `vi.mock("../src/agent-types.js")` | Same as `agent-runner.test.ts` |
62
+ | `tools/agent-tool.test.ts` (240 lines) | Mocks `reloadCustomAgents` in deps | Replace with `registry` in deps |
63
+ | `ui/agent-menu.test.ts` (184 lines) | `vi.mock("../../src/agent-types.js")`, mocks `reloadCustomAgents` in deps | Replace with `registry` in deps, remove module mock |
64
+ | `agent-widget.test.ts` (26 lines) | Minimal | Pass registry to widget constructor |
65
+ | `agent-manager.test.ts` (712 lines) | No `agent-types.js` mock | Add registry to constructor options |
66
+
67
+ ### Constraints from AGENTS.md / code-design skill
68
+
69
+ - **ISP:** `session-config.ts` should accept a narrow interface (only `resolveAgentConfig` + `getToolNamesForType`), not the full registry class.
70
+ - **DIP:** Accept collaborators as parameters; keep IO at the edges.
71
+ - **Pi SDK boundaries:** Pure helpers must not import Pi SDK types.
72
+ - **Lift-and-shift:** Never plan a single step that rewrites an entire large test file at once.
73
+
74
+ ## Design Overview
75
+
76
+ ### `AgentTypeRegistry` class
77
+
78
+ ```typescript
79
+ export class AgentTypeRegistry {
80
+ private agents = new Map<string, AgentConfig>();
81
+
82
+ constructor(private loadUserAgents: () => Map<string, AgentConfig>) {
83
+ this.reload();
84
+ }
85
+
86
+ /** Re-scan custom agents from disk and merge with defaults. */
87
+ reload(): void { /* clear + merge DEFAULT_AGENTS + loadUserAgents() */ }
88
+
89
+ resolveAgentConfig(type: string): AgentConfig { /* ... */ }
90
+ resolveType(name: string): string | undefined { /* ... */ }
91
+ getAvailableTypes(): string[] { /* ... */ }
92
+ getAllTypes(): string[] { /* ... */ }
93
+ getDefaultAgentNames(): string[] { /* ... */ }
94
+ getUserAgentNames(): string[] { /* ... */ }
95
+ isValidType(type: string): boolean { /* ... */ }
96
+ getToolNamesForType(type: string): string[] { /* ... */ }
97
+
98
+ static readonly DEFAULT_AGENT_NAMES = ["general-purpose", "Explore", "Plan"] as const;
99
+ }
100
+ ```
101
+
102
+ The constructor accepts a `loadUserAgents` callback (typically `() => loadCustomAgents(process.cwd())`).
103
+ This keeps `process.cwd()` at the edge (in `index.ts`) and makes tests trivial — they pass `() => new Map()` or a fixture.
104
+
105
+ ### What stays as free functions
106
+
107
+ - **`BUILTIN_TOOL_NAMES`** — constant array, no Map dependency.
108
+ Stays as a module-level export in `agent-types.ts`.
109
+ - **`getMemoryToolNames` / `getReadOnlyMemoryToolNames`** — pure functions over constant arrays, no Map access.
110
+ Stay as module-level exports.
111
+
112
+ ### Narrow interface for `session-config.ts`
113
+
114
+ ```typescript
115
+ /** Narrow registry interface for session-config (ISP). */
116
+ export interface AgentConfigLookup {
117
+ resolveAgentConfig(type: string): AgentConfig;
118
+ getToolNamesForType(type: string): string[];
119
+ }
120
+ ```
121
+
122
+ `assembleSessionConfig` gains a `registry: AgentConfigLookup` parameter.
123
+ Tests construct a plain object satisfying this interface — no class instantiation needed.
124
+
125
+ ### Threading through the call chain
126
+
127
+ `session-config.ts` ← called by `agent-runner.ts` ← called by `AgentManager`:
128
+
129
+ 1. `assembleSessionConfig(type, ctx, options, env, registry)` — new param.
130
+ 2. `RunOptions` gains `registry: AgentConfigLookup`.
131
+ 3. `AgentManagerOptions` gains `registry: AgentTypeRegistry`.
132
+ 4. `AgentManager` stores the registry and passes it to the runner via `RunOptions`.
133
+
134
+ ### Replacing `reloadCustomAgents` callback
135
+
136
+ Before:
137
+
138
+ ```typescript
139
+ // index.ts
140
+ const reloadCustomAgents = () => {
141
+ const userAgents = loadCustomAgents(process.cwd());
142
+ registerAgents(userAgents);
143
+ };
144
+
145
+ // AgentToolDeps / AgentMenuDeps
146
+ reloadCustomAgents: () => void;
147
+ ```
148
+
149
+ After:
150
+
151
+ ```typescript
152
+ // index.ts
153
+ const registry = new AgentTypeRegistry(() => loadCustomAgents(process.cwd()));
154
+
155
+ // AgentToolDeps / AgentMenuDeps
156
+ registry: AgentTypeRegistry;
157
+
158
+ // Callers use:
159
+ deps.registry.reload();
160
+ ```
161
+
162
+ ### Display helpers (`getDisplayName`, `getPromptModeLabel`)
163
+
164
+ These functions in `agent-widget.ts` call `resolveAgentConfig(type)`.
165
+ After the extraction, they accept the registry (or a `resolveAgentConfig` callback) as a parameter.
166
+ The `AgentWidget` constructor gains a `registry` parameter and passes it to these helpers internally.
167
+ External callers (`conversation-viewer.ts`, `get-result-tool.ts`, etc.) pass the registry they already hold via their deps.
168
+
169
+ ## Module-Level Changes
170
+
171
+ ### New
172
+
173
+ No new files.
174
+
175
+ ### Modified
176
+
177
+ 1. **`src/agent-types.ts`**
178
+ - Add `AgentTypeRegistry` class with all instance methods.
179
+ - Add `AgentConfigLookup` interface.
180
+ - Keep free functions temporarily (delegation shim during migration).
181
+ - Final step: remove free functions, module-scoped `agents` Map, and `registerAgents`.
182
+
183
+ 2. **`src/types.ts`**
184
+ - Remove `DEFAULT_AGENT_NAMES` constant (moved to `AgentTypeRegistry.DEFAULT_AGENT_NAMES`).
185
+
186
+ 3. **`src/session-config.ts`**
187
+ - `assembleSessionConfig` gains a `registry: AgentConfigLookup` parameter.
188
+ - Remove imports of `resolveAgentConfig`, `getToolNamesForType` from `agent-types.ts`.
189
+
190
+ 4. **`src/agent-runner.ts`**
191
+ - `RunOptions` gains `registry: AgentConfigLookup`.
192
+ - `runAgent` passes `options.registry` to `assembleSessionConfig`.
193
+
194
+ 5. **`src/agent-manager.ts`**
195
+ - `AgentManagerOptions` gains `registry: AgentTypeRegistry`.
196
+ - `AgentManager` stores `this.registry` and passes it into `RunOptions` when calling `runner.run`.
197
+
198
+ 6. **`src/tools/agent-tool.ts`**
199
+ - `AgentToolDeps`: add `registry: AgentTypeRegistry`, remove `reloadCustomAgents`.
200
+ - Replace `resolveType(...)` / `resolveAgentConfig(...)` imports with `deps.registry.resolveType(...)` / `deps.registry.resolveAgentConfig(...)`.
201
+ - Replace `deps.reloadCustomAgents()` with `deps.registry.reload()`.
202
+
203
+ 7. **`src/ui/agent-menu.ts`**
204
+ - `AgentMenuDeps`: add `registry: AgentTypeRegistry`, remove `reloadCustomAgents`.
205
+ - Replace `getAllTypes()`/`resolveAgentConfig()`/`resolveType()` imports with `deps.registry.*` calls.
206
+ - `BUILTIN_TOOL_NAMES` import stays (it is a constant, not a method).
207
+ - Replace `deps.reloadCustomAgents()` with `deps.registry.reload()`.
208
+
209
+ 8. **`src/ui/agent-widget.ts`**
210
+ - `AgentWidget` constructor gains a `registry: AgentTypeRegistry` (or narrow interface) parameter.
211
+ - `getDisplayName(type, registry)` / `getPromptModeLabel(type, registry)` gain a registry parameter.
212
+ - Internal render methods use `this.registry`.
213
+
214
+ 9. **`src/ui/conversation-viewer.ts`**
215
+ - `ConversationViewer` constructor gains a registry parameter.
216
+ - Passes it to `getDisplayName` / `getPromptModeLabel` calls.
217
+
218
+ 10. **`src/tools/get-result-tool.ts`**
219
+ - `GetResultToolDeps` (or equivalent) gains registry.
220
+ - Passes it to `getDisplayName` calls.
221
+
222
+ 11. **`src/index.ts`**
223
+ - Construct `AgentTypeRegistry` with `() => loadCustomAgents(process.cwd())`.
224
+ - Pass registry to `AgentManager`, agent-tool deps, menu deps, widget, get-result-tool deps.
225
+ - Remove `reloadCustomAgents` closure.
226
+ - Remove free-function imports (`registerAgents`, `getDefaultAgentNames`, `getUserAgentNames`, `getAvailableTypes`, `resolveAgentConfig`).
227
+ - Use `registry.*` methods directly for `buildTypeListText`.
228
+
229
+ ### Removed
230
+
231
+ - Free functions from `agent-types.ts`: `registerAgents`, `resolveType`, `resolveAgentConfig`, `getAvailableTypes`, `getAllTypes`, `getDefaultAgentNames`, `getUserAgentNames`, `isValidType`, `getToolNamesForType` (final cleanup step).
232
+ - Module-scoped `agents` Map and `resolveKey` helper.
233
+ - `DEFAULT_AGENT_NAMES` from `types.ts`.
234
+ - `reloadCustomAgents` field from `AgentToolDeps` and `AgentMenuDeps`.
235
+
236
+ ## Test Impact Analysis
237
+
238
+ ### New tests enabled
239
+
240
+ - **Isolation without module resets:** Each test creates its own `AgentTypeRegistry` with a fixture callback, eliminating cross-test state leakage and the `registerAgents(new Map())` ceremony.
241
+ - **Reload behavior:** Tests can verify `registry.reload()` picks up new agents without touching module state.
242
+
243
+ ### Existing tests that become redundant
244
+
245
+ - The existing free-function tests in `agent-types.test.ts` become redundant once the class tests cover the same behavior.
246
+ They can be removed in the final cleanup step.
247
+
248
+ ### Existing tests that must stay
249
+
250
+ - `session-config.test.ts` — tests `assembleSessionConfig` behavior; mock setup changes from `vi.mock("agent-types.js")` to passing a mock `AgentConfigLookup` object.
251
+ - `agent-runner.test.ts`, `agent-runner-extension-tools.test.ts` — test runner behavior; mock setup changes from `vi.mock("agent-types.js")` to providing mock registry in `RunOptions`.
252
+ - `tools/agent-tool.test.ts` — tests tool handler; deps mock changes from `reloadCustomAgents: vi.fn()` to `registry: mockRegistry`.
253
+ - `ui/agent-menu.test.ts` — tests menu handler; deps mock changes similarly.
254
+ - `agent-manager.test.ts` — must add a mock registry to constructor options.
255
+
256
+ ## TDD Order
257
+
258
+ 1. **Create `AgentTypeRegistry` class** — Add class to `agent-types.ts` alongside existing free functions.
259
+ Add `AgentConfigLookup` interface.
260
+ Test all methods in a new `describe('AgentTypeRegistry')` block in `agent-types.test.ts`: construction, `reload()`, `resolveAgentConfig`, `resolveType`, `getAvailableTypes`, `getAllTypes`, `getDefaultAgentNames`, `getUserAgentNames`, `isValidType`, `getToolNamesForType`.
261
+ - Test surface: `agent-types.test.ts` — new describe block
262
+ - Commit: `feat(pi-subagents): add AgentTypeRegistry class (#108)`
263
+
264
+ 2. **Inject through the config-assembly chain** — `assembleSessionConfig` gains `registry: AgentConfigLookup` param.
265
+ `RunOptions` gains `registry: AgentConfigLookup`.
266
+ `AgentManagerOptions` gains `registry: AgentTypeRegistry`.
267
+ Construct registry in `index.ts` and pass through `AgentManager` → `runAgent` → `assembleSessionConfig`.
268
+ Update `session-config.test.ts` (replace `vi.mock("agent-types.js")` with mock `AgentConfigLookup` object), `agent-runner.test.ts` and `agent-runner-extension-tools.test.ts` (provide mock registry in `RunOptions`, remove `vi.mock`), `agent-manager.test.ts` (add registry to constructor options).
269
+ - Test surface: `session-config.test.ts`, `agent-runner.test.ts`, `agent-runner-extension-tools.test.ts`, `agent-manager.test.ts`
270
+ - Commit: `refactor(pi-subagents): inject registry through config-assembly chain (#108)`
271
+
272
+ 3. **Inject into agent tool** — Add `registry: AgentTypeRegistry` to `AgentToolDeps`, remove `reloadCustomAgents`.
273
+ Replace `resolveType` / `resolveAgentConfig` module imports with `deps.registry.*` calls.
274
+ Replace `deps.reloadCustomAgents()` with `deps.registry.reload()`.
275
+ Update `index.ts` agent-tool deps and `tools/agent-tool.test.ts`.
276
+ - Test surface: `tools/agent-tool.test.ts`
277
+ - Commit: `refactor(pi-subagents): inject registry into agent tool (#108)`
278
+
279
+ 4. **Inject into agent menu** — Add `registry: AgentTypeRegistry` to `AgentMenuDeps`, remove `reloadCustomAgents`.
280
+ Replace `getAllTypes` / `resolveAgentConfig` / `resolveType` module imports with `deps.registry.*` calls.
281
+ Replace `deps.reloadCustomAgents()` with `deps.registry.reload()`.
282
+ Update `index.ts` menu deps and `ui/agent-menu.test.ts`.
283
+ - Test surface: `ui/agent-menu.test.ts`
284
+ - Commit: `refactor(pi-subagents): inject registry into agent menu (#108)`
285
+
286
+ 5. **Inject into agent widget and display helpers** — `AgentWidget` constructor gains `registry`.
287
+ `getDisplayName(type, registry)` and `getPromptModeLabel(type, registry)` gain registry parameters.
288
+ `ConversationViewer` constructor gains registry.
289
+ `GetResultToolDeps` gains registry for `getDisplayName` calls.
290
+ Update `index.ts` wiring, `agent-widget.test.ts`, and any test files that call these helpers.
291
+ - Test surface: `agent-widget.test.ts`, related callers
292
+ - Commit: `refactor(pi-subagents): inject registry into agent widget (#108)`
293
+
294
+ 6. **Move `DEFAULT_AGENT_NAMES` to registry** — Add static `DEFAULT_AGENT_NAMES` property on `AgentTypeRegistry`.
295
+ Remove the constant from `types.ts`.
296
+ Grep confirms no import consumers exist — the constant is defined but unused.
297
+ - Test surface: `agent-types.test.ts` — add assertion for static property
298
+ - Commit: `refactor(pi-subagents): move DEFAULT_AGENT_NAMES to registry (#108)`
299
+
300
+ 7. **Remove free-function exports** — Delete `registerAgents`, `resolveType`, `resolveAgentConfig`, `getAvailableTypes`, `getAllTypes`, `getDefaultAgentNames`, `getUserAgentNames`, `isValidType`, `getToolNamesForType`, the module-scoped `agents` Map, and `resolveKey` helper from `agent-types.ts`.
301
+ Remove the free-function tests from `agent-types.test.ts` (now covered by class tests).
302
+ Remove any remaining free-function imports from `index.ts`.
303
+ Verify with `pnpm run check` that no dangling references remain.
304
+ - Test surface: `agent-types.test.ts` — remove old describe block
305
+ - Commit: `refactor(pi-subagents): remove free-function exports from agent-types (#108)`
306
+
307
+ ## Risks and Mitigations
308
+
309
+ | Risk | Impact | Mitigation |
310
+ | --------------------------------------------------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
311
+ | Large blast radius — 11 source files, 8 test files | Merge conflicts if other PRs land concurrently | Lift-and-shift: free functions keep working until final removal; each step is a valid commit |
312
+ | `vi.mock("agent-types.js")` removal in runner tests changes behavior | Tests may expose latent bugs in session-config | Mock `AgentConfigLookup` with the same values the current `vi.mock` provides |
313
+ | Display helpers (`getDisplayName`, `getPromptModeLabel`) thread registry through many callers | Signature churn in UI layer | These callers already have a deps bag or constructor params — registry fits naturally |
314
+ | `agent-types.test.ts` (333 lines) needs migration from free-function tests to class tests | Large test rewrite in step 7 | Step 1 creates class tests first; step 7 only deletes the now-redundant free-function tests |
315
+ | `AgentRunner` interface change (`RunOptions` gains `registry`) | Breaks callers that construct `RunOptions` | Only `agent-manager.ts` constructs `RunOptions`; single-site change |
316
+
317
+ ## Open Questions
318
+
319
+ - **`getDisplayName` / `getPromptModeLabel` placement:** These are thin display helpers that wrap `resolveAgentConfig`.
320
+ The plan proposes adding a registry parameter.
321
+ An alternative is to make them methods on the registry itself (e.g., `registry.getDisplayName(type)`), trading purity for convenience.
322
+ Decide during implementation based on how natural the call sites feel.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.3.1",
3
+ "version": "6.4.0",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -11,6 +11,7 @@ import type { Model } from "@earendil-works/pi-ai";
11
11
  import type { AgentSession, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { AgentRecord } from "./agent-record.js";
13
13
  import type { AgentRunner } from "./agent-runner.js";
14
+ import { AgentTypeRegistry } from "./agent-types.js";
14
15
  import { debugLog } from "./debug.js";
15
16
  import { buildParentSnapshot } from "./parent-snapshot.js";
16
17
  import { subscribeRecordObserver } from "./record-observer.js";
@@ -30,6 +31,7 @@ export interface AgentManagerOptions {
30
31
  runner: AgentRunner;
31
32
  worktrees: WorktreeManager;
32
33
  exec: ShellExec;
34
+ registry: AgentTypeRegistry;
33
35
  maxConcurrent?: number;
34
36
  getRunConfig?: () => RunConfig;
35
37
  onStart?: OnAgentStart;
@@ -81,6 +83,7 @@ export class AgentManager {
81
83
  private readonly runner: AgentRunner;
82
84
  private readonly worktrees: WorktreeManager;
83
85
  private readonly exec: ShellExec;
86
+ private readonly registry: AgentTypeRegistry;
84
87
  private maxConcurrent: number;
85
88
  private getRunConfig?: () => RunConfig;
86
89
 
@@ -93,6 +96,7 @@ export class AgentManager {
93
96
  this.runner = options.runner;
94
97
  this.worktrees = options.worktrees;
95
98
  this.exec = options.exec;
99
+ this.registry = options.registry;
96
100
  this.onComplete = options.onComplete;
97
101
  this.onStart = options.onStart;
98
102
  this.onCompact = options.onCompact;
@@ -203,6 +207,7 @@ export class AgentManager {
203
207
  parentSessionFile: options.parentSessionFile,
204
208
  parentSessionId: options.parentSessionId,
205
209
  signal: record.abortController!.signal,
210
+ registry: this.registry,
206
211
  onSessionCreated: (session) => {
207
212
  record.session = session;
208
213
  // Capture the session file path early so it's available for display
@@ -12,6 +12,7 @@ import {
12
12
  SessionManager,
13
13
  SettingsManager,
14
14
  } from "@earendil-works/pi-coding-agent";
15
+ import type { AgentConfigLookup } from "./agent-types.js";
15
16
  import { extractText } from "./context.js";
16
17
  import { detectEnv } from "./env.js";
17
18
  import { assembleSessionConfig } from "./session-config.js";
@@ -91,6 +92,8 @@ export interface RunOptions {
91
92
  * module-scope `graceTurns` during migration.
92
93
  */
93
94
  graceTurns?: number;
95
+ /** Agent config lookup — provides resolveAgentConfig and getToolNamesForType. */
96
+ registry: AgentConfigLookup;
94
97
  }
95
98
 
96
99
  export interface RunResult {
@@ -189,6 +192,7 @@ export async function runAgent(
189
192
  thinkingLevel: options.thinkingLevel,
190
193
  },
191
194
  env,
195
+ options.registry,
192
196
  );
193
197
 
194
198
  const agentDir = getAgentDir();
@@ -8,79 +8,132 @@
8
8
  import { DEFAULT_AGENTS } from "./default-agents.js";
9
9
  import type { AgentConfig } from "./types.js";
10
10
 
11
- /** All known built-in tool names. */
12
- export const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
11
+ // ── AgentConfigLookup interface ──────────────────────────────────────────────
13
12
 
14
- /** Unified runtime registry of all agents (defaults + user-defined). */
15
- const agents = new Map<string, AgentConfig>();
13
+ /**
14
+ * Narrow registry interface for consumers that only need config resolution.
15
+ * Prefer this over the full `AgentTypeRegistry` in function signatures (ISP).
16
+ */
17
+ export interface AgentConfigLookup {
18
+ resolveAgentConfig(type: string): AgentConfig;
19
+ getToolNamesForType(type: string): string[];
20
+ }
21
+
22
+ // ── AgentTypeRegistry class ──────────────────────────────────────────────────
16
23
 
17
24
  /**
18
- * Register agents into the unified registry.
19
- * Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).
20
- * Disabled agents (enabled === false) are kept in the registry but excluded from spawning.
25
+ * Injectable registry of all agent configurations (defaults + user-defined).
26
+ *
27
+ * Replaces the module-scoped `agents` Map and its companion free functions.
28
+ * The constructor accepts a `loadUserAgents` callback to defer disk I/O to the
29
+ * call site, keeping this class side-effect-free and easy to test.
21
30
  */
22
- export function registerAgents(userAgents: Map<string, AgentConfig>): void {
23
- agents.clear();
31
+ export class AgentTypeRegistry implements AgentConfigLookup {
32
+ private agents = new Map<string, AgentConfig>();
24
33
 
25
- // Start with defaults
26
- for (const [name, config] of DEFAULT_AGENTS) {
27
- agents.set(name, config);
34
+ /** The three embedded default agent names. */
35
+ static readonly DEFAULT_AGENT_NAMES = ["general-purpose", "Explore", "Plan"] as const;
36
+
37
+ constructor(private loadUserAgents: () => Map<string, AgentConfig>) {
38
+ this.reload();
28
39
  }
29
40
 
30
- // Overlay user agents (overrides defaults with same name)
31
- for (const [name, config] of userAgents) {
32
- agents.set(name, config);
41
+ /**
42
+ * Re-scan user agents and rebuild the registry.
43
+ * Starts with DEFAULT_AGENTS, then overlays whatever `loadUserAgents()` returns.
44
+ */
45
+ reload(): void {
46
+ this.agents.clear();
47
+ for (const [name, config] of DEFAULT_AGENTS) {
48
+ this.agents.set(name, config);
49
+ }
50
+ for (const [name, config] of this.loadUserAgents()) {
51
+ this.agents.set(name, config);
52
+ }
33
53
  }
34
- }
35
54
 
36
- /** Case-insensitive key resolution. */
37
- function resolveKey(name: string): string | undefined {
38
- if (agents.has(name)) return name;
39
- const lower = name.toLowerCase();
40
- for (const key of agents.keys()) {
41
- if (key.toLowerCase() === lower) return key;
55
+ /** Resolve a type name case-insensitively. Returns the canonical key or undefined. */
56
+ resolveType(name: string): string | undefined {
57
+ return this.resolveKey(name);
42
58
  }
43
- return undefined;
44
- }
45
59
 
46
- /** Resolve a type name case-insensitively. Returns the canonical key or undefined. */
47
- export function resolveType(name: string): string | undefined {
48
- return resolveKey(name);
49
- }
60
+ /** Get all enabled type names (for spawning and tool descriptions). */
61
+ getAvailableTypes(): string[] {
62
+ return [...this.agents.entries()]
63
+ .filter(([_, config]) => config.enabled !== false)
64
+ .map(([name]) => name);
65
+ }
50
66
 
51
- /** Get all enabled type names (for spawning and tool descriptions). */
52
- export function getAvailableTypes(): string[] {
53
- return [...agents.entries()]
54
- .filter(([_, config]) => config.enabled !== false)
55
- .map(([name]) => name);
56
- }
67
+ /** Get all type names including disabled (for UI listing). */
68
+ getAllTypes(): string[] {
69
+ return [...this.agents.keys()];
70
+ }
57
71
 
58
- /** Get all type names including disabled (for UI listing). */
59
- export function getAllTypes(): string[] {
60
- return [...agents.keys()];
61
- }
72
+ /** Get names of default agents currently in the registry. */
73
+ getDefaultAgentNames(): string[] {
74
+ return [...this.agents.entries()]
75
+ .filter(([_, config]) => config.isDefault === true)
76
+ .map(([name]) => name);
77
+ }
62
78
 
63
- /** Get names of default agents currently in the registry. */
64
- export function getDefaultAgentNames(): string[] {
65
- return [...agents.entries()]
66
- .filter(([_, config]) => config.isDefault === true)
67
- .map(([name]) => name);
68
- }
79
+ /** Get names of user-defined agents (non-defaults) currently in the registry. */
80
+ getUserAgentNames(): string[] {
81
+ return [...this.agents.entries()]
82
+ .filter(([_, config]) => config.isDefault !== true)
83
+ .map(([name]) => name);
84
+ }
69
85
 
70
- /** Get names of user-defined agents (non-defaults) currently in the registry. */
71
- export function getUserAgentNames(): string[] {
72
- return [...agents.entries()]
73
- .filter(([_, config]) => config.isDefault !== true)
74
- .map(([name]) => name);
75
- }
86
+ /** Check if a type is valid and enabled (case-insensitive). */
87
+ isValidType(type: string): boolean {
88
+ const key = this.resolveKey(type);
89
+ if (!key) return false;
90
+ return this.agents.get(key)?.enabled !== false;
91
+ }
76
92
 
77
- /** Check if a type is valid and enabled (case-insensitive). */
78
- export function isValidType(type: string): boolean {
79
- const key = resolveKey(type);
80
- if (!key) return false;
81
- return agents.get(key)?.enabled !== false;
93
+ /** Get built-in tool names for a type (case-insensitive). */
94
+ getToolNamesForType(type: string): string[] {
95
+ const key = this.resolveKey(type);
96
+ const raw = key ? this.agents.get(key) : undefined;
97
+ const config = raw?.enabled !== false ? raw : undefined;
98
+ const names = config?.builtinToolNames?.length ? config.builtinToolNames : [...BUILTIN_TOOL_NAMES];
99
+ return names;
100
+ }
101
+
102
+ /** Resolve agent config with guaranteed non-null return. Falls back: unknown → general-purpose → absolute fallback. */
103
+ resolveAgentConfig(type: string): AgentConfig {
104
+ const key = this.resolveKey(type);
105
+ const config = key ? this.agents.get(key) : undefined;
106
+ if (config) return config;
107
+
108
+ const gp = this.agents.get("general-purpose");
109
+ if (gp) return gp;
110
+
111
+ // Absolute fallback (should never happen in practice)
112
+ return {
113
+ name: type,
114
+ displayName: "Agent",
115
+ description: "General-purpose agent for complex, multi-step tasks",
116
+ builtinToolNames: BUILTIN_TOOL_NAMES,
117
+ extensions: true,
118
+ skills: true,
119
+ systemPrompt: "",
120
+ promptMode: "append",
121
+ };
122
+ }
123
+
124
+ private resolveKey(name: string): string | undefined {
125
+ if (this.agents.has(name)) return name;
126
+ const lower = name.toLowerCase();
127
+ for (const key of this.agents.keys()) {
128
+ if (key.toLowerCase() === lower) return key;
129
+ }
130
+ return undefined;
131
+ }
82
132
  }
83
133
 
134
+ /** All known built-in tool names. */
135
+ export const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
136
+
84
137
  /** Tool names required for memory management. */
85
138
  const MEMORY_TOOL_NAMES = ["read", "write", "edit"];
86
139
 
@@ -100,39 +153,3 @@ const READONLY_MEMORY_TOOL_NAMES = ["read"];
100
153
  export function getReadOnlyMemoryToolNames(existingToolNames: Set<string>): string[] {
101
154
  return READONLY_MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
102
155
  }
103
-
104
- /** Get built-in tool names for a type (case-insensitive). */
105
- export function getToolNamesForType(type: string): string[] {
106
- const key = resolveKey(type);
107
- const raw = key ? agents.get(key) : undefined;
108
- const config = raw?.enabled !== false ? raw : undefined;
109
- const names = config?.builtinToolNames?.length ? config.builtinToolNames : [...BUILTIN_TOOL_NAMES];
110
- return names;
111
- }
112
-
113
- /** Resolve agent config with guaranteed non-null return. Falls back: unknown → general-purpose → absolute fallback. */
114
- export function resolveAgentConfig(type: string): AgentConfig {
115
- const key = resolveKey(type);
116
- const config = key ? agents.get(key) : undefined;
117
- if (config) {
118
- return config;
119
- }
120
-
121
- // Fallback to general-purpose for unknown types
122
- const gp = agents.get("general-purpose");
123
- if (gp) {
124
- return gp;
125
- }
126
-
127
- // Absolute fallback (should never happen in practice)
128
- return {
129
- name: type,
130
- displayName: "Agent",
131
- description: "General-purpose agent for complex, multi-step tasks",
132
- builtinToolNames: BUILTIN_TOOL_NAMES,
133
- extensions: true,
134
- skills: true,
135
- systemPrompt: "",
136
- promptMode: "append",
137
- };
138
- }