@gotgenes/pi-subagents 6.4.0 → 6.5.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 +16 -0
- package/docs/architecture/architecture.md +46 -33
- package/docs/plans/0109-extract-settings-manager.md +276 -0
- package/docs/retro/0108-extract-agent-type-registry.md +41 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +11 -13
- package/src/index.ts +15 -37
- package/src/runtime.ts +0 -6
- package/src/settings.ts +94 -46
- package/src/tools/agent-tool.ts +3 -3
- package/src/ui/agent-menu.ts +24 -28
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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.5.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.4.0...pi-subagents-v6.5.0) (2026-05-21)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add SettingsManager class with get/set normalization ([a21aa28](https://github.com/gotgenes/pi-packages/commit/a21aa28bc4b30b4c17ebfb88c939eba01756f39f))
|
|
14
|
+
* SettingsManager load, save, snapshot, and lifecycle events ([c3ece9f](https://github.com/gotgenes/pi-packages/commit/c3ece9f8429919da5a8397691b66020941555b37))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* 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))
|
|
20
|
+
* plan extract SettingsManager class ([#109](https://github.com/gotgenes/pi-packages/issues/109)) ([88cece7](https://github.com/gotgenes/pi-packages/commit/88cece74c3ff6726f37827aff7ac337fc720f642))
|
|
21
|
+
* **retro:** add retro notes for issue [#108](https://github.com/gotgenes/pi-packages/issues/108) ([55b1877](https://github.com/gotgenes/pi-packages/commit/55b187736f05aba381f5aa2554e451433e005c4d))
|
|
22
|
+
* 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))
|
|
23
|
+
|
|
8
24
|
## [6.4.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.3.1...pi-subagents-v6.4.0) (2026-05-21)
|
|
9
25
|
|
|
10
26
|
|
|
@@ -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
|
|
381
|
-
| -------------------------- |
|
|
382
|
-
| ~~Global mutable state~~ | ~~`agent-types.ts`~~
|
|
383
|
-
| Closure bag as class | `createNotificationSystem()`
|
|
384
|
-
| Mutable state bag | `AgentActivity` (7 fields)
|
|
385
|
-
| Settings relay
|
|
386
|
-
| Post-construction mutation | `AgentRecord` non-transition state
|
|
387
|
-
| Fire-and-forget callbacks | `AgentManagerOptions`
|
|
388
|
-
| Duplicate `SpawnOptions` | `service.ts` + `agent-manager.ts`
|
|
389
|
-
| Type dumping ground | `types.ts`
|
|
390
|
-
| Wide dependency bags | `AgentToolDeps` (
|
|
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,26 @@ 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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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:
|
|
414
|
+
Impact: reduced `AgentMenuDeps` from 13 → 8 fields; `AgentToolDeps` from 8 → 7 fields.
|
|
415
|
+
|
|
416
|
+
#### A2b. `SettingsManager` apply methods (#118)
|
|
417
|
+
|
|
418
|
+
Eliminate the cross-collaborator orchestration in `showSettings`.
|
|
419
|
+
The menu currently mutates `settings`, pokes `manager.notifyConcurrencyChanged()`, then calls `settings.saveAndNotify()` — it knows too much about the consequence chain.
|
|
420
|
+
Add `applyMaxConcurrent(n)`, `applyDefaultMaxTurns(n)`, `applyGraceTurns(n)` methods that own the full lifecycle: normalize → apply → notify interested parties (via callback) → persist → emit event → return toast.
|
|
421
|
+
`SettingsManager` accepts an `onMaxConcurrentChanged` callback wired to `manager.notifyConcurrencyChanged()` at init.
|
|
422
|
+
`notifyConcurrencyChanged` disappears from `AgentMenuManager`.
|
|
423
|
+
|
|
424
|
+
Impact: eliminates LoD / Tell-Don't-Ask violation; menu no longer coordinates between settings and manager.
|
|
412
425
|
|
|
413
426
|
#### A3. `AgentActivityTracker` class (#110)
|
|
414
427
|
|
|
@@ -449,10 +462,10 @@ The two types serve different consumers and should not share a name.
|
|
|
449
462
|
|
|
450
463
|
#### D2. Narrow `AgentToolDeps` and `AgentMenuDeps` (#114)
|
|
451
464
|
|
|
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
|
|
465
|
+
| Bag | Before | After | How |
|
|
466
|
+
| --------------- | --------- | ----- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
467
|
+
| `AgentToolDeps` | 9 fields | ~5 | Registry owns reload; activity tracker is a collaborator; `emitEvent` moves to observer |
|
|
468
|
+
| `AgentMenuDeps` | 13 fields | ~6 | Settings manager absorbs 6 fields (#109); apply methods remove `notifyConcurrencyChanged` (#118); registry owns reload |
|
|
456
469
|
|
|
457
470
|
### Step E: Decompose large files and relocate types (parallel)
|
|
458
471
|
|
|
@@ -473,23 +486,23 @@ The 654-line file splits along a natural seam.
|
|
|
473
486
|
|
|
474
487
|
### Expected impact
|
|
475
488
|
|
|
476
|
-
| Metric | Before
|
|
477
|
-
| ------------------------------------------ |
|
|
478
|
-
| Module-scoped mutable state | ~~1 (`agent-types.ts` Map)~~
|
|
479
|
-
| Closure-bag "classes" | 2 (`createNotificationSystem
|
|
480
|
-
| Externally-mutated state bags | 2 (`AgentActivity`, `AgentRecord` non-transition fields)
|
|
481
|
-
| `AgentManagerOptions` fields | 8
|
|
482
|
-
| `AgentToolDeps` fields | 9
|
|
483
|
-
| `AgentMenuDeps` fields | 13
|
|
484
|
-
| `SpawnOptions` callback fields | 1 (`onSessionCreated`)
|
|
485
|
-
| Callbacks threaded through deps | 8
|
|
486
|
-
| Types in `types.ts` without a natural home | 4
|
|
489
|
+
| Metric | Before | After |
|
|
490
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------- | ------- |
|
|
491
|
+
| Module-scoped mutable state | ~~1 (`agent-types.ts` Map)~~ | **0** ✓ |
|
|
492
|
+
| Closure-bag "classes" | ~~2~~ 1 (`createNotificationSystem`; settings free functions **fixed #109**) | 0 |
|
|
493
|
+
| Externally-mutated state bags | 2 (`AgentActivity`, `AgentRecord` non-transition fields) | 0 |
|
|
494
|
+
| `AgentManagerOptions` fields | 8 | 5 |
|
|
495
|
+
| `AgentToolDeps` fields | ~~9~~ **7** (−6 registry #108, −1 settings #109 → +1 settings obj) | ~5 |
|
|
496
|
+
| `AgentMenuDeps` fields | ~~13~~ **8** (−6 settings #109 collapsed to 1; −1 registry #108) | ~6 ✓ |
|
|
497
|
+
| `SpawnOptions` callback fields | 1 (`onSessionCreated`) | 0 |
|
|
498
|
+
| Callbacks threaded through deps | ~~8~~ 0 remaining settings callbacks (**fixed #109**); `emitEvent` ×3 remain | 0 |
|
|
499
|
+
| Types in `types.ts` without a natural home | 4 | 0 |
|
|
487
500
|
|
|
488
501
|
### Dependency graph
|
|
489
502
|
|
|
490
503
|
```text
|
|
491
504
|
A1 (Registry) ──────────────────┐
|
|
492
|
-
A2 (Settings)
|
|
505
|
+
A2 (Settings) ── A2b (Apply) ──┤
|
|
493
506
|
A3 (Activity Tracker) ───────────┤
|
|
494
507
|
├── D2 (Narrow deps) ── E1 (agent-tool split)
|
|
495
508
|
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.
|
|
@@ -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.
|
package/package.json
CHANGED
package/src/agent-manager.ts
CHANGED
|
@@ -32,7 +32,8 @@ export interface AgentManagerOptions {
|
|
|
32
32
|
worktrees: WorktreeManager;
|
|
33
33
|
exec: ShellExec;
|
|
34
34
|
registry: AgentTypeRegistry;
|
|
35
|
-
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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.
|
|
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.
|
|
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,
|
|
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 {
|
|
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,13 @@ 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
|
+
const settings = new SettingsManager({
|
|
60
|
+
emit: (event, payload) => pi.events.emit(event, payload),
|
|
61
|
+
cwd: process.cwd(),
|
|
62
|
+
});
|
|
63
|
+
settings.load();
|
|
64
|
+
|
|
58
65
|
// Background completion: emit lifecycle event and delegate to notification system
|
|
59
66
|
const manager = new AgentManager({
|
|
60
67
|
runner: { run: runAgent, resume: resumeAgent },
|
|
@@ -105,7 +112,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
105
112
|
compactionCount: record.compactionCount,
|
|
106
113
|
});
|
|
107
114
|
},
|
|
108
|
-
|
|
115
|
+
getMaxConcurrent: () => settings.maxConcurrent,
|
|
116
|
+
getRunConfig: () => settings,
|
|
109
117
|
});
|
|
110
118
|
|
|
111
119
|
// Typed service published via Symbol.for() for cross-extension access.
|
|
@@ -164,18 +172,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
164
172
|
|
|
165
173
|
const typeListText = buildTypeListText();
|
|
166
174
|
|
|
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
175
|
// ---- Agent tool ----
|
|
180
176
|
|
|
181
177
|
pi.registerTool(defineTool(createAgentTool({
|
|
@@ -184,7 +180,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
184
180
|
spawnAndWait: (ctx, type, prompt, opts) => manager.spawnAndWait(ctx, type, prompt, opts),
|
|
185
181
|
resume: (id, prompt, signal) => manager.resume(id, prompt, signal),
|
|
186
182
|
getRecord: (id) => manager.getRecord(id),
|
|
187
|
-
getMaxConcurrent: () =>
|
|
183
|
+
getMaxConcurrent: () => settings.maxConcurrent,
|
|
188
184
|
listAgents: () => manager.listAgents(),
|
|
189
185
|
},
|
|
190
186
|
widget: {
|
|
@@ -199,7 +195,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
199
195
|
typeListText,
|
|
200
196
|
availableTypesText: registry.getAvailableTypes().join(", "),
|
|
201
197
|
agentDir: getAgentDir(),
|
|
202
|
-
|
|
198
|
+
settings,
|
|
203
199
|
})));
|
|
204
200
|
|
|
205
201
|
// ---- get_subagent_result tool ----
|
|
@@ -226,8 +222,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
226
222
|
listAgents: () => manager.listAgents(),
|
|
227
223
|
getRecord: (id) => manager.getRecord(id),
|
|
228
224
|
spawnAndWait: (ctx, type, prompt, opts) => manager.spawnAndWait(ctx, type, prompt, opts),
|
|
229
|
-
|
|
230
|
-
setMaxConcurrent: (n) => manager.setMaxConcurrent(n),
|
|
225
|
+
notifyConcurrencyChanged: () => manager.notifyConcurrencyChanged(),
|
|
231
226
|
},
|
|
232
227
|
registry,
|
|
233
228
|
agentActivity: runtime.agentActivity,
|
|
@@ -240,24 +235,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
240
235
|
}
|
|
241
236
|
return getModelLabelFromConfig(cfg.model);
|
|
242
237
|
},
|
|
243
|
-
|
|
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
|
-
),
|
|
238
|
+
settings,
|
|
261
239
|
emitEvent: (name, data) => pi.events.emit(name, data),
|
|
262
240
|
personalAgentsDir: join(getAgentDir(), 'agents'),
|
|
263
241
|
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;
|
package/src/settings.ts
CHANGED
|
@@ -16,16 +16,104 @@ export interface SubagentsSettings {
|
|
|
16
16
|
graceTurns?: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
|
|
20
|
-
export interface SettingsAppliers {
|
|
21
|
-
setMaxConcurrent: (n: number) => void;
|
|
22
|
-
setDefaultMaxTurns: (n: number) => void;
|
|
23
|
-
setGraceTurns: (n: number) => void;
|
|
24
|
-
}
|
|
25
19
|
|
|
26
20
|
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
|
|
27
21
|
export type SettingsEmit = (event: string, payload: unknown) => void;
|
|
28
22
|
|
|
23
|
+
const DEFAULT_MAX_CONCURRENT = 4;
|
|
24
|
+
const DEFAULT_GRACE_TURNS = 5;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Owns all three in-memory settings values and their load/save/persist cycle.
|
|
28
|
+
* Replaces the scattered free-function + SettingsAppliers callback pattern.
|
|
29
|
+
*/
|
|
30
|
+
export class SettingsManager {
|
|
31
|
+
private _defaultMaxTurns: number | undefined = undefined;
|
|
32
|
+
private _graceTurns: number = DEFAULT_GRACE_TURNS;
|
|
33
|
+
private _maxConcurrent: number = DEFAULT_MAX_CONCURRENT;
|
|
34
|
+
|
|
35
|
+
private readonly emit: SettingsEmit;
|
|
36
|
+
private readonly cwd: string;
|
|
37
|
+
|
|
38
|
+
constructor(deps: { emit: SettingsEmit; cwd: string }) {
|
|
39
|
+
this.emit = deps.emit;
|
|
40
|
+
this.cwd = deps.cwd;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── defaultMaxTurns: 0 or undefined → unlimited (undefined); else max(1, n) ──
|
|
44
|
+
|
|
45
|
+
get defaultMaxTurns(): number | undefined {
|
|
46
|
+
return this._defaultMaxTurns;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
set defaultMaxTurns(n: number | undefined) {
|
|
50
|
+
if (n == null || n === 0) {
|
|
51
|
+
this._defaultMaxTurns = undefined;
|
|
52
|
+
} else {
|
|
53
|
+
this._defaultMaxTurns = Math.max(1, n);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── graceTurns: minimum 1 ──
|
|
58
|
+
|
|
59
|
+
get graceTurns(): number {
|
|
60
|
+
return this._graceTurns;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
set graceTurns(n: number) {
|
|
64
|
+
this._graceTurns = Math.max(1, n);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── maxConcurrent: minimum 1 ──
|
|
68
|
+
|
|
69
|
+
get maxConcurrent(): number {
|
|
70
|
+
return this._maxConcurrent;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
set maxConcurrent(n: number) {
|
|
74
|
+
this._maxConcurrent = Math.max(1, n);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Lifecycle methods ──
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Load merged settings (global + project), apply to in-memory values,
|
|
81
|
+
* and emit the `subagents:settings_loaded` lifecycle event.
|
|
82
|
+
* Returns the raw loaded settings object.
|
|
83
|
+
*/
|
|
84
|
+
load(): SubagentsSettings {
|
|
85
|
+
const settings = loadSettings(this.cwd);
|
|
86
|
+
if (typeof settings.maxConcurrent === "number") this.maxConcurrent = settings.maxConcurrent;
|
|
87
|
+
if (typeof settings.defaultMaxTurns === "number") this.defaultMaxTurns = settings.defaultMaxTurns;
|
|
88
|
+
if (typeof settings.graceTurns === "number") this.graceTurns = settings.graceTurns;
|
|
89
|
+
this.emit("subagents:settings_loaded", { settings });
|
|
90
|
+
return settings;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Snapshot current in-memory values for persistence.
|
|
95
|
+
* `defaultMaxTurns` uses 0 as the on-disk marker for unlimited (undefined).
|
|
96
|
+
*/
|
|
97
|
+
snapshot(): { maxConcurrent: number; defaultMaxTurns: number; graceTurns: number } {
|
|
98
|
+
return {
|
|
99
|
+
maxConcurrent: this._maxConcurrent,
|
|
100
|
+
defaultMaxTurns: this._defaultMaxTurns ?? 0,
|
|
101
|
+
graceTurns: this._graceTurns,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Persist the current snapshot, emit `subagents:settings_changed`,
|
|
107
|
+
* and return the toast the UI should display.
|
|
108
|
+
*/
|
|
109
|
+
saveAndNotify(successMsg: string): { message: string; level: "info" | "warning" } {
|
|
110
|
+
const snap = this.snapshot();
|
|
111
|
+
const persisted = saveSettings(snap, this.cwd);
|
|
112
|
+
this.emit("subagents:settings_changed", { settings: snap, persisted });
|
|
113
|
+
return persistToastFor(successMsg, persisted);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
29
117
|
// Sanity ceilings — prevent hand-edited configs from asking for values that
|
|
30
118
|
// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
|
|
31
119
|
// that any realistic power-user setting passes through.
|
|
@@ -107,13 +195,6 @@ export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()):
|
|
|
107
195
|
}
|
|
108
196
|
}
|
|
109
197
|
|
|
110
|
-
/** Apply persisted settings to the in-memory state via caller-supplied setters. */
|
|
111
|
-
export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers): void {
|
|
112
|
-
if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
|
|
113
|
-
if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
|
|
114
|
-
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
198
|
/**
|
|
118
199
|
* Format the user-facing toast for a settings mutation. Pure function —
|
|
119
200
|
* routes the success/failure of `saveSettings` into the right message + level
|
|
@@ -127,36 +208,3 @@ export function persistToastFor(
|
|
|
127
208
|
? { message: successMsg, level: "info" }
|
|
128
209
|
: { message: `${successMsg} (session only; failed to persist)`, level: "warning" };
|
|
129
210
|
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Load merged settings, apply them to in-memory state, and emit the
|
|
133
|
-
* `subagents:settings_loaded` lifecycle event. Returns the loaded settings so
|
|
134
|
-
* callers can log/inspect. Extension init wires this once.
|
|
135
|
-
*/
|
|
136
|
-
export function applyAndEmitLoaded(
|
|
137
|
-
appliers: SettingsAppliers,
|
|
138
|
-
emit: SettingsEmit,
|
|
139
|
-
cwd: string = process.cwd(),
|
|
140
|
-
): SubagentsSettings {
|
|
141
|
-
const settings = loadSettings(cwd);
|
|
142
|
-
applySettings(settings, appliers);
|
|
143
|
-
emit("subagents:settings_loaded", { settings });
|
|
144
|
-
return settings;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Persist a settings snapshot, emit the `subagents:settings_changed` event
|
|
149
|
-
* (regardless of persist outcome so listeners see the in-memory change), and
|
|
150
|
-
* return the toast the UI should display. Event payload carries the `persisted`
|
|
151
|
-
* flag so listeners can react to write failures.
|
|
152
|
-
*/
|
|
153
|
-
export function saveAndEmitChanged(
|
|
154
|
-
snapshot: SubagentsSettings,
|
|
155
|
-
successMsg: string,
|
|
156
|
-
emit: SettingsEmit,
|
|
157
|
-
cwd: string = process.cwd(),
|
|
158
|
-
): { message: string; level: "info" | "warning" } {
|
|
159
|
-
const persisted = saveSettings(snapshot, cwd);
|
|
160
|
-
emit("subagents:settings_changed", { settings: snapshot, persisted });
|
|
161
|
-
return persistToastFor(successMsg, persisted);
|
|
162
|
-
}
|
package/src/tools/agent-tool.ts
CHANGED
|
@@ -112,8 +112,8 @@ export interface AgentToolDeps {
|
|
|
112
112
|
typeListText: string;
|
|
113
113
|
availableTypesText: string;
|
|
114
114
|
agentDir: string;
|
|
115
|
-
/**
|
|
116
|
-
|
|
115
|
+
/** Narrow settings accessor — only the default max turns is needed here. */
|
|
116
|
+
settings: { readonly defaultMaxTurns: number | undefined };
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
// ---- Factory ----
|
|
@@ -364,7 +364,7 @@ Guidelines:
|
|
|
364
364
|
? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
|
|
365
365
|
: undefined;
|
|
366
366
|
const effectiveMaxTurns = normalizeMaxTurns(
|
|
367
|
-
resolvedConfig.maxTurns ?? deps.
|
|
367
|
+
resolvedConfig.maxTurns ?? deps.settings.defaultMaxTurns,
|
|
368
368
|
);
|
|
369
369
|
const agentInvocation: AgentInvocation = {
|
|
370
370
|
modelName,
|
package/src/ui/agent-menu.ts
CHANGED
|
@@ -20,8 +20,16 @@ export interface AgentMenuManager {
|
|
|
20
20
|
getRecord: (id: string) => AgentRecord | undefined;
|
|
21
21
|
/** Used by generate wizard to spawn an agent that writes the .md file. */
|
|
22
22
|
spawnAndWait: (ctx: ExtensionContext, type: string, prompt: string, opts: Omit<SpawnOptions, "isBackground">) => Promise<AgentRecord>;
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/** Drain the concurrency queue after maxConcurrent has been updated on SettingsManager. */
|
|
24
|
+
notifyConcurrencyChanged: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Narrow settings interface required by the agent menu. */
|
|
28
|
+
export interface AgentMenuSettings {
|
|
29
|
+
maxConcurrent: number;
|
|
30
|
+
defaultMaxTurns: number | undefined;
|
|
31
|
+
graceTurns: number;
|
|
32
|
+
saveAndNotify(msg: string): { message: string; level: "info" | "warning" };
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
export interface AgentMenuDeps {
|
|
@@ -30,24 +38,11 @@ export interface AgentMenuDeps {
|
|
|
30
38
|
agentActivity: Map<string, AgentActivity>;
|
|
31
39
|
/** Resolve model label for a given agent type + registry. */
|
|
32
40
|
getModelLabel: (type: string, registry?: ModelRegistry) => string;
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
/** Save settings and return a notification result. */
|
|
36
|
-
saveSettings: (
|
|
37
|
-
settings: { maxConcurrent: number; defaultMaxTurns: number; graceTurns: number },
|
|
38
|
-
successMsg: string,
|
|
39
|
-
) => { message: string; level: string };
|
|
41
|
+
/** Settings manager — owns in-memory values and persistence. */
|
|
42
|
+
settings: AgentMenuSettings;
|
|
40
43
|
emitEvent: (name: string, data: unknown) => void;
|
|
41
44
|
personalAgentsDir: string;
|
|
42
45
|
projectAgentsDir: string;
|
|
43
|
-
/** Returns the runtime default max turns (undefined = unlimited). */
|
|
44
|
-
getDefaultMaxTurns: () => number | undefined;
|
|
45
|
-
/** Returns the runtime grace turns value. */
|
|
46
|
-
getGraceTurns: () => number;
|
|
47
|
-
/** Updates the runtime default max turns (undefined = unlimited). */
|
|
48
|
-
setDefaultMaxTurns: (n: number | undefined) => void;
|
|
49
|
-
/** Updates the runtime grace turns value (minimum 1). */
|
|
50
|
-
setGraceTurns: (n: number) => void;
|
|
51
46
|
}
|
|
52
47
|
|
|
53
48
|
// ---- Narrow UI context types ----
|
|
@@ -608,21 +603,22 @@ ${systemPrompt}
|
|
|
608
603
|
|
|
609
604
|
async function showSettings(ctx: ExtensionContext) {
|
|
610
605
|
const choice = await ctx.ui.select("Settings", [
|
|
611
|
-
`Max concurrency (current: ${deps.
|
|
612
|
-
`Default max turns (current: ${deps.
|
|
613
|
-
`Grace turns (current: ${deps.
|
|
606
|
+
`Max concurrency (current: ${deps.settings.maxConcurrent})`,
|
|
607
|
+
`Default max turns (current: ${deps.settings.defaultMaxTurns ?? "unlimited"})`,
|
|
608
|
+
`Grace turns (current: ${deps.settings.graceTurns})`,
|
|
614
609
|
]);
|
|
615
610
|
if (!choice) return;
|
|
616
611
|
|
|
617
612
|
if (choice.startsWith("Max concurrency")) {
|
|
618
613
|
const val = await ctx.ui.input(
|
|
619
614
|
"Max concurrent background agents",
|
|
620
|
-
String(deps.
|
|
615
|
+
String(deps.settings.maxConcurrent),
|
|
621
616
|
);
|
|
622
617
|
if (val) {
|
|
623
618
|
const n = parseInt(val, 10);
|
|
624
619
|
if (n >= 1) {
|
|
625
|
-
deps.
|
|
620
|
+
deps.settings.maxConcurrent = n;
|
|
621
|
+
deps.manager.notifyConcurrencyChanged();
|
|
626
622
|
notifyApplied(ctx, `Max concurrency set to ${n}`);
|
|
627
623
|
} else {
|
|
628
624
|
ctx.ui.notify("Must be a positive integer.", "warning");
|
|
@@ -631,15 +627,15 @@ ${systemPrompt}
|
|
|
631
627
|
} else if (choice.startsWith("Default max turns")) {
|
|
632
628
|
const val = await ctx.ui.input(
|
|
633
629
|
"Default max turns before wrap-up (0 = unlimited)",
|
|
634
|
-
String(deps.
|
|
630
|
+
String(deps.settings.defaultMaxTurns ?? 0),
|
|
635
631
|
);
|
|
636
632
|
if (val) {
|
|
637
633
|
const n = parseInt(val, 10);
|
|
638
634
|
if (n === 0) {
|
|
639
|
-
deps.
|
|
635
|
+
deps.settings.defaultMaxTurns = undefined;
|
|
640
636
|
notifyApplied(ctx, "Default max turns set to unlimited");
|
|
641
637
|
} else if (n >= 1) {
|
|
642
|
-
deps.
|
|
638
|
+
deps.settings.defaultMaxTurns = n;
|
|
643
639
|
notifyApplied(ctx, `Default max turns set to ${n}`);
|
|
644
640
|
} else {
|
|
645
641
|
ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning");
|
|
@@ -648,12 +644,12 @@ ${systemPrompt}
|
|
|
648
644
|
} else if (choice.startsWith("Grace turns")) {
|
|
649
645
|
const val = await ctx.ui.input(
|
|
650
646
|
"Grace turns after wrap-up steer",
|
|
651
|
-
String(deps.
|
|
647
|
+
String(deps.settings.graceTurns),
|
|
652
648
|
);
|
|
653
649
|
if (val) {
|
|
654
650
|
const n = parseInt(val, 10);
|
|
655
651
|
if (n >= 1) {
|
|
656
|
-
deps.
|
|
652
|
+
deps.settings.graceTurns = n;
|
|
657
653
|
notifyApplied(ctx, `Grace turns set to ${n}`);
|
|
658
654
|
} else {
|
|
659
655
|
ctx.ui.notify("Must be a positive integer.", "warning");
|
|
@@ -663,7 +659,7 @@ ${systemPrompt}
|
|
|
663
659
|
}
|
|
664
660
|
|
|
665
661
|
function notifyApplied(ctx: ExtensionContext, successMsg: string) {
|
|
666
|
-
const { message, level } = deps.
|
|
662
|
+
const { message, level } = deps.settings.saveAndNotify(successMsg);
|
|
667
663
|
ctx.ui.notify(message, level as "info" | "warning" | "error");
|
|
668
664
|
}
|
|
669
665
|
|