@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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ 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
+ ## [6.6.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.5.0...pi-subagents-v6.6.0) (2026-05-21)
9
+
10
+
11
+ ### Features
12
+
13
+ * accept onMaxConcurrentChanged callback in SettingsManager constructor ([a6829ba](https://github.com/gotgenes/pi-packages/commit/a6829ba528caa7d106139de5217eddc89a18cf83))
14
+ * add SettingsManager.applyDefaultMaxTurns and applyGraceTurns methods ([02f8c65](https://github.com/gotgenes/pi-packages/commit/02f8c65297b80aaf1231960bf4b2ed2bdb13eeb4))
15
+ * add SettingsManager.applyMaxConcurrent method ([ad9de8a](https://github.com/gotgenes/pi-packages/commit/ad9de8a2553233bb34e7e07f9745f6e778ae1564))
16
+
17
+
18
+ ### Documentation
19
+
20
+ * mark A2b SettingsManager apply methods done in architecture ([#118](https://github.com/gotgenes/pi-packages/issues/118)) ([dafd480](https://github.com/gotgenes/pi-packages/commit/dafd4800b4b3a0cb5935ffd8f20c0a257b7c8be5))
21
+ * plan SettingsManager apply methods ([#118](https://github.com/gotgenes/pi-packages/issues/118)) ([51e14ac](https://github.com/gotgenes/pi-packages/commit/51e14aceaf4e30591ca993a06b459e9e1ae8f031))
22
+ * **retro:** add retro notes for issue [#109](https://github.com/gotgenes/pi-packages/issues/109) ([22e0ccb](https://github.com/gotgenes/pi-packages/commit/22e0ccb0a32d54472b0cece5c27d1cf80a2afe14))
23
+
24
+ ## [6.5.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.4.0...pi-subagents-v6.5.0) (2026-05-21)
25
+
26
+
27
+ ### Features
28
+
29
+ * add SettingsManager class with get/set normalization ([a21aa28](https://github.com/gotgenes/pi-packages/commit/a21aa28bc4b30b4c17ebfb88c939eba01756f39f))
30
+ * SettingsManager load, save, snapshot, and lifecycle events ([c3ece9f](https://github.com/gotgenes/pi-packages/commit/c3ece9f8429919da5a8397691b66020941555b37))
31
+
32
+
33
+ ### Documentation
34
+
35
+ * add A2b SettingsManager apply methods step ([#118](https://github.com/gotgenes/pi-packages/issues/118)) to architecture roadmap ([43a462a](https://github.com/gotgenes/pi-packages/commit/43a462a7b8e4ee4db2ca8eaa4d85e7e0d5ee4024))
36
+ * plan extract SettingsManager class ([#109](https://github.com/gotgenes/pi-packages/issues/109)) ([88cece7](https://github.com/gotgenes/pi-packages/commit/88cece74c3ff6726f37827aff7ac337fc720f642))
37
+ * **retro:** add retro notes for issue [#108](https://github.com/gotgenes/pi-packages/issues/108) ([55b1877](https://github.com/gotgenes/pi-packages/commit/55b187736f05aba381f5aa2554e451433e005c4d))
38
+ * update architecture doc — mark A2 SettingsManager complete ([#109](https://github.com/gotgenes/pi-packages/issues/109)) ([856baa6](https://github.com/gotgenes/pi-packages/commit/856baa64b65098193c3d9228d5a4c7af8f207c72))
39
+
8
40
  ## [6.4.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.3.1...pi-subagents-v6.4.0) (2026-05-21)
9
41
 
10
42
 
@@ -49,7 +49,7 @@ usage.ts — token usage tracking
49
49
  model-resolver.ts — fuzzy model name resolution
50
50
  invocation-config.ts — merge tool params with agent config
51
51
  session-dir.ts — subagent session directory derivation
52
- settings.ts — persistent operational settings
52
+ settings.ts — persistent operational settings; `SettingsManager` class owns all three in-memory values
53
53
 
54
54
  service.ts — SubagentsService interface + Symbol.for() accessors
55
55
  service-adapter.ts — SubagentsService implementation wrapping AgentManager
@@ -377,17 +377,17 @@ Each step is sequenced so it makes the next step easier.
377
377
 
378
378
  ### Current smells
379
379
 
380
- | Smell | Location | Evidence |
381
- | -------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
382
- | ~~Global mutable state~~ | ~~`agent-types.ts`~~ | **Fixed #108**: `AgentTypeRegistry` class; `reloadCustomAgents` callback removed from `AgentToolDeps` and `AgentMenuDeps` |
383
- | Closure bag as class | `createNotificationSystem()` | Returns 4 functions sharing closure state (`pendingNudges`, timers) |
384
- | Mutable state bag | `AgentActivity` (7 fields) | Written by `ui-observer.ts`, read by widget, notification, agent-tool |
385
- | Settings relay | `AgentMenuDeps` (13 fields) | 6 fields are settings get/set pairs threaded through as callbacks |
386
- | Post-construction mutation | `AgentRecord` non-transition state | `session`, `outputFile`, `worktree`, `promise` written by external code after construction |
387
- | Fire-and-forget callbacks | `AgentManagerOptions` | `onStart`, `onComplete`, `onCompact` wired as closures in `index.ts` |
388
- | Duplicate `SpawnOptions` | `service.ts` + `agent-manager.ts` | Two incompatible shapes (JSON-friendly vs runtime types) with the same name |
389
- | Type dumping ground | `types.ts` | `NotificationDetails` used only by notification/renderer; ~~`DEFAULT_AGENT_NAMES` moved to `AgentTypeRegistry` (#108)~~; `AgentConfig` (21 fields) consumers use 2–4 each |
390
- | Wide dependency bags | `AgentToolDeps` (8), `AgentMenuDeps` (12) | `reloadCustomAgents` replaced by `registry` (#108); more narrowing planned in D steps |
380
+ | Smell | Location | Evidence |
381
+ | -------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
382
+ | ~~Global mutable state~~ | ~~`agent-types.ts`~~ | **Fixed #108**: `AgentTypeRegistry` class; `reloadCustomAgents` callback removed from `AgentToolDeps` and `AgentMenuDeps` |
383
+ | Closure bag as class | `createNotificationSystem()` | Returns 4 functions sharing closure state (`pendingNudges`, timers) |
384
+ | Mutable state bag | `AgentActivity` (7 fields) | Written by `ui-observer.ts`, read by widget, notification, agent-tool |
385
+ | ~~Settings relay~~ | ~~`AgentMenuDeps` (13 fields)~~ | **Fixed #109**: `SettingsManager` class; 6 callback fields collapsed to `settings: SettingsManager`; `AgentMenuDeps` now 8 fields |
386
+ | Post-construction mutation | `AgentRecord` non-transition state | `session`, `outputFile`, `worktree`, `promise` written by external code after construction |
387
+ | Fire-and-forget callbacks | `AgentManagerOptions` | `onStart`, `onComplete`, `onCompact` wired as closures in `index.ts` |
388
+ | Duplicate `SpawnOptions` | `service.ts` + `agent-manager.ts` | Two incompatible shapes (JSON-friendly vs runtime types) with the same name |
389
+ | Type dumping ground | `types.ts` | `NotificationDetails` used only by notification/renderer; ~~`DEFAULT_AGENT_NAMES` moved to `AgentTypeRegistry` (#108)~~; `AgentConfig` (21 fields) consumers use 2–4 each |
390
+ | Wide dependency bags | `AgentToolDeps` (7), `AgentMenuDeps` (8) | Settings narrowed (#109); registry narrowed (#108); more narrowing planned in D steps |
391
391
 
392
392
  ### Step A: Extract state into classes (foundation, parallel)
393
393
 
@@ -402,13 +402,25 @@ Wrap the module-scoped `agents` Map and free functions in `agent-types.ts` into
402
402
 
403
403
  Impact: eliminates global mutable state, enables test isolation without module resets, removes `reloadCustomAgents` callback from 2 dependency bags.
404
404
 
405
- #### A2. `SettingsManager` class (#109)
405
+ #### ~~A2. `SettingsManager` class (#109)~~ — **Done**
406
406
 
407
- Encapsulate the settings load/save/apply cycle into a class that owns the in-memory values and the persistence layer.
408
- Absorbs `SettingsAppliers`, `applyAndEmitLoaded`, `saveAndEmitChanged` from free functions in `settings.ts`.
409
- The 6 settings-related fields in `AgentMenuDeps` (`getDefaultMaxTurns`, `setDefaultMaxTurns`, `getGraceTurns`, `setGraceTurns`, `snapshotSettings`, `saveSettings`) collapse to a single `settings: SettingsManager` collaborator.
407
+ Encapsulated settings load/save/apply cycle into `SettingsManager` (in `settings.ts`).
408
+ Owns `defaultMaxTurns`, `graceTurns`, `maxConcurrent` with normalizing property accessors.
409
+ Absorbed `SettingsAppliers`, `applyAndEmitLoaded`, `saveAndEmitChanged`.
410
+ The 6 settings-related fields in `AgentMenuDeps` collapsed to `settings: AgentMenuSettings`.
411
+ `AgentManager` reads `maxConcurrent` via injected `getMaxConcurrent` function.
412
+ `SubagentRuntime.defaultMaxTurns` and `.graceTurns` removed.
410
413
 
411
- Impact: reduces `AgentMenuDeps` from 13 → 8 fields.
414
+ Impact: reduced `AgentMenuDeps` from 13 → 8 fields; `AgentToolDeps` from 8 → 7 fields.
415
+
416
+ #### ~~A2b. `SettingsManager` apply methods (#118)~~ — **Done**
417
+
418
+ Added `applyMaxConcurrent(n)`, `applyDefaultMaxTurns(n)`, `applyGraceTurns(n)` to `SettingsManager`.
419
+ Each owns the full consequence chain: normalize → set in memory → notify callback → persist → emit event → return toast.
420
+ `SettingsManager` accepts an `onMaxConcurrentChanged` callback (wired to `manager.notifyConcurrencyChanged()` at init).
421
+ `notifyConcurrencyChanged` removed from `AgentMenuManager`; `showSettings` now makes a single apply call per setting.
422
+
423
+ Impact: eliminates LoD / Tell-Don't-Ask violation in `showSettings`; menu no longer coordinates between settings and manager.
412
424
 
413
425
  #### A3. `AgentActivityTracker` class (#110)
414
426
 
@@ -449,10 +461,10 @@ The two types serve different consumers and should not share a name.
449
461
 
450
462
  #### D2. Narrow `AgentToolDeps` and `AgentMenuDeps` (#114)
451
463
 
452
- | Bag | Before | After | How |
453
- | --------------- | --------- | ----- | --------------------------------------------------------------------------------------- |
454
- | `AgentToolDeps` | 9 fields | ~5 | Registry owns reload; activity tracker is a collaborator; `emitEvent` moves to observer |
455
- | `AgentMenuDeps` | 13 fields | ~6 | Settings manager absorbs 6 fields; registry owns reload |
464
+ | Bag | Before | After | How |
465
+ | --------------- | --------- | ----- | ---------------------------------------------------------------------------------------------------------------------- |
466
+ | `AgentToolDeps` | 9 fields | ~5 | Registry owns reload; activity tracker is a collaborator; `emitEvent` moves to observer |
467
+ | `AgentMenuDeps` | 13 fields | ~6 | Settings manager absorbs 6 fields (#109); apply methods remove `notifyConcurrencyChanged` (#118); registry owns reload |
456
468
 
457
469
  ### Step E: Decompose large files and relocate types (parallel)
458
470
 
@@ -473,23 +485,23 @@ The 654-line file splits along a natural seam.
473
485
 
474
486
  ### Expected impact
475
487
 
476
- | Metric | Before | After |
477
- | ------------------------------------------ | ------------------------------------------------------------------------------------------------ | ------- |
478
- | Module-scoped mutable state | ~~1 (`agent-types.ts` Map)~~ | **0** ✓ |
479
- | Closure-bag "classes" | 2 (`createNotificationSystem`, settings free functions) | 0 |
480
- | Externally-mutated state bags | 2 (`AgentActivity`, `AgentRecord` non-transition fields) | 0 |
481
- | `AgentManagerOptions` fields | 8 | 5 |
482
- | `AgentToolDeps` fields | 9 | ~5 |
483
- | `AgentMenuDeps` fields | 13 | ~6 |
484
- | `SpawnOptions` callback fields | 1 (`onSessionCreated`) | 0 |
485
- | Callbacks threaded through deps | 8 (~~`reloadCustomAgents` ×2~~ fixed #108, `emitEvent` ×3, settings ×6, `getDefaultMaxTurns` ×2) | 0 |
486
- | Types in `types.ts` without a natural home | 4 | 0 |
488
+ | Metric | Before | After |
489
+ | ------------------------------------------ | ---------------------------------------------------------------------------- | ------- |
490
+ | Module-scoped mutable state | ~~1 (`agent-types.ts` Map)~~ | **0** ✓ |
491
+ | Closure-bag "classes" | ~~2~~ 1 (`createNotificationSystem`; settings free functions **fixed #109**) | 0 |
492
+ | Externally-mutated state bags | 2 (`AgentActivity`, `AgentRecord` non-transition fields) | 0 |
493
+ | `AgentManagerOptions` fields | 8 | 5 |
494
+ | `AgentToolDeps` fields | ~~9~~ **7** (−6 registry #108, −1 settings #109 → +1 settings obj) | ~5 |
495
+ | `AgentMenuDeps` fields | ~~13~~ **8** (−6 settings #109 collapsed to 1; −1 registry #108) | ~6|
496
+ | `SpawnOptions` callback fields | 1 (`onSessionCreated`) | 0 |
497
+ | Callbacks threaded through deps | ~~8~~ 0 remaining settings callbacks (**fixed #109**); `emitEvent` ×3 remain | 0 |
498
+ | Types in `types.ts` without a natural home | 4 | 0 |
487
499
 
488
500
  ### Dependency graph
489
501
 
490
502
  ```text
491
503
  A1 (Registry) ──────────────────┐
492
- A2 (Settings) ──────────────────┤
504
+ A2 (Settings) ── A2b (Apply) ──┤
493
505
  A3 (Activity Tracker) ───────────┤
494
506
  ├── D2 (Narrow deps) ── E1 (agent-tool split)
495
507
  B (Record lifecycle) ───────────┤
@@ -0,0 +1,276 @@
1
+ ---
2
+ issue: 109
3
+ issue_title: "refactor(pi-subagents): extract SettingsManager class"
4
+ ---
5
+
6
+ # Extract SettingsManager class
7
+
8
+ ## Problem Statement
9
+
10
+ The settings read/write/persist cycle is spread across free functions in `settings.ts` (`loadSettings`, `saveSettings`, `applySettings`, `applyAndEmitLoaded`, `saveAndEmitChanged`), a `SettingsAppliers` callback interface, and 6 settings-related fields in `AgentMenuDeps`.
11
+ The in-memory values live on two different objects (`SubagentRuntime` for `defaultMaxTurns`/`graceTurns`, `AgentManager` for `maxConcurrent`).
12
+ This is mutable state plus the methods that read and write it — a class waiting to happen.
13
+
14
+ ## Goals
15
+
16
+ - Encapsulate the settings concern into a single testable `SettingsManager` class.
17
+ - Own all three in-memory settings values (`defaultMaxTurns`, `graceTurns`, `maxConcurrent`).
18
+ - Absorb the `SettingsAppliers` interface and the composite functions `applyAndEmitLoaded`, `saveAndEmitChanged`.
19
+ - Collapse the 6 settings-related fields in `AgentMenuDeps` to a single `settings` collaborator (13 → 8 fields).
20
+ - Replace `getDefaultMaxTurns` in `AgentToolDeps` with a narrow settings accessor.
21
+ - Move `maxConcurrent` ownership from `AgentManager` to `SettingsManager`; `AgentManager` reads via injected function.
22
+ - Keep pure helpers (`sanitize`, `loadSettings`, `saveSettings`, `persistToastFor`) as private/internal implementation details.
23
+ - This is a non-breaking refactoring change — no public API surface changes.
24
+
25
+ ## Non-Goals
26
+
27
+ - Changing the persistence format (`subagents.json`) or the global-vs-project merge strategy.
28
+ - Extracting `AgentActivityTracker` (#110) — that is the next step in Phase 7.
29
+ - Changing the `SubagentsService` public API (`service.ts`).
30
+ - Touching `RunConfig` — it stays as-is; `SettingsManager` naturally satisfies it.
31
+
32
+ ## Background
33
+
34
+ ### Current module map
35
+
36
+ | Module | Settings concern |
37
+ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
38
+ | `settings.ts` | Free functions: `loadSettings`, `saveSettings`, `applySettings`, `applyAndEmitLoaded`, `saveAndEmitChanged`, `persistToastFor`. Types: `SubagentsSettings`, `SettingsAppliers`, `SettingsEmit`. |
39
+ | `runtime.ts` | `SubagentRuntime.defaultMaxTurns` and `.graceTurns` — mutable fields read by `AgentManager` via `getRunConfig`. |
40
+ | `agent-manager.ts` | `private maxConcurrent` — used for queue decisions (`spawn`, `drainQueue`). Exposed via `get/setMaxConcurrent`. |
41
+ | `index.ts` | Wires callbacks: constructs `SettingsAppliers` closures, calls `applyAndEmitLoaded` at startup, builds 6 callback fields for `AgentMenuDeps`. |
42
+ | `ui/agent-menu.ts` | `AgentMenuDeps` has 6 settings fields: `getDefaultMaxTurns`, `setDefaultMaxTurns`, `getGraceTurns`, `setGraceTurns`, `snapshotSettings`, `saveSettings`. `AgentMenuManager` has `getMaxConcurrent`, `setMaxConcurrent`. |
43
+ | `tools/agent-tool.ts` | `AgentToolDeps.getDefaultMaxTurns` — reads the runtime default for the Agent tool. |
44
+
45
+ ### Architecture reference
46
+
47
+ Phase 7, Step A2 in `docs/architecture/architecture.md`.
48
+ Predecessor A1 (`AgentTypeRegistry`, #108) is complete.
49
+
50
+ ### Applicable constraints (from AGENTS.md / code-design)
51
+
52
+ - One concern per file — the class consolidates what is currently scattered.
53
+ - Prefer explicit configuration over hidden behavior.
54
+ - Dependency inversion — consumers accept narrow interfaces, not the concrete class.
55
+ - No output arguments — the current `SettingsAppliers` callback pattern writes into external state; the class owns the state directly.
56
+ - ES2024 target; pnpm only.
57
+
58
+ ## Design Overview
59
+
60
+ ### SettingsManager class
61
+
62
+ ```typescript
63
+ export class SettingsManager {
64
+ // Private fields with built-in defaults
65
+ private _defaultMaxTurns: number | undefined = undefined;
66
+ private _graceTurns: number = 5;
67
+ private _maxConcurrent: number = 4; // DEFAULT_MAX_CONCURRENT
68
+
69
+ private readonly emit: SettingsEmit;
70
+ private readonly cwd: string;
71
+
72
+ constructor(deps: { emit: SettingsEmit; cwd: string });
73
+
74
+ // ── Property accessors with normalization ──
75
+ get defaultMaxTurns(): number | undefined;
76
+ set defaultMaxTurns(n: number | undefined); // 0 or undefined → undefined; else max(1, n)
77
+
78
+ get graceTurns(): number;
79
+ set graceTurns(n: number); // max(1, n)
80
+
81
+ get maxConcurrent(): number;
82
+ set maxConcurrent(n: number); // max(1, n)
83
+
84
+ // ── Lifecycle methods ──
85
+
86
+ /** Load merged settings (global + project), apply to in-memory, emit settings_loaded. */
87
+ load(): SubagentsSettings;
88
+
89
+ /** Snapshot current values for persistence (defaultMaxTurns uses 0 for unlimited). */
90
+ snapshot(): { maxConcurrent: number; defaultMaxTurns: number; graceTurns: number };
91
+
92
+ /** Persist snapshot, emit settings_changed, return toast. */
93
+ saveAndNotify(successMsg: string): { message: string; level: "info" | "warning" };
94
+ }
95
+ ```
96
+
97
+ The setter for `defaultMaxTurns` inlines the `normalizeMaxTurns` logic (`0 → undefined`, else `Math.max(1, n)`) to avoid a new dependency from `settings.ts` → `agent-runner.ts`.
98
+ The `normalizeMaxTurns` export in `agent-runner.ts` stays for per-invocation normalization in the Agent tool.
99
+
100
+ ### maxConcurrent ownership transfer
101
+
102
+ `AgentManager` currently owns `maxConcurrent` and calls `this.drainQueue()` in `setMaxConcurrent`.
103
+ After the change:
104
+
105
+ 1. `SettingsManager` owns the value.
106
+ 2. `AgentManager` accepts a `getMaxConcurrent: () => number` function (injected via `AgentManagerOptions`).
107
+ All internal reads (`spawn` queue check, `drainQueue` loop) use `this.getMaxConcurrent()` instead of `this.maxConcurrent`.
108
+ 3. `AgentManager.setMaxConcurrent(n)` is replaced with `notifyConcurrencyChanged()` — it only drains the queue; the value has already been set on `SettingsManager` by the caller.
109
+ 4. `AgentMenuManager` loses `getMaxConcurrent` and `setMaxConcurrent`; gains `notifyConcurrencyChanged`.
110
+ The menu reads `settings.maxConcurrent` directly for display.
111
+
112
+ ### Consumer interface narrowing
113
+
114
+ Each consumer gets the narrowest type it needs:
115
+
116
+ | Consumer | Interface |
117
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
118
+ | `AgentMenuDeps.settings` | `{ maxConcurrent: number; defaultMaxTurns: number \| undefined; graceTurns: number; saveAndNotify(msg: string): { message: string; level: "info" \| "warning" } }` |
119
+ | `AgentToolDeps.settings` | `{ readonly defaultMaxTurns: number \| undefined }` |
120
+ | `AgentManagerOptions.getMaxConcurrent` | `() => number` (function, not object) |
121
+ | `AgentManagerOptions.getRunConfig` | Unchanged — `SettingsManager` satisfies `RunConfig` structurally. |
122
+
123
+ ### RunConfig compatibility
124
+
125
+ `SettingsManager` has `defaultMaxTurns` and `graceTurns` as readable properties, so it structurally satisfies the existing `RunConfig` interface.
126
+ In `index.ts`, the `getRunConfig` option becomes `() => settings` (returning the `SettingsManager` instance directly).
127
+
128
+ ### SubagentRuntime cleanup
129
+
130
+ `SubagentRuntime.defaultMaxTurns` and `.graceTurns` are removed.
131
+ These fields exist solely for settings; after the extraction, `SubagentRuntime` only retains session state and widget delegation.
132
+
133
+ ## Module-Level Changes
134
+
135
+ ### `src/settings.ts`
136
+
137
+ - **Add** `SettingsManager` class (constructor, 3 property accessors, `load`, `snapshot`, `saveAndNotify`).
138
+ - **Keep** `SubagentsSettings`, `SettingsEmit`, `loadSettings`, `saveSettings`, `sanitize`, `persistToastFor` as internal helpers (some may become unexported).
139
+ - **Remove** `SettingsAppliers` interface.
140
+ - **Remove** `applySettings`, `applyAndEmitLoaded`, `saveAndEmitChanged` functions.
141
+
142
+ ### `src/runtime.ts`
143
+
144
+ - **Remove** `defaultMaxTurns` and `graceTurns` fields from `SubagentRuntime`.
145
+ - **Remove** `RunConfig` interface (no longer needed — consumers read from `SettingsManager` directly).
146
+
147
+ ### `src/agent-manager.ts`
148
+
149
+ - **Change** `AgentManagerOptions`: remove `maxConcurrent?: number`; add `getMaxConcurrent?: () => number`.
150
+ - **Change** constructor: store `getMaxConcurrent` function instead of a value.
151
+ - **Replace** `private maxConcurrent: number` with `private readonly getMaxConcurrent: () => number`.
152
+ - **Replace** `setMaxConcurrent(n)` with `notifyConcurrencyChanged()` (public, just calls `drainQueue()`).
153
+ - **Keep** `getRunConfig` — wiring changes in `index.ts` to point at settings.
154
+
155
+ ### `src/ui/agent-menu.ts`
156
+
157
+ - **Remove** from `AgentMenuDeps`: `getDefaultMaxTurns`, `setDefaultMaxTurns`, `getGraceTurns`, `setGraceTurns`, `snapshotSettings`, `saveSettings`.
158
+ - **Add** to `AgentMenuDeps`: `settings` with the narrow inline interface.
159
+ - **Remove** from `AgentMenuManager`: `getMaxConcurrent`, `setMaxConcurrent`.
160
+ - **Add** to `AgentMenuManager`: `notifyConcurrencyChanged: () => void`.
161
+ - **Update** `showSettings`: read from `deps.settings`, write to `deps.settings`, call `deps.manager.notifyConcurrencyChanged()` after concurrency change.
162
+ - **Update** `notifyApplied`: call `deps.settings.saveAndNotify(msg)`.
163
+
164
+ ### `src/tools/agent-tool.ts`
165
+
166
+ - **Remove** `getDefaultMaxTurns` from `AgentToolDeps`.
167
+ - **Add** `settings: { readonly defaultMaxTurns: number | undefined }` to `AgentToolDeps`.
168
+ - **Update** usage: `deps.getDefaultMaxTurns()` → `deps.settings.defaultMaxTurns`.
169
+
170
+ ### `src/index.ts`
171
+
172
+ - **Create** `SettingsManager` before `AgentManager`; call `.load()`.
173
+ - **Pass** `getMaxConcurrent: () => settings.maxConcurrent` to `AgentManager`.
174
+ - **Pass** `getRunConfig: () => settings` to `AgentManager`.
175
+ - **Pass** `settings` to `AgentMenuDeps` and `AgentToolDeps`.
176
+ - **Remove** the ad-hoc `applyAndEmitLoaded` call and the 6 callback fields.
177
+ - **Remove** `runtime.defaultMaxTurns` and `runtime.graceTurns` references.
178
+
179
+ ## Test Impact Analysis
180
+
181
+ ### New unit tests enabled
182
+
183
+ - **Integrated settings lifecycle**: construct → `load()` → mutate → `saveAndNotify()` → verify snapshot and events, all on a single object.
184
+ Previously impossible because state was scattered across free functions, callbacks, and two separate objects.
185
+ - **Normalization in setters**: direct tests for `set defaultMaxTurns(0) → undefined`, `set graceTurns(0) → 1`, `set maxConcurrent(0) → 1`.
186
+ Previously tested only indirectly through `applySettings` + callback mocks.
187
+ - **Snapshot consistency**: verify that `snapshot()` reflects the current in-memory state after mutations.
188
+
189
+ ### Existing tests that become redundant
190
+
191
+ - `applySettings` tests — the SettingsAppliers callback pattern is removed; normalization logic moves into the class setters.
192
+ - `applyAndEmitLoaded` tests — absorbed into `SettingsManager.load()` tests.
193
+ - `saveAndEmitChanged` tests — absorbed into `SettingsManager.saveAndNotify()` tests.
194
+
195
+ ### Existing tests that must stay
196
+
197
+ - All `loadSettings` / `saveSettings` / sanitizer tests — these test the I/O + validation layer, which remains as internal helpers.
198
+ - `persistToastFor` tests — pure function, still used internally by `saveAndNotify`.
199
+ - `agent-menu.test.ts` settings tests — still needed; mock shape changes from 6 fields to a settings object.
200
+ - `agent-tool.test.ts` — mock shape changes from `getDefaultMaxTurns` function to `settings` object.
201
+ - `agent-manager.test.ts` — mock shape changes from `maxConcurrent` number to `getMaxConcurrent` function.
202
+
203
+ ## TDD Order
204
+
205
+ ### Cycle 1: SettingsManager — constructor, defaults, get/set normalization
206
+
207
+ 1. Red: test constructor produces correct defaults (`defaultMaxTurns: undefined`, `graceTurns: 5`, `maxConcurrent: 4`).
208
+ Test setter normalization: `defaultMaxTurns = 0 → undefined`, `graceTurns = 0 → 1`, `maxConcurrent = 0 → 1`, `defaultMaxTurns = 10 → 10`.
209
+ 2. Green: implement `SettingsManager` class with private fields, constructor, and property accessors.
210
+ 3. Commit: `feat: add SettingsManager class with get/set normalization`
211
+
212
+ ### Cycle 2: SettingsManager — load, snapshot, saveAndNotify, events
213
+
214
+ 1. Red: test `load()` reads merged settings from disk, applies to in-memory values, emits `subagents:settings_loaded`.
215
+ Test `snapshot()` returns current values with `defaultMaxTurns ?? 0`.
216
+ Test `saveAndNotify()` persists to disk, emits `subagents:settings_changed`, returns toast.
217
+ Test save failure returns warning-level toast.
218
+ 2. Green: implement `load()`, `snapshot()`, `saveAndNotify()` using existing `loadSettings`, `saveSettings`, `persistToastFor`.
219
+ 3. Commit: `feat: SettingsManager load, save, snapshot, and lifecycle events`
220
+
221
+ ### Cycle 3: Narrow AgentMenuDeps — collapse 6 fields to settings
222
+
223
+ 1. Red: update `makeDeps` in `agent-menu.test.ts` — replace 6 settings fields with a `settings` mock object; replace `getMaxConcurrent`/`setMaxConcurrent` on manager with `notifyConcurrencyChanged`.
224
+ All existing menu tests fail due to mock shape change.
225
+ 2. Green: update `AgentMenuDeps` and `AgentMenuManager` interfaces; update `showSettings` and `notifyApplied` to use `deps.settings`.
226
+ 3. Run `pnpm run check` to verify types.
227
+ 4. Commit: `refactor: collapse settings fields in AgentMenuDeps to SettingsManager`
228
+
229
+ ### Cycle 4: Narrow AgentToolDeps — replace getDefaultMaxTurns
230
+
231
+ 1. Red: update `makeDeps` in `agent-tool.test.ts` — replace `getDefaultMaxTurns` with `settings: { defaultMaxTurns: undefined }`.
232
+ 2. Green: update `AgentToolDeps` interface; update `createAgentTool` to read `deps.settings.defaultMaxTurns`.
233
+ 3. Run `pnpm run check`.
234
+ 4. Commit: `refactor: replace getDefaultMaxTurns with settings in AgentToolDeps`
235
+
236
+ ### Cycle 5: Move maxConcurrent from AgentManager to SettingsManager
237
+
238
+ 1. Red: update `agent-manager.test.ts` — replace `maxConcurrent` option with `getMaxConcurrent` function; replace `setMaxConcurrent` calls with `notifyConcurrencyChanged`.
239
+ 2. Green: update `AgentManagerOptions` (replace `maxConcurrent?: number` with `getMaxConcurrent?: () => number`), update constructor, replace `private maxConcurrent` with `private readonly getMaxConcurrent`, rename `setMaxConcurrent` → `notifyConcurrencyChanged`.
240
+ 3. Run `pnpm run check`.
241
+ 4. Commit: `refactor: AgentManager reads maxConcurrent from SettingsManager`
242
+
243
+ ### Cycle 6: Wire SettingsManager in index.ts
244
+
245
+ 1. Update `index.ts`: create `SettingsManager` before `AgentManager`; call `.load()`; pass to all consumers; remove `applyAndEmitLoaded` call and ad-hoc callback closures.
246
+ 2. Run full test suite.
247
+ 3. Commit: `refactor: wire SettingsManager in extension init`
248
+
249
+ ### Cycle 7: Remove SubagentRuntime settings fields
250
+
251
+ 1. Remove `defaultMaxTurns` and `graceTurns` from `SubagentRuntime`.
252
+ 2. Remove `RunConfig` interface from `runtime.ts` (import from `settings.ts` if still needed, or inline).
253
+ 3. Run `pnpm run check`.
254
+ 4. Commit: `refactor: remove settings fields from SubagentRuntime`
255
+
256
+ ### Cycle 8: Remove superseded free functions and types
257
+
258
+ 1. Remove `SettingsAppliers`, `applySettings`, `applyAndEmitLoaded`, `saveAndEmitChanged` from `settings.ts`.
259
+ 2. Remove corresponding test sections from `settings.test.ts`.
260
+ 3. Make `loadSettings`, `saveSettings` unexported if no external consumers remain (keep exported if tests import them directly for the sanitizer/IO tests).
261
+ 4. Run full test suite.
262
+ 5. Commit: `refactor: remove superseded settings free functions and SettingsAppliers`
263
+
264
+ ## Risks and Mitigations
265
+
266
+ | Risk | Mitigation |
267
+ | ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
268
+ | `maxConcurrent` ownership transfer breaks queue drain timing | `notifyConcurrencyChanged()` preserves the drain-on-change behavior; `AgentManager` reads via function on every queue decision, so the value is always current. |
269
+ | Large mock updates in agent-menu.test.ts cause merge conflicts | Lift-and-shift: cycles 1–2 add the new class without touching existing code; cycles 3–5 migrate one consumer at a time. |
270
+ | `normalizeMaxTurns` logic duplicated between setter and agent-runner.ts | The setter inlines trivial normalization (`0 → undefined`, else `max(1, n)`); `normalizeMaxTurns` stays in `agent-runner.ts` for per-invocation use. Both are simple enough that duplication is cheaper than a shared dependency. |
271
+ | `RunConfig` removal from `runtime.ts` breaks imports | Grep all `RunConfig` imports before removing; move the type to `settings.ts` or inline at the use site if needed. |
272
+
273
+ ## Open Questions
274
+
275
+ - Should `loadSettings` and `saveSettings` remain exported (for the standalone sanitizer/IO tests) or become private to the module?
276
+ Defer until cycle 8 — check whether any test imports them directly.