@gotgenes/pi-subagents 6.4.0 → 6.6.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,271 @@
1
+ ---
2
+ issue: 118
3
+ issue_title: "refactor(pi-subagents): SettingsManager apply methods — eliminate cross-collaborator orchestration"
4
+ ---
5
+
6
+ # SettingsManager apply methods — eliminate cross-collaborator orchestration
7
+
8
+ ## Problem Statement
9
+
10
+ `showSettings` in `agent-menu.ts` orchestrates across two collaborators when changing a setting — it mutates `settings` properties directly (output arguments), separately pokes `manager.notifyConcurrencyChanged()`, then calls `settings.saveAndNotify()`.
11
+ The menu knows too much about the consequence chain of a settings change.
12
+
13
+ This is a Law of Demeter / Tell-Don't-Ask violation: the menu should *tell* settings what the user wants, not coordinate the mechanics of persistence and queue drain.
14
+
15
+ ## Goals
16
+
17
+ - Add `applyMaxConcurrent(n)`, `applyDefaultMaxTurns(n)`, `applyGraceTurns(n)` methods to `SettingsManager` that own the full consequence chain: normalize → set in memory → notify interested parties → persist → emit lifecycle event → return toast.
18
+ - Accept an optional `onMaxConcurrentChanged` callback in `SettingsManager` constructor deps, wired to `manager.notifyConcurrencyChanged()` at init.
19
+ - Narrow `AgentMenuSettings` — replace writable property setters and `saveAndNotify` with 3 read-only getters and 3 apply methods.
20
+ - Remove `notifyConcurrencyChanged` from `AgentMenuManager` — the menu no longer needs to know about the manager for settings changes.
21
+ - This is a non-breaking refactoring — no public API surface changes.
22
+
23
+ ## Non-Goals
24
+
25
+ - Narrowing `AgentToolDeps` or `AgentMenuDeps` further (#114) — that is a separate issue.
26
+ - Changing the persistence format or the global-vs-project merge strategy.
27
+ - Removing `saveAndNotify` from `SettingsManager` — it remains as a public method; the apply methods delegate to it internally.
28
+ - Removing the property setters from `SettingsManager` — they remain for `load()` and direct test use; only the `AgentMenuSettings` interface narrows.
29
+
30
+ ## Background
31
+
32
+ ### Current flow (max concurrency example)
33
+
34
+ ```text
35
+ showSettings (agent-menu.ts)
36
+ ├── deps.settings.maxConcurrent = n ← output argument
37
+ ├── deps.manager.notifyConcurrencyChanged()← cross-collaborator orchestration
38
+ └── notifyApplied(ctx, message)
39
+ └── deps.settings.saveAndNotify(msg) ← separate persist + emit call
40
+ ```
41
+
42
+ ### Target flow
43
+
44
+ ```text
45
+ showSettings (agent-menu.ts)
46
+ └── deps.settings.applyMaxConcurrent(n) ← one call, toast returned
47
+ ├── this.maxConcurrent = n ← internal set
48
+ ├── this.onMaxConcurrentChanged?.() ← callback to manager
49
+ └── this.saveAndNotify(message) ← internal persist + emit
50
+ ```
51
+
52
+ ### Module map (affected files only)
53
+
54
+ | Module | Current role | Change |
55
+ | ---------------------- | ------------------------------------------------------ | ------------------------------------------------------------- |
56
+ | `src/settings.ts` | `SettingsManager` class with setters, `saveAndNotify` | Add `onMaxConcurrentChanged` callback; add 3 `apply*` methods |
57
+ | `src/ui/agent-menu.ts` | `showSettings` orchestrates across manager + settings | Simplify to single apply call per setting |
58
+ | `src/agent-manager.ts` | `notifyConcurrencyChanged()` public method | No change — still called via callback |
59
+ | `src/index.ts` | Wires `notifyConcurrencyChanged` on `AgentMenuManager` | Wire `onMaxConcurrentChanged` on settings constructor instead |
60
+
61
+ ### Architecture reference
62
+
63
+ Follow-up to Phase 7, Step A2 (#109, closed/implemented).
64
+ Sequenced before D2 (#114, open).
65
+
66
+ ### Applicable constraints
67
+
68
+ - Law of Demeter — eliminate cross-collaborator reach-through (code-design skill).
69
+ - No output arguments — the menu should not write into received deps (code-design skill).
70
+ - Dependency inversion — consumers accept narrow interfaces (code-design skill).
71
+ - One concern per file — `SettingsManager` already owns the concern; this deepens encapsulation.
72
+
73
+ ## Design Overview
74
+
75
+ ### SettingsManager constructor deps change
76
+
77
+ ```typescript
78
+ constructor(deps: {
79
+ emit: SettingsEmit;
80
+ cwd: string;
81
+ onMaxConcurrentChanged?: () => void; // ← new
82
+ })
83
+ ```
84
+
85
+ The callback is stored as a private field and called from `applyMaxConcurrent` only.
86
+
87
+ ### Apply methods
88
+
89
+ Each method normalizes the input, sets the in-memory value, calls any interested-party callback, and delegates to `saveAndNotify` for persist + emit + toast:
90
+
91
+ ```typescript
92
+ applyMaxConcurrent(n: number): { message: string; level: "info" | "warning" } {
93
+ this.maxConcurrent = n; // setter normalizes (max(1, n))
94
+ this.onMaxConcurrentChanged?.();
95
+ return this.saveAndNotify(`Max concurrency set to ${this.maxConcurrent}`);
96
+ }
97
+
98
+ applyDefaultMaxTurns(n: number): { message: string; level: "info" | "warning" } {
99
+ this.defaultMaxTurns = n === 0 ? undefined : n; // setter normalizes
100
+ const label = this.defaultMaxTurns == null ? "unlimited" : String(this.defaultMaxTurns);
101
+ return this.saveAndNotify(`Default max turns set to ${label}`);
102
+ }
103
+
104
+ applyGraceTurns(n: number): { message: string; level: "info" | "warning" } {
105
+ this.graceTurns = n; // setter normalizes (max(1, n))
106
+ return this.saveAndNotify(`Grace turns set to ${this.graceTurns}`);
107
+ }
108
+ ```
109
+
110
+ The toast message uses the *post-normalization* value (e.g., `max(1, n)`) so the user sees what was actually applied.
111
+
112
+ ### Consumer call-site sketch (agent-menu.ts)
113
+
114
+ ```typescript
115
+ // Max concurrency — before:
116
+ deps.settings.maxConcurrent = n;
117
+ deps.manager.notifyConcurrencyChanged();
118
+ notifyApplied(ctx, `Max concurrency set to ${n}`);
119
+
120
+ // Max concurrency — after:
121
+ const toast = deps.settings.applyMaxConcurrent(n);
122
+ ctx.ui.notify(toast.message, toast.level);
123
+ ```
124
+
125
+ Three property writes + one cross-collaborator call + one persist call → one method call.
126
+ The menu never touches the manager for settings changes.
127
+
128
+ ### Narrowed AgentMenuSettings interface
129
+
130
+ ```typescript
131
+ export interface AgentMenuSettings {
132
+ readonly maxConcurrent: number;
133
+ readonly defaultMaxTurns: number | undefined;
134
+ readonly graceTurns: number;
135
+ applyMaxConcurrent(n: number): { message: string; level: "info" | "warning" };
136
+ applyDefaultMaxTurns(n: number): { message: string; level: "info" | "warning" };
137
+ applyGraceTurns(n: number): { message: string; level: "info" | "warning" };
138
+ }
139
+ ```
140
+
141
+ ### Narrowed AgentMenuManager interface
142
+
143
+ ```typescript
144
+ export interface AgentMenuManager {
145
+ listAgents: () => AgentRecord[];
146
+ getRecord: (id: string) => AgentRecord | undefined;
147
+ spawnAndWait: (...) => Promise<AgentRecord>;
148
+ // notifyConcurrencyChanged removed — settings owns the callback
149
+ }
150
+ ```
151
+
152
+ ### index.ts wiring change
153
+
154
+ ```typescript
155
+ // Before:
156
+ const settings = new SettingsManager({ emit, cwd });
157
+ // ... later in menu deps:
158
+ manager: { ..., notifyConcurrencyChanged: () => manager.notifyConcurrencyChanged() },
159
+
160
+ // After:
161
+ const settings = new SettingsManager({
162
+ emit,
163
+ cwd,
164
+ onMaxConcurrentChanged: () => manager.notifyConcurrencyChanged(),
165
+ });
166
+ // ... menu deps no longer includes notifyConcurrencyChanged
167
+ ```
168
+
169
+ The closure captures `manager` by reference — safe because the callback is never invoked before `manager` is constructed.
170
+
171
+ ## Module-Level Changes
172
+
173
+ ### `src/settings.ts`
174
+
175
+ - **Add** `onMaxConcurrentChanged?: () => void` to constructor deps.
176
+ - **Add** `applyMaxConcurrent(n)`, `applyDefaultMaxTurns(n)`, `applyGraceTurns(n)` methods.
177
+ - **Keep** property setters, `saveAndNotify`, `load`, `snapshot` — apply methods delegate to them.
178
+
179
+ ### `src/ui/agent-menu.ts`
180
+
181
+ - **Change** `AgentMenuSettings`: replace writable properties + `saveAndNotify` with readonly properties + 3 apply methods.
182
+ - **Change** `AgentMenuManager`: remove `notifyConcurrencyChanged`.
183
+ - **Change** `showSettings`: replace 3-step orchestration with single apply call per setting.
184
+ - **Remove** `notifyApplied` helper — no longer needed; each branch calls `ctx.ui.notify(toast.message, toast.level)` directly.
185
+
186
+ ### `src/index.ts`
187
+
188
+ - **Add** `onMaxConcurrentChanged` to `SettingsManager` constructor call.
189
+ - **Remove** `notifyConcurrencyChanged` from the menu's manager dep.
190
+
191
+ ### `src/agent-manager.ts`
192
+
193
+ - **No change** — `notifyConcurrencyChanged()` remains as a public method, now called via callback instead of menu.
194
+
195
+ ## Test Impact Analysis
196
+
197
+ ### New unit tests enabled
198
+
199
+ - **Apply method integration**: construct `SettingsManager` with an `onMaxConcurrentChanged` spy → call `applyMaxConcurrent(n)` → verify the spy was called, value was set, `saveAndNotify` persisted, and the returned toast is correct.
200
+ Previously impossible because the consequence chain was spread across the menu and two collaborators.
201
+ - **Toast message accuracy**: verify that apply methods use post-normalization values in the toast (e.g., `applyMaxConcurrent(0)` sets to 1 and reports "set to 1", not "set to 0").
202
+ - **Callback not invoked for non-concurrency settings**: verify `onMaxConcurrentChanged` is *not* called when `applyDefaultMaxTurns` or `applyGraceTurns` is used.
203
+
204
+ ### Existing tests that become simpler
205
+
206
+ - `agent-menu.test.ts` settings tests: currently assert 3 side effects per setting (property mutation, `notifyConcurrencyChanged` call, `saveAndNotify` call).
207
+ After: assert a single `apply*` call with the correct argument.
208
+ The mock object shrinks (no writable setters, no `saveAndNotify`).
209
+
210
+ ### Existing tests that must stay
211
+
212
+ - `settings.test.ts` — `saveAndNotify()` tests stay; the apply methods delegate to it.
213
+ - `settings.test.ts` — property setter normalization tests stay; apply methods delegate to setters.
214
+ - `agent-menu.test.ts` — settings navigation tests stay; only the assertions change.
215
+ - `agent-manager.test.ts` — `notifyConcurrencyChanged` / drain-queue tests stay unchanged.
216
+
217
+ ## TDD Order
218
+
219
+ ### Cycle 1: Add `onMaxConcurrentChanged` callback to SettingsManager constructor
220
+
221
+ 1. Red: test that constructing with `onMaxConcurrentChanged` stores the callback (verified indirectly in cycle 2).
222
+ Test that constructing without it does not throw.
223
+ 2. Green: add optional `onMaxConcurrentChanged` to constructor deps, store as private field.
224
+ 3. Commit: `feat: accept onMaxConcurrentChanged callback in SettingsManager constructor`
225
+
226
+ ### Cycle 2: Add `applyMaxConcurrent` method
227
+
228
+ 1. Red: test `applyMaxConcurrent(8)` — sets `maxConcurrent` to 8, calls `onMaxConcurrentChanged` spy, persists, emits event, returns info toast with "Max concurrency set to 8".
229
+ Test `applyMaxConcurrent(0)` — normalizes to 1, toast says "set to 1".
230
+ Test without callback — no throw, still persists and returns toast.
231
+ 2. Green: implement `applyMaxConcurrent`.
232
+ 3. Commit: `feat: add SettingsManager.applyMaxConcurrent method`
233
+
234
+ ### Cycle 3: Add `applyDefaultMaxTurns` and `applyGraceTurns` methods
235
+
236
+ 1. Red: test `applyDefaultMaxTurns(0)` — sets to unlimited, toast says "unlimited".
237
+ Test `applyDefaultMaxTurns(10)` — sets to 10, toast says "set to 10".
238
+ Test `applyGraceTurns(3)` — sets to 3, toast says "set to 3".
239
+ Test that neither calls `onMaxConcurrentChanged`.
240
+ 2. Green: implement both methods.
241
+ 3. Commit: `feat: add SettingsManager.applyDefaultMaxTurns and applyGraceTurns methods`
242
+
243
+ ### Cycle 4: Narrow `AgentMenuSettings` and `AgentMenuManager`, simplify `showSettings`
244
+
245
+ 1. Red: update `makeDeps` in `agent-menu.test.ts` — replace writable settings properties + `saveAndNotify` with readonly properties + 3 `apply*` mocks; remove `notifyConcurrencyChanged` from manager mock.
246
+ Update assertions: `expect(deps.settings.applyMaxConcurrent).toHaveBeenCalledWith(8)` instead of checking property + `saveAndNotify` + `notifyConcurrencyChanged`.
247
+ 2. Green: update `AgentMenuSettings` interface (readonly getters + apply methods), update `AgentMenuManager` (remove `notifyConcurrencyChanged`), rewrite `showSettings` to use apply methods, remove `notifyApplied` helper.
248
+ 3. Run `pnpm run check`.
249
+ 4. Commit: `refactor: simplify showSettings to use SettingsManager apply methods`
250
+
251
+ ### Cycle 5: Wire `onMaxConcurrentChanged` in index.ts
252
+
253
+ 1. Update `SettingsManager` constructor in `index.ts` — add `onMaxConcurrentChanged: () => manager.notifyConcurrencyChanged()`.
254
+ 2. Remove `notifyConcurrencyChanged` from the menu's manager dep object.
255
+ 3. Run full test suite.
256
+ 4. Commit: `refactor: wire onMaxConcurrentChanged callback in extension init (#118)`
257
+
258
+ ## Risks and Mitigations
259
+
260
+ | Risk | Mitigation |
261
+ | ---------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
262
+ | Closure ordering: `onMaxConcurrentChanged` references `manager` before it's constructed | The closure captures by reference; `manager` is assigned before any runtime invocation of the callback. Verified by the `index.ts` construction order (`settings` → `manager` → menu wiring). |
263
+ | Toast message drift: apply methods generate messages internally, so changes require updating `SettingsManager` instead of the menu | Each method has exactly one message template; the coupling is intentional — the owner of the setting knows how to describe the change. |
264
+ | Apply methods duplicate the 3-step pattern (set → callback → save) | The duplication is incidental across 3 settings — extracting a shared helper would need a discriminator for the callback, adding complexity for 3 call sites. |
265
+ | `saveAndNotify` remains public but is no longer in `AgentMenuSettings` | It's still useful for programmatic callers and tests; keeping it public is intentional. |
266
+
267
+ ## Open Questions
268
+
269
+ - Should the property setters on `SettingsManager` be demoted to `private` (with only apply methods for external mutation)?
270
+ Defer — `load()` uses them internally, and narrowing is done via the `AgentMenuSettings` interface.
271
+ If a future consumer needs direct set access, the setters are there.
@@ -0,0 +1,41 @@
1
+ ---
2
+ issue: 108
3
+ issue_title: "refactor(pi-subagents): extract AgentTypeRegistry class from module-scoped state"
4
+ ---
5
+
6
+ # Retro: #108 — extract AgentTypeRegistry class
7
+
8
+ ## Final Retrospective (2026-05-21T13:30:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned and implemented the `AgentTypeRegistry` class extraction from module-scoped state in `agent-types.ts`.
13
+ The lift-and-shift approach across 7 TDD steps (9 commits including plan and docs) migrated 11 source files and 11 test files while keeping 574 tests green at every commit.
14
+ The `reloadCustomAgents` callback was removed from `AgentToolDeps` and `AgentMenuDeps`, replaced by `deps.registry.reload()`.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - The `AgentConfigLookup` narrow interface (ISP) for `session-config.ts` kept tests simple — plain objects with 2 methods, no class instantiation needed.
21
+ - Using `vi.spyOn` on a real `AgentTypeRegistry` instance in `agent-menu.test.ts` was cleaner than `vi.hoisted` + `vi.fn()` factories: correct types, automatic cleanup via `vi.restoreAllMocks()`.
22
+ - Step 2 (inject through 4-file config-assembly chain) was the riskiest single step but landed cleanly because the `vi.mock("agent-types.js")` stubs were narrowed to only the free functions that `session-config.ts` still imports (`getMemoryToolNames`, `getReadOnlyMemoryToolNames`).
23
+
24
+ #### What caused friction (agent side)
25
+
26
+ 1. `missing-context` — The plan's "Test files affected" table listed 8 files but missed 3 (`prompts.test.ts`, `tools/get-result-tool.test.ts`, `conversation-viewer.test.ts`) that directly import symbols being removed in step 7.
27
+ The grep during planning found `prompts.test.ts` as an importer of `registerAgents` but didn't include it in the table.
28
+ Impact: 3 extra test files needed updating in step 7; caught by the full-suite run, not by surprise in CI.
29
+
30
+ 2. `wrong-abstraction` — First `perl -0777` regex for bulk-updating 16 `ConversationViewer` constructor calls in `conversation-viewer.test.ts` failed because the character class `[^)]+` didn't match the multi-line arguments.
31
+ A simpler pattern targeting just the `vi.fn(),\n );` suffix worked on the second try.
32
+ Impact: added friction but no rework — ~2 minutes.
33
+
34
+ 3. `missing-context` — Type check after step 7 revealed `promptMode: string` vs `"replace" | "append"` narrowing issue in `agent-runner-extension-tools.test.ts`.
35
+ The `agentConfigMock.current` object had `promptMode: "replace"` which TypeScript widened to `string` when spread into the mock `AgentConfigLookup` return.
36
+ Impact: one additional edit with a return-type annotation; caught by `pnpm run check` as recommended by the testing skill.
37
+
38
+ #### What caused friction (user side)
39
+
40
+ - No user-side friction observed.
41
+ The issue description was clear, the architecture doc had the design already sketched, and no mid-session redirects were needed.
@@ -0,0 +1,55 @@
1
+ ---
2
+ issue: 109
3
+ issue_title: "refactor(pi-subagents): extract SettingsManager class"
4
+ ---
5
+
6
+ # Retro: #109 — extract SettingsManager class
7
+
8
+ ## Final Retrospective (2026-05-21T17:30:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned and implemented the `SettingsManager` class extraction across 8 TDD cycles plus doc updates.
13
+ The class owns `defaultMaxTurns`, `graceTurns`, and `maxConcurrent` with normalizing property accessors, a `load()` method for merged config, and `saveAndNotify()` for persistence + lifecycle events.
14
+ Six settings-related callback fields in `AgentMenuDeps` collapsed to a single `settings` collaborator (13 → 8 fields), and `SettingsAppliers`, `applySettings`, `applyAndEmitLoaded`, `saveAndEmitChanged` were removed.
15
+ A follow-up issue (#118) was filed for a LoD/Tell-Don't-Ask violation the user identified in the post-implementation review.
16
+
17
+ ### Observations
18
+
19
+ #### What went well
20
+
21
+ - The lift-and-shift TDD approach worked cleanly: the new class was built and tested in isolation (cycles 1–2), consumers migrated one at a time (cycles 3–5), wiring consolidated (cycle 6), and old code removed last (cycles 7–8).
22
+ Each commit left the repo in a valid state.
23
+ - The user's design critique (LoD / Tell-Don't-Ask on `deps.settings.saveAndNotify()` orchestration) was sharp and led to a concrete follow-up issue (#118) with a clear fix.
24
+ The session handled it well — acknowledged, designed the fix, filed the issue, updated the architecture doc, and stopped without scope-creeping into implementation.
25
+
26
+ #### What caused friction (agent side)
27
+
28
+ 1. `wrong-abstraction` — The plan did not anticipate that changing `AgentMenuDeps` (cycle 3) would immediately break `index.ts` type-checking.
29
+ Each interface change in cycles 3, 4, and 5 required a same-commit bridge fix in `index.ts` to keep `pnpm run check` clean.
30
+ The plan's separation of "migrate consumers" (cycles 3–5) from "wire in index.ts" (cycle 6) was too coarse — interface changes propagate to the call site immediately.
31
+ Impact: three unplanned bridge edits in `index.ts`, each small but requiring context-switching mid-cycle.
32
+
33
+ 2. `missing-context` — The `sed` command in cycle 5 (`sed -i '' 's/maxConcurrent: 1,/getMaxConcurrent: () => 1,/g'`) missed two call sites where `maxConcurrent: 1` had no trailing comma (end of object literal).
34
+ A `grep` check after the `sed` would have caught this immediately.
35
+ Impact: two tests failed unexpectedly; required a follow-up read + manual edit before the cycle could complete.
36
+
37
+ 3. `missing-context` — The Edit tool failed on `runtime.ts` because the file uses `─` (U+2500, BOX DRAWINGS LIGHT HORIZONTAL) in section separators, not `—` (U+2014, EM DASH).
38
+ The Unicode characters looked identical in the terminal, and the Edit tool's exact-match requirement meant the mismatch was silent until the replacement failed.
39
+ Impact: three failed Edit attempts before falling back to a Python script for the replacement.
40
+ This is the same class of issue seen in previous sessions with Unicode characters in source files.
41
+
42
+ 4. `premature-convergence` — The plan designed `SettingsManager` as a data holder with persistence methods but didn't consider whether the menu should orchestrate across `settings` and `manager` or whether `SettingsManager` should own the full consequence chain.
43
+ The user caught this as a LoD/Tell-Don't-Ask violation in post-implementation review.
44
+ Impact: no rework (filed as follow-up #118), but the design could have been better from the start if the plan had applied the design-review checklist's LoD check to the proposed `showSettings` interaction pattern.
45
+
46
+ #### What caused friction (user side)
47
+
48
+ - The user's LoD critique was well-timed — after implementation was complete, avoiding mid-stream rework.
49
+ If the critique had surfaced during planning (e.g., by the agent applying the design-review checklist to the proposed consumer interaction pattern), the follow-up issue might have been part of the original scope.
50
+ This is an opportunity for the planning step to simulate the consumer's call sites before finalizing the design, not just the class interface.
51
+
52
+ ### Changes made
53
+
54
+ 1. `.pi/prompts/plan-issue.md` — Added consumer call-site sketch heuristic to the "Design Overview" section: when a new collaborator is introduced, sketch 3–5 lines of consumer pseudocode to verify Tell-Don't-Ask and LoD.
55
+ 2. `.pi/skills/testing/SKILL.md` — Added TDD planning rule: when a step changes an interface with a single call site, the step must include updating that call site (type checker enforces co-location).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.4.0",
3
+ "version": "6.6.0",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -32,7 +32,8 @@ export interface AgentManagerOptions {
32
32
  worktrees: WorktreeManager;
33
33
  exec: ShellExec;
34
34
  registry: AgentTypeRegistry;
35
- maxConcurrent?: number;
35
+ /** Injected getter for the concurrency limit — owned by SettingsManager. */
36
+ getMaxConcurrent?: () => number;
36
37
  getRunConfig?: () => RunConfig;
37
38
  onStart?: OnAgentStart;
38
39
  onComplete?: OnAgentComplete;
@@ -84,7 +85,7 @@ export class AgentManager {
84
85
  private readonly worktrees: WorktreeManager;
85
86
  private readonly exec: ShellExec;
86
87
  private readonly registry: AgentTypeRegistry;
87
- private maxConcurrent: number;
88
+ private readonly _getMaxConcurrent: () => number;
88
89
  private getRunConfig?: () => RunConfig;
89
90
 
90
91
  /** Queue of background agents waiting to start. */
@@ -101,23 +102,20 @@ export class AgentManager {
101
102
  this.onStart = options.onStart;
102
103
  this.onCompact = options.onCompact;
103
104
  this.getRunConfig = options.getRunConfig;
104
- this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
105
+ this._getMaxConcurrent = options.getMaxConcurrent ?? (() => DEFAULT_MAX_CONCURRENT);
105
106
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
106
107
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
107
108
  this.cleanupInterval.unref();
108
109
  }
109
110
 
110
- /** Update the max concurrent background agents limit. */
111
- setMaxConcurrent(n: number) {
112
- this.maxConcurrent = Math.max(1, n);
113
- // Start queued agents if the new limit allows
111
+ /**
112
+ * Drain the concurrency queue after SettingsManager has updated maxConcurrent.
113
+ * Call this whenever the concurrency limit increases so queued agents can start.
114
+ */
115
+ notifyConcurrencyChanged(): void {
114
116
  this.drainQueue();
115
117
  }
116
118
 
117
- getMaxConcurrent(): number {
118
- return this.maxConcurrent;
119
- }
120
-
121
119
  /**
122
120
  * Spawn an agent and return its ID immediately (for background use).
123
121
  * If the concurrency limit is reached, the agent is queued.
@@ -144,7 +142,7 @@ export class AgentManager {
144
142
  const snapshot = buildParentSnapshot(ctx, options.inheritContext);
145
143
  const args: SpawnArgs = { snapshot, type, prompt, options };
146
144
 
147
- if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
145
+ if (options.isBackground && !options.bypassQueue && this.runningBackground >= this._getMaxConcurrent()) {
148
146
  // Queue it — will be started when a running agent completes
149
147
  this.queue.push({ id, args });
150
148
  return id;
@@ -284,7 +282,7 @@ export class AgentManager {
284
282
 
285
283
  /** Start queued agents up to the concurrency limit. */
286
284
  private drainQueue() {
287
- while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
285
+ while (this.queue.length > 0 && this.runningBackground < this._getMaxConcurrent()) {
288
286
  const next = this.queue.shift()!;
289
287
  const record = this.agents.get(next.id);
290
288
  if (!record || record.status !== "queued") continue;
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import { join } from "node:path";
14
14
  import { defineTool, type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
15
15
  import { AgentManager } from "./agent-manager.js";
16
- import { getAgentConversation, normalizeMaxTurns, resumeAgent, runAgent, steerAgent } from "./agent-runner.js";
16
+ import { getAgentConversation, resumeAgent, runAgent, steerAgent } from "./agent-runner.js";
17
17
  import { AgentTypeRegistry } from "./agent-types.js";
18
18
  import { loadCustomAgents } from "./custom-agents.js";
19
19
  import { SessionLifecycleHandler, ToolStartHandler } from "./handlers/index.js";
@@ -23,7 +23,7 @@ import { createNotificationRenderer } from "./renderer.js";
23
23
  import { createSubagentRuntime } from "./runtime.js";
24
24
  import { publishSubagentsService, unpublishSubagentsService } from "./service.js";
25
25
  import { createSubagentsService } from "./service-adapter.js";
26
- import { applyAndEmitLoaded, saveAndEmitChanged } from "./settings.js";
26
+ import { SettingsManager } from "./settings.js";
27
27
  import { createAgentTool } from "./tools/agent-tool.js";
28
28
  import { createGetResultTool } from "./tools/get-result-tool.js";
29
29
  import { getModelLabelFromConfig } from "./tools/helpers.js";
@@ -55,6 +55,15 @@ export default function (pi: ExtensionAPI) {
55
55
  updateWidget: () => runtime.updateWidget(),
56
56
  });
57
57
 
58
+ // Settings: owns all three in-memory values and handles load/save/emit.
59
+ // onMaxConcurrentChanged is wired after manager is constructed (closure captures by reference).
60
+ const settings = new SettingsManager({
61
+ emit: (event, payload) => pi.events.emit(event, payload),
62
+ cwd: process.cwd(),
63
+ onMaxConcurrentChanged: () => manager.notifyConcurrencyChanged(),
64
+ });
65
+ settings.load();
66
+
58
67
  // Background completion: emit lifecycle event and delegate to notification system
59
68
  const manager = new AgentManager({
60
69
  runner: { run: runAgent, resume: resumeAgent },
@@ -105,7 +114,8 @@ export default function (pi: ExtensionAPI) {
105
114
  compactionCount: record.compactionCount,
106
115
  });
107
116
  },
108
- getRunConfig: () => ({ defaultMaxTurns: runtime.defaultMaxTurns, graceTurns: runtime.graceTurns }),
117
+ getMaxConcurrent: () => settings.maxConcurrent,
118
+ getRunConfig: () => settings,
109
119
  });
110
120
 
111
121
  // Typed service published via Symbol.for() for cross-extension access.
@@ -164,18 +174,6 @@ export default function (pi: ExtensionAPI) {
164
174
 
165
175
  const typeListText = buildTypeListText();
166
176
 
167
- // Apply persisted settings on startup and emit `subagents:settings_loaded`.
168
- // Global + project merged; missing → defaults; corrupt file emits a warning
169
- // to stderr and falls back to defaults.
170
- applyAndEmitLoaded(
171
- {
172
- setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
173
- setDefaultMaxTurns: (n) => { runtime.defaultMaxTurns = normalizeMaxTurns(n); },
174
- setGraceTurns: (n) => { runtime.graceTurns = Math.max(1, n); },
175
- },
176
- (event, payload) => pi.events.emit(event, payload),
177
- );
178
-
179
177
  // ---- Agent tool ----
180
178
 
181
179
  pi.registerTool(defineTool(createAgentTool({
@@ -184,7 +182,7 @@ export default function (pi: ExtensionAPI) {
184
182
  spawnAndWait: (ctx, type, prompt, opts) => manager.spawnAndWait(ctx, type, prompt, opts),
185
183
  resume: (id, prompt, signal) => manager.resume(id, prompt, signal),
186
184
  getRecord: (id) => manager.getRecord(id),
187
- getMaxConcurrent: () => manager.getMaxConcurrent(),
185
+ getMaxConcurrent: () => settings.maxConcurrent,
188
186
  listAgents: () => manager.listAgents(),
189
187
  },
190
188
  widget: {
@@ -199,7 +197,7 @@ export default function (pi: ExtensionAPI) {
199
197
  typeListText,
200
198
  availableTypesText: registry.getAvailableTypes().join(", "),
201
199
  agentDir: getAgentDir(),
202
- getDefaultMaxTurns: () => runtime.defaultMaxTurns,
200
+ settings,
203
201
  })));
204
202
 
205
203
  // ---- get_subagent_result tool ----
@@ -226,8 +224,6 @@ export default function (pi: ExtensionAPI) {
226
224
  listAgents: () => manager.listAgents(),
227
225
  getRecord: (id) => manager.getRecord(id),
228
226
  spawnAndWait: (ctx, type, prompt, opts) => manager.spawnAndWait(ctx, type, prompt, opts),
229
- getMaxConcurrent: () => manager.getMaxConcurrent(),
230
- setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
231
227
  },
232
228
  registry,
233
229
  agentActivity: runtime.agentActivity,
@@ -240,24 +236,7 @@ export default function (pi: ExtensionAPI) {
240
236
  }
241
237
  return getModelLabelFromConfig(cfg.model);
242
238
  },
243
- snapshotSettings: () => ({
244
- maxConcurrent: manager.getMaxConcurrent(),
245
- defaultMaxTurns: runtime.defaultMaxTurns ?? 0,
246
- graceTurns: runtime.graceTurns,
247
- }),
248
- getDefaultMaxTurns: () => runtime.defaultMaxTurns,
249
- getGraceTurns: () => runtime.graceTurns,
250
- setDefaultMaxTurns: (n) => {
251
- runtime.defaultMaxTurns = normalizeMaxTurns(n);
252
- },
253
- setGraceTurns: (n) => {
254
- runtime.graceTurns = Math.max(1, n);
255
- },
256
- saveSettings: (settings, successMsg) => saveAndEmitChanged(
257
- settings,
258
- successMsg,
259
- (event, payload) => pi.events.emit(event, payload),
260
- ),
239
+ settings,
261
240
  emitEvent: (name, data) => pi.events.emit(name, data),
262
241
  personalAgentsDir: join(getAgentDir(), 'agents'),
263
242
  projectAgentsDir: join(process.cwd(), '.pi', 'agents'),
package/src/runtime.ts CHANGED
@@ -24,12 +24,6 @@ export interface RunConfig {
24
24
  * Tests construct a fresh runtime per test for full isolation.
25
25
  */
26
26
  export class SubagentRuntime {
27
- // ── Execution config (was module-scope in agent-runner.ts) ──────────────
28
- /** Default max turns for all agents. undefined = unlimited. */
29
- defaultMaxTurns: number | undefined = undefined;
30
- /** Additional turns allowed after the soft-limit steer message. */
31
- graceTurns: number = 5;
32
-
33
27
  // ── Session state (was closure-scoped in index.ts) ───────────────────────
34
28
  /** Active Pi session context — set on session_start, cleared on session_shutdown. */
35
29
  currentCtx: { pi: unknown; ctx: unknown } | undefined = undefined;