@gotgenes/pi-subagents 6.14.1 → 6.15.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,20 @@ 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.15.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.14.1...pi-subagents-v6.15.0) (2026-05-23)
9
+
10
+
11
+ ### Features
12
+
13
+ * extract resolveSpawnConfig pure function ([#145](https://github.com/gotgenes/pi-packages/issues/145)) ([e89724a](https://github.com/gotgenes/pi-packages/commit/e89724a87480713529160d0fa23975becbcfe162))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * plan decompose execute and push ctx to boundary ([#145](https://github.com/gotgenes/pi-packages/issues/145)) ([aae7d7b](https://github.com/gotgenes/pi-packages/commit/aae7d7b4e04ab0dddedd2a0f9f2b806719956ced))
19
+ * update architecture doc for completed Step M ([#145](https://github.com/gotgenes/pi-packages/issues/145)) ([33ec0c7](https://github.com/gotgenes/pi-packages/commit/33ec0c73479076c180381dcc1cb4106ba635f33f))
20
+ * update plan with injected collaborators for ctx elimination ([#145](https://github.com/gotgenes/pi-packages/issues/145)) ([76bb57b](https://github.com/gotgenes/pi-packages/commit/76bb57b4b5190078ded8685907f0878640031e13))
21
+
8
22
  ## [6.14.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v6.14.0...pi-subagents-v6.14.1) (2026-05-23)
9
23
 
10
24
 
@@ -647,38 +647,24 @@ Apply the dependency bag convention to touched modules: `NotificationDeps` (4 fi
647
647
 
648
648
  Impact: eliminates dual counting; removes `??` fallback pattern from widget and conversation viewer; hides `ExecutionState` structure from consumers.
649
649
 
650
- ### Step M: Decompose execute and push ExtensionContext to the boundary (#145)
650
+ ### Step M: Decompose execute and push ExtensionContext to the boundary (#145)
651
651
 
652
- `execute` is 145 lines with three responsibilities mixed together:
652
+ Extracted config resolution into `resolveSpawnConfig` (pure function in `spawn-config.ts`).
653
+ Injected three collaborators (`buildSnapshot`, `getModelInfo`, `getSessionInfo`) into `createAgentTool` so `execute` no longer reads `ctx` beyond `ctx.ui` (already delegated to `widget.setUICtx`).
654
+ `AgentManager.spawn()` and `spawnAndWait()` accept `ParentSnapshot` instead of `ExtensionContext`.
655
+ `service-adapter.ts` calls `buildParentSnapshot(session.ctx)` at its boundary.
656
+ `foreground-runner` and `background-spawner` receive `ResolvedSpawnConfig` + domain values (`snapshot`, `parentSessionFile`, `parentSessionId`) instead of `ctx`.
653
657
 
654
- 1. **Boundary extraction** (~5 lines) - read `ctx.model`, `ctx.modelRegistry`, `ctx.ui`, `ctx.sessionManager`, call `buildParentSnapshot(ctx)`.
655
- 2. **Config resolution** (~60 lines) - resolve agent type, merge invocation config, resolve model, compute max turns, build tags and display metadata.
656
- 3. **Dispatch** (~80 lines) - resume / background / foreground, each passing 14-16 field parameter bags.
657
-
658
- The config resolution section is working for the dependencies: manually unpacking `resolvedConfig` field by field, computing derived values, then repacking everything into massive objects for `spawnBackground` and `runForeground`.
659
- The 16-field bags are the symptom - they exist because the resolution happened in the wrong place.
660
-
661
- The fix has two parts:
662
-
663
- 1. **Extract config resolution** into a pure function (e.g. `resolveSpawnConfig`) that accepts the raw tool params, registry, model info, and settings, and returns a single `ResolvedSpawnConfig` object.
664
- `execute` becomes: extract ctx → resolve config → dispatch.
665
- `spawnBackground` and `runForeground` receive `ResolvedSpawnConfig` instead of 16 individual fields.
666
- 2. **Push `ctx` to the boundary.**
667
- `execute` extracts everything from `ctx` in its first few lines.
668
- `foreground-runner.ts` and `background-spawner.ts` receive domain values (`snapshot`, `parentSessionFile`, `parentSessionId`) instead of `ctx`.
669
- `AgentManager.spawn()` and `spawnAndWait()` accept `ParentSnapshot` instead of `ExtensionContext`.
670
- `service-adapter.ts` calls `buildParentSnapshot(session.ctx)` at its boundary.
658
+ Dissolved `ForegroundDeps`, `BackgroundDeps`, and `AdapterDeps` into plain parameters.
659
+ `AgentToolDeps` is destructured in the `createAgentTool` signature.
671
660
 
672
661
  After this step, `ExtensionContext` appears only in:
673
662
 
674
- - `agent-tool.ts execute` (SDK callback - unavoidable)
663
+ - `index.ts` closures (wired at extension startup)
675
664
  - `service-adapter.ts` (cross-extension boundary)
676
- - `index.ts` (extension entry point)
677
665
  - Menu handlers (addressed by Step N)
678
666
 
679
- Apply the dependency bag convention to touched modules: `ForegroundDeps` (3 fields) and `BackgroundDeps` (3 fields) become plain parameters; `AdapterDeps` (3 fields) becomes plain parameters; `AgentToolDeps` (6 fields) is destructured in the signature.
680
-
681
- Impact: `execute` drops from ~145 to ~30 lines; eliminates 16-field parameter bags; eliminates 1 `vi.mock()` call in `agent-manager.test.ts`; `foreground-runner` and `background-spawner` tests no longer need `ctx` mocks; `AgentManager` operates entirely on domain types.
667
+ Impact: `execute` dropped from ~145 to ~25 lines; eliminated 16-field parameter bags; eliminated `vi.mock("../src/parent-snapshot.js")` in `agent-manager.test.ts`; foreground/background runner tests no longer need `ctx` mocks; `AgentManager` operates entirely on domain types.
682
668
 
683
669
  ### Step N: Narrow UI context for menu handlers (#146)
684
670
 
@@ -690,7 +676,7 @@ Creation wizard’s `spawnAndWait` call changes: the narrow `AgentMenuManager.sp
690
676
 
691
677
  Apply the dependency bag convention to touched modules: `AgentConfigEditorDeps` (4 fields), `SteerToolDeps` (4 fields), and `GetResultDeps` (4 fields) become plain parameters; `AgentMenuDeps` (8 fields) and `AgentCreationWizardDeps` (5 fields) are destructured in the signature.
692
678
 
693
- After Steps M and N, `ExtensionContext` appears only at true boundaries: `agent-tool.ts execute` (SDK callback), `service-adapter.ts` (cross-extension bridge), and `index.ts` (extension entry point).
679
+ After Steps M and N, `ExtensionContext` appears only at true boundaries: `index.ts` closures, `service-adapter.ts` (cross-extension bridge), and `index.ts` (extension entry point).
694
680
 
695
681
  Impact: eliminates ~43 `ctx as any` casts across menu, editor, and wizard test files; tests construct a plain object satisfying `MenuUI` with no cast.
696
682
 
@@ -0,0 +1,290 @@
1
+ ---
2
+ issue: 145
3
+ issue_title: "Decompose execute and push ExtensionContext to the boundary (Phase 9, Step M)"
4
+ ---
5
+
6
+ # Decompose execute and push ctx to the boundary
7
+
8
+ ## Problem Statement
9
+
10
+ `agent-tool.ts` `execute` is ~140 lines mixing three concerns: boundary extraction (~5 lines reading `ctx`), config resolution (~60 lines unpacking `resolvedConfig` field by field), and dispatch (~80 lines building 14–16 field parameter bags for `spawnBackground` and `runForeground`).
11
+ The large parameter bags exist because config resolution happens inline instead of in a dedicated function.
12
+ Meanwhile, `ExtensionContext` is threaded from `execute` through `ForegroundParams.ctx` / `BackgroundParams.ctx` into `foreground-runner` and `background-spawner`, where the only thing consumed is `sessionManager.getSessionFile()` and `sessionManager.getSessionId()`.
13
+ `AgentManager.spawn()` and `spawnAndWait()` accept `ExtensionContext` directly and call `buildParentSnapshot(ctx)` internally — but this is already a pure boundary concern that belongs at the call site.
14
+ Additionally, `execute` reaches into `ctx` for model info and session identity — these are session-scoped values that `index.ts` already captures and could inject as collaborators, removing `execute`'s need to read `ctx` beyond the UI context it already delegates to `widget.setUICtx()`.
15
+
16
+ ## Goals
17
+
18
+ - Extract config resolution into a pure function (`resolveSpawnConfig`) so `execute` becomes: resolve config → dispatch.
19
+ - Inject three missing collaborators into `createAgentTool` so `execute` no longer extracts values from `ctx`:
20
+ - `buildSnapshot: (inheritContext: boolean) => ParentSnapshot` — closure over `ctx`, wired in `index.ts`.
21
+ - `getModelInfo: () => ModelInfo` — provides `parentModel` and `modelRegistry` for `resolveSpawnConfig`.
22
+ - `getSessionInfo: () => { parentSessionFile: string; parentSessionId: string }` — parent session identity.
23
+ - Replace `ForegroundParams.ctx` and `BackgroundParams.ctx` with plain domain values (`parentSessionFile`, `parentSessionId`, `snapshot`).
24
+ - Change `AgentManager.spawn()` and `spawnAndWait()` to accept `ParentSnapshot` instead of `ExtensionContext`.
25
+ - Move `buildParentSnapshot(ctx)` calls to the two boundaries: `index.ts` (via closure) and `service-adapter.ts`.
26
+ - Eliminate the `vi.mock("../src/parent-snapshot.js")` in `agent-manager.test.ts`.
27
+ - Apply the dependency bag convention: dissolve `ForegroundDeps`, `BackgroundDeps`, `AdapterDeps` (each ≤3 fields) into plain parameters.
28
+ - This is a breaking internal refactor — no public API changes.
29
+
30
+ ## Non-Goals
31
+
32
+ - Narrowing menu handler ctx (Step N, #146) — deferred.
33
+ - Injecting text wrapping into ConversationViewer (Step O, #147) — unrelated track.
34
+ - Observation model consolidation (Step L, #144) — independent track.
35
+ - Changing the `SubagentsService` public API in `service.ts`.
36
+
37
+ ## Background
38
+
39
+ ### Relevant modules
40
+
41
+ | Module | Current role |
42
+ | ----------------------------- | ------------------------------------------------------------------------------------------------ |
43
+ | `tools/agent-tool.ts` | `execute` callback — 140 lines, mixes boundary extraction, config resolution, dispatch |
44
+ | `tools/foreground-runner.ts` | `runForeground()` — receives 14-field `ForegroundParams` including `ctx` with `sessionManager` |
45
+ | `tools/background-spawner.ts` | `spawnBackground()` — receives 14-field `BackgroundParams` including `ctx` with `sessionManager` |
46
+ | `agent-manager.ts` | `spawn()` / `spawnAndWait()` accept `ExtensionContext`, call `buildParentSnapshot()` internally |
47
+ | `parent-snapshot.ts` | `buildParentSnapshot(ctx)` — pure function capturing `ParentSnapshot` from ctx |
48
+ | `service-adapter.ts` | Cross-extension boundary — calls `manager.spawn(session.ctx, ...)` |
49
+ | `invocation-config.ts` | `resolveAgentInvocationConfig()` — merges agent config with tool params |
50
+ | `model-resolver.ts` | `resolveInvocationModel()` — resolves model strings to model instances |
51
+ | `index.ts` | Extension entry point — wires `createAgentTool` deps, captures `runtime.currentCtx` |
52
+ | `runtime.ts` | `SubagentRuntime` — holds session-scoped mutable state including `currentCtx` |
53
+
54
+ ### Constraints from AGENTS.md
55
+
56
+ - Keep modules focused and composable (one concern per file).
57
+ - Prefer explicit configuration over hidden behavior.
58
+ - Keep Pi SDK imports out of business-logic modules.
59
+ - Business logic should be pure functions — keep IO at the edges.
60
+
61
+ ### Phase 9 context
62
+
63
+ This is Step M of Phase 9.
64
+ It has no blockers and blocks Step N (#146), which narrows menu handler ctx.
65
+ After this step, `ExtensionContext` appears only at true SDK/extension boundaries: `index.ts` closures, `service-adapter.ts`, and menu handlers.
66
+
67
+ ## Design Overview
68
+
69
+ ### Part 1: Extract config resolution (done)
70
+
71
+ A new pure function `resolveSpawnConfig` in `spawn-config.ts` encapsulates all config resolution logic previously inline in `execute`.
72
+ `execute` calls `resolveSpawnConfig(params, registry, modelInfo, settings)` and dispatches on the result.
73
+ This is already committed.
74
+
75
+ ### Part 2: Inject collaborators and push ctx out of execute
76
+
77
+ `execute` currently reads `ctx.model`, `ctx.modelRegistry`, `ctx.sessionManager`, and passes `ctx` to `buildParentSnapshot`.
78
+ These are all session-scoped values that `index.ts` captures at session start.
79
+ Three collaborators replace the `ctx` reads:
80
+
81
+ ```typescript
82
+ // Injected as plain parameters into createAgentTool:
83
+ buildSnapshot: (inheritContext: boolean) => ParentSnapshot,
84
+ getModelInfo: () => ModelInfo,
85
+ getSessionInfo: () => { parentSessionFile: string; parentSessionId: string },
86
+ ```
87
+
88
+ `index.ts` wires them as closures over `runtime.currentCtx`:
89
+
90
+ ```typescript
91
+ createAgentTool({
92
+ // ... existing params ...
93
+ buildSnapshot: (inheritContext) => buildParentSnapshot(ctx, inheritContext),
94
+ getModelInfo: () => ({
95
+ parentModel: ctx.model,
96
+ modelRegistry: ctx.modelRegistry,
97
+ }),
98
+ getSessionInfo: () => ({
99
+ parentSessionFile: ctx.sessionManager.getSessionFile(),
100
+ parentSessionId: ctx.sessionManager.getSessionId(),
101
+ }),
102
+ })
103
+ ```
104
+
105
+ After this, `execute` touches `ctx` only for `ctx.ui` — which is already delegated via `widget.setUICtx()`.
106
+ The `ExtensionContext` import in `agent-tool.ts` is removed entirely.
107
+
108
+ ### Part 3: Push ctx out of AgentManager
109
+
110
+ `AgentManager.spawn()` and `spawnAndWait()` accept `ParentSnapshot` instead of `ExtensionContext`.
111
+ The internal `buildParentSnapshot(ctx, ...)` call is removed — `snapshot` arrives pre-built from the call sites.
112
+ `service-adapter.ts` calls `buildParentSnapshot(session.ctx, ...)` at its boundary before delegating.
113
+
114
+ ### Part 4: Push ctx out of foreground-runner and background-spawner
115
+
116
+ `ForegroundParams.ctx` and `BackgroundParams.ctx` are replaced by `snapshot: ParentSnapshot`, `parentSessionFile: string`, `parentSessionId: string`.
117
+ The narrow manager interfaces change from `ctx: any` to `snapshot: ParentSnapshot`.
118
+
119
+ ### Part 5: Shrink params bags with ResolvedSpawnConfig
120
+
121
+ `ForegroundParams` and `BackgroundParams` carry `ResolvedSpawnConfig` instead of 10+ individual fields that were computed during config resolution.
122
+ Only dispatch-specific fields (`rawType`, `fellBack`, `toolCallId`, `displayName`) remain as separate params fields.
123
+
124
+ ### Part 6: Dissolve small dependency bags
125
+
126
+ Per the dependency bag convention:
127
+
128
+ - `ForegroundDeps` (3 fields) → plain parameters on `runForeground`.
129
+ - `BackgroundDeps` (3 fields) → plain parameters on `spawnBackground`.
130
+ - `AdapterDeps` (4 fields) → plain parameters on `createSubagentsService`.
131
+ - `AgentToolDeps` → destructured in the `createAgentTool` signature; the interface stays as a named type for the test factory.
132
+
133
+ The narrow `*ManagerDeps` and `*WidgetDeps` interfaces stay — they define the contract each function needs from its collaborators.
134
+
135
+ ## Module-Level Changes
136
+
137
+ ### New file: `src/tools/spawn-config.ts` (done)
138
+
139
+ - `ResolvedSpawnConfig` interface.
140
+ - `ModelInfo` interface.
141
+ - `resolveSpawnConfig()` pure function.
142
+
143
+ ### Modified: `src/tools/agent-tool.ts`
144
+
145
+ - `execute` shrinks from ~140 to ~20 lines.
146
+ - `ExtensionContext` import removed — `execute` no longer reads `ctx` directly (beyond `ctx.ui` via widget).
147
+ - Three new collaborator parameters: `buildSnapshot`, `getModelInfo`, `getSessionInfo`.
148
+ - Calls `resolveSpawnConfig(params, registry, getModelInfo(), settings)`.
149
+ - Calls `buildSnapshot(config.inheritContext)` for the snapshot.
150
+ - Calls `getSessionInfo()` for parent session identity.
151
+ - Passes domain values (not `ctx`) to `runForeground` / `spawnBackground`.
152
+ - `AgentToolManager.spawn` and `spawnAndWait` signatures change to accept `ParentSnapshot`.
153
+ - `AgentToolDeps` stays as a named type (used by test factory) but its fields are destructured in `createAgentTool`.
154
+
155
+ ### Modified: `src/tools/foreground-runner.ts`
156
+
157
+ - `ForegroundDeps` interface removed — `runForeground` accepts `manager`, `widget`, `agentActivity` as plain parameters.
158
+ - `ForegroundParams.ctx` removed — replaced by `snapshot`, `parentSessionFile`, `parentSessionId`.
159
+ - `ForegroundManagerDeps.spawnAndWait` signature changes from `ctx: any` to `snapshot: ParentSnapshot`.
160
+ - Individual config fields move into `ResolvedSpawnConfig`.
161
+
162
+ ### Modified: `src/tools/background-spawner.ts`
163
+
164
+ - `BackgroundDeps` interface removed — `spawnBackground` accepts `manager`, `widget`, `agentActivity` as plain parameters.
165
+ - `BackgroundParams.ctx` removed — replaced by `snapshot`, `parentSessionFile`, `parentSessionId`.
166
+ - `BackgroundManagerDeps.spawn` signature changes from `ctx: any` to `snapshot: ParentSnapshot`.
167
+ - Individual config fields move into `ResolvedSpawnConfig`.
168
+
169
+ ### Modified: `src/agent-manager.ts`
170
+
171
+ - `spawn()` signature changes from `ctx: ExtensionContext` to `snapshot: ParentSnapshot`.
172
+ - `spawnAndWait()` signature changes from `ctx: ExtensionContext` to `snapshot: ParentSnapshot`.
173
+ - Internal `buildParentSnapshot(ctx, ...)` call removed.
174
+ - Imports of `ExtensionContext` and `buildParentSnapshot` removed.
175
+
176
+ ### Modified: `src/service-adapter.ts`
177
+
178
+ - `AdapterDeps` interface removed — `createSubagentsService` accepts plain parameters.
179
+ - `AgentManagerLike.spawn` signature changes from `ctx: unknown` to `snapshot: ParentSnapshot`.
180
+ - `spawn()` method calls `buildParentSnapshot(session.ctx, options?.inheritContext)` before delegating.
181
+ - Adds imports of `buildParentSnapshot` and `ParentSnapshot`.
182
+
183
+ ### Modified: `src/index.ts`
184
+
185
+ - Wiring for `createAgentTool` adds three collaborator closures: `buildSnapshot`, `getModelInfo`, `getSessionInfo`.
186
+ - `manager.spawn` / `spawnAndWait` wiring adapters removed (closures no longer need to relay `ctx`).
187
+ - Wiring for `createSubagentsService` changes from bag to plain arguments.
188
+
189
+ ## Test Impact Analysis
190
+
191
+ ### New unit tests enabled
192
+
193
+ - `spawn-config.test.ts` (done) — pure-function tests for `resolveSpawnConfig`.
194
+
195
+ ### Existing tests that simplify
196
+
197
+ - `agent-manager.test.ts` — the `vi.mock("../src/parent-snapshot.js")` block is removed.
198
+ All tests pass a plain `ParentSnapshot` object directly instead of `mockCtx`.
199
+ - `foreground-runner.test.ts` — `makeCtx()` helper removed; plain strings for session identity.
200
+ - `background-spawner.test.ts` — same as foreground.
201
+ - `agent-tool.test.ts` — `makeCtx()` simplified; collaborator stubs replace `ctx.model` / `ctx.modelRegistry` reads.
202
+ - `service-adapter.test.ts` — adapter test setup changes from bag to plain parameters.
203
+
204
+ ### Existing tests that stay
205
+
206
+ - `parent-snapshot.test.ts` — unchanged; `buildParentSnapshot` is still a standalone pure function.
207
+
208
+ ## TDD Order
209
+
210
+ ### Step 1: Extract resolveSpawnConfig (done)
211
+
212
+ 1. ~~Write `spawn-config.test.ts`, implement `spawn-config.ts`.~~
213
+ Commit: `feat: extract resolveSpawnConfig pure function (#145)` ✓
214
+
215
+ 2. ~~Rewire `execute` to call `resolveSpawnConfig`.~~
216
+ Commit: `refactor: use resolveSpawnConfig in execute (#145)` ✓
217
+
218
+ ### Step 2: Push ctx out of AgentManager
219
+
220
+ 3. Red: update `agent-manager.test.ts` — replace `mockCtx` with a plain `ParentSnapshot` object, remove `vi.mock("../src/parent-snapshot.js")`.
221
+ Green: change `AgentManager.spawn()` and `spawnAndWait()` to accept `ParentSnapshot`.
222
+ Update `agent-tool.ts` manager interface, `service-adapter.ts` to call `buildParentSnapshot` at its boundary, and `index.ts` wiring.
223
+ Commit: `refactor: AgentManager accepts ParentSnapshot instead of ExtensionContext (#145)`
224
+
225
+ ### Step 3: Inject collaborators into createAgentTool
226
+
227
+ 4. Red: update `agent-tool.test.ts` — add `buildSnapshot`, `getModelInfo`, `getSessionInfo` stubs to `createToolDeps`; simplify `makeCtx()`.
228
+ Green: add three collaborator parameters to `createAgentTool`; rewrite `execute` to use them instead of `ctx.model` / `ctx.modelRegistry` / `ctx.sessionManager`.
229
+ Remove `ExtensionContext` import from `agent-tool.ts`.
230
+ Update `index.ts` wiring to provide closures.
231
+ Commit: `refactor: inject collaborators into createAgentTool, eliminate ctx reads (#145)`
232
+
233
+ ### Step 4: Push ctx out of foreground-runner and background-spawner
234
+
235
+ 5. Red: update `foreground-runner.test.ts` — remove `makeCtx()`, replace `ForegroundParams.ctx` with `snapshot` / `parentSessionFile` / `parentSessionId`.
236
+ Green: change `ForegroundParams` to use plain domain values, update `runForeground` accordingly.
237
+ Commit: `refactor: foreground-runner receives domain values instead of ctx (#145)`
238
+
239
+ 6. Red: update `background-spawner.test.ts` — remove `makeCtx()`, replace `BackgroundParams.ctx` with `snapshot` / `parentSessionFile` / `parentSessionId`.
240
+ Green: change `BackgroundParams` to use plain domain values, update `spawnBackground` accordingly.
241
+ Commit: `refactor: background-spawner receives domain values instead of ctx (#145)`
242
+
243
+ ### Step 5: Shrink params bags with ResolvedSpawnConfig
244
+
245
+ 7. Red: update `foreground-runner.test.ts` `makeParams()` to use `ResolvedSpawnConfig` fields.
246
+ Green: change `ForegroundParams` to carry `ResolvedSpawnConfig`.
247
+ Update `agent-tool.ts` dispatch to pass the config through.
248
+ Commit: `refactor: ForegroundParams carries ResolvedSpawnConfig (#145)`
249
+
250
+ 8. Red: update `background-spawner.test.ts` `makeParams()` to use `ResolvedSpawnConfig` fields.
251
+ Green: change `BackgroundParams` to carry `ResolvedSpawnConfig`.
252
+ Update `agent-tool.ts` dispatch to pass the config through.
253
+ Commit: `refactor: BackgroundParams carries ResolvedSpawnConfig (#145)`
254
+
255
+ ### Step 6: Dissolve small dependency bags
256
+
257
+ 9. Red: update `foreground-runner.test.ts` calls to pass `manager`, `widget`, `agentActivity` as plain args.
258
+ Green: remove `ForegroundDeps` interface, change `runForeground` signature.
259
+ Commit: `refactor: dissolve ForegroundDeps into plain parameters (#145)`
260
+
261
+ 10. Red: update `background-spawner.test.ts` calls to pass plain args.
262
+ Green: remove `BackgroundDeps` interface, change `spawnBackground` signature.
263
+ Commit: `refactor: dissolve BackgroundDeps into plain parameters (#145)`
264
+
265
+ 11. Red: update `service-adapter.test.ts` to pass plain parameters instead of `AdapterDeps` bag.
266
+ Green: remove `AdapterDeps` interface, change `createSubagentsService` signature.
267
+ Update `index.ts` wiring call site.
268
+ Commit: `refactor: dissolve AdapterDeps into plain parameters (#145)`
269
+
270
+ 12. Refactor: destructure `AgentToolDeps` in `createAgentTool` signature (keep the named type for test factory).
271
+ Commit: `refactor: destructure AgentToolDeps in createAgentTool (#145)`
272
+
273
+ ### Step 7: Final verification
274
+
275
+ 13. Run full test suite and type check.
276
+
277
+ ## Risks and Mitigations
278
+
279
+ | Risk | Mitigation |
280
+ | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
281
+ | Wide blast radius — touches 7+ source files and 5+ test files | Incremental TDD steps; each commit leaves the repo green |
282
+ | `service-adapter.ts` now imports `buildParentSnapshot` — new coupling | Acceptable: the adapter is already a boundary module that bridges `ExtensionContext` to domain types |
283
+ | `ResolvedSpawnConfig` could become a new "god object" | It is a pure data return from a single function; consumers destructure what they need |
284
+ | Three new collaborators grow `AgentToolDeps` from 6 to 9 fields | The deps bag is destructured at the signature; the named type exists only for the test factory. The real dependency count stays the same — previously hidden behind `ctx` reads |
285
+ | `index.ts` closures capture `ctx` — stale reference risk | Same pattern `service-adapter.ts` already uses via `runtime.currentCtx`; session lifecycle clears on shutdown |
286
+
287
+ ## Open Questions
288
+
289
+ - The exact boundary between fields that stay in `ForegroundParams` / `BackgroundParams` vs. fields that move into `ResolvedSpawnConfig` may shift during implementation.
290
+ The guiding principle: if the field is computed during config resolution, it belongs in `ResolvedSpawnConfig`; if it is dispatch-specific (e.g., `toolCallId`, `signal`, `onUpdate`), it stays in the params type.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.14.1",
3
+ "version": "6.15.0",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -8,14 +8,13 @@
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
- import type { AgentSession, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
12
12
  import { AgentRecord } from "./agent-record.js";
13
13
  import type { AgentRunner } from "./agent-runner.js";
14
14
  import { AgentTypeRegistry } from "./agent-types.js";
15
15
  import { debugLog } from "./debug.js";
16
16
  import { NotificationState } from "./notification-state.js";
17
17
  import type { ParentSnapshot } from "./parent-snapshot.js";
18
- import { buildParentSnapshot } from "./parent-snapshot.js";
19
18
  import { subscribeRecordObserver } from "./record-observer.js";
20
19
  import type { RunConfig } from "./runtime.js";
21
20
  import type { AgentInvocation, IsolationMode, ShellExec, SubagentType, ThinkingLevel } from "./types.js";
@@ -141,7 +140,7 @@ export class AgentManager {
141
140
  * If the concurrency limit is reached, the agent is queued.
142
141
  */
143
142
  spawn(
144
- ctx: ExtensionContext,
143
+ snapshot: ParentSnapshot,
145
144
  type: SubagentType,
146
145
  prompt: string,
147
146
  options: AgentSpawnConfig,
@@ -167,7 +166,6 @@ export class AgentManager {
167
166
  this.observer?.onAgentCreated(record);
168
167
  }
169
168
 
170
- const snapshot = buildParentSnapshot(ctx, options.inheritContext);
171
169
  const args: SpawnArgs = { snapshot, type, prompt, options };
172
170
 
173
171
  if (options.isBackground && !options.bypassQueue && this.runningBackground >= this._getMaxConcurrent()) {
@@ -332,12 +330,12 @@ export class AgentManager {
332
330
  * Foreground agents bypass the concurrency queue.
333
331
  */
334
332
  async spawnAndWait(
335
- ctx: ExtensionContext,
333
+ snapshot: ParentSnapshot,
336
334
  type: SubagentType,
337
335
  prompt: string,
338
336
  options: Omit<AgentSpawnConfig, "isBackground">,
339
337
  ): Promise<AgentRecord> {
340
- const id = this.spawn(ctx, type, prompt, { ...options, isBackground: false });
338
+ const id = this.spawn(snapshot, type, prompt, { ...options, isBackground: false });
341
339
  const record = this.agents.get(id)!;
342
340
  await record.promise;
343
341
  return record;
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ import { SessionLifecycleHandler, ToolStartHandler } from "./handlers/index.js";
29
29
  import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
30
30
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
31
31
  import { buildEventData, type NotificationDetails, NotificationManager } from "./notification.js";
32
+ import { buildParentSnapshot } from "./parent-snapshot.js";
32
33
  import { buildAgentPrompt } from "./prompts.js";
33
34
  import { createNotificationRenderer } from "./renderer.js";
34
35
  import { createSubagentRuntime } from "./runtime.js";
@@ -162,12 +163,12 @@ export default function (pi: ExtensionAPI) {
162
163
 
163
164
  // Typed service published via Symbol.for() for cross-extension access.
164
165
  // Consumers: const { getSubagentsService } = await import("@gotgenes/pi-subagents");
165
- const service = createSubagentsService({
166
+ const service = createSubagentsService(
166
167
  manager,
167
168
  resolveModel,
168
- getCtx: () => runtime.currentCtx,
169
- getModelRegistry: () => (runtime.currentCtx?.ctx as { modelRegistry?: ModelRegistry } | undefined)?.modelRegistry,
170
- });
169
+ () => runtime.currentCtx,
170
+ () => (runtime.currentCtx?.ctx as { modelRegistry?: ModelRegistry } | undefined)?.modelRegistry,
171
+ );
171
172
  publishSubagentsService(service);
172
173
 
173
174
  const lifecycle = new SessionLifecycleHandler(
@@ -193,8 +194,8 @@ export default function (pi: ExtensionAPI) {
193
194
 
194
195
  pi.registerTool(defineTool(createAgentTool({
195
196
  manager: {
196
- spawn: (ctx, type, prompt, opts) => manager.spawn(ctx, type, prompt, opts),
197
- spawnAndWait: (ctx, type, prompt, opts) => manager.spawnAndWait(ctx, type, prompt, opts),
197
+ spawn: (snapshot, type, prompt, opts) => manager.spawn(snapshot, type, prompt, opts),
198
+ spawnAndWait: (snapshot, type, prompt, opts) => manager.spawnAndWait(snapshot, type, prompt, opts),
198
199
  resume: (id, prompt, signal) => manager.resume(id, prompt, signal),
199
200
  getRecord: (id) => manager.getRecord(id),
200
201
  getMaxConcurrent: () => settings.maxConcurrent,
@@ -209,6 +210,19 @@ export default function (pi: ExtensionAPI) {
209
210
  registry,
210
211
  agentDir: getAgentDir(),
211
212
  settings,
213
+ buildSnapshot: (inheritContext) =>
214
+ buildParentSnapshot(
215
+ runtime.currentCtx?.ctx as import("@earendil-works/pi-coding-agent").ExtensionContext,
216
+ inheritContext,
217
+ ),
218
+ getModelInfo: () => ({
219
+ parentModel: (runtime.currentCtx?.ctx as any)?.model,
220
+ modelRegistry: (runtime.currentCtx?.ctx as any)?.modelRegistry,
221
+ }),
222
+ getSessionInfo: () => ({
223
+ parentSessionFile: (runtime.currentCtx?.ctx as any)?.sessionManager?.getSessionFile() ?? "",
224
+ parentSessionId: (runtime.currentCtx?.ctx as any)?.sessionManager?.getSessionId() ?? "",
225
+ }),
212
226
  })));
213
227
 
214
228
  // ---- get_subagent_result tool ----
@@ -235,7 +249,7 @@ export default function (pi: ExtensionAPI) {
235
249
  manager: {
236
250
  listAgents: () => manager.listAgents(),
237
251
  getRecord: (id) => manager.getRecord(id),
238
- spawnAndWait: (ctx, type, prompt, opts) => manager.spawnAndWait(ctx, type, prompt, opts),
252
+ spawnAndWait: (ctx, type, prompt, opts) => manager.spawnAndWait(buildParentSnapshot(ctx), type, prompt, opts),
239
253
  },
240
254
  registry,
241
255
  agentActivity: runtime.agentActivity,
@@ -5,13 +5,16 @@
5
5
  * (stripping non-serializable fields), and session gating.
6
6
  */
7
7
 
8
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
9
  import type { ModelRegistry } from "./model-resolver.js";
10
+ import type { ParentSnapshot } from "./parent-snapshot.js";
11
+ import { buildParentSnapshot } from "./parent-snapshot.js";
9
12
  import type { SubagentRecord, SubagentsService } from "./service.js";
10
13
  import type { AgentRecord } from "./types.js";
11
14
 
12
15
  /** Narrow interface for the AgentManager — avoids coupling to the concrete class. */
13
16
  export interface AgentManagerLike {
14
- spawn(ctx: unknown, type: string, prompt: string, options: unknown): string;
17
+ spawn(snapshot: ParentSnapshot, type: string, prompt: string, options: unknown): string;
15
18
  getRecord(id: string): AgentRecord | undefined;
16
19
  listAgents(): AgentRecord[];
17
20
  abort(id: string): boolean;
@@ -20,32 +23,27 @@ export interface AgentManagerLike {
20
23
  queueSteer(id: string, message: string): boolean;
21
24
  }
22
25
 
23
- /** Dependencies injected into the adapter factory. */
24
- export interface AdapterDeps {
25
- manager: AgentManagerLike;
26
- resolveModel: (input: string, registry: ModelRegistry) => unknown | string;
27
- getCtx: () => { pi: unknown; ctx: unknown } | undefined;
28
- getModelRegistry: () => ModelRegistry | undefined;
29
- }
30
-
31
26
  /** Create a SubagentsService backed by the given dependencies. */
32
- export function createSubagentsService(deps: AdapterDeps): SubagentsService {
33
- const { manager } = deps;
34
-
27
+ export function createSubagentsService(
28
+ manager: AgentManagerLike,
29
+ resolveModel: (input: string, registry: ModelRegistry) => unknown | string,
30
+ getCtx: () => { pi: unknown; ctx: unknown } | undefined,
31
+ getModelRegistry: () => ModelRegistry | undefined,
32
+ ): SubagentsService {
35
33
  return {
36
34
  spawn(type: string, prompt: string, options?) {
37
- const session = deps.getCtx();
35
+ const session = getCtx();
38
36
  if (!session) {
39
37
  throw new Error("No active session — cannot spawn agents outside a session.");
40
38
  }
41
39
 
42
40
  let model: unknown;
43
41
  if (options?.model) {
44
- const registry = deps.getModelRegistry();
42
+ const registry = getModelRegistry();
45
43
  if (!registry) {
46
44
  throw new Error("No model registry available.");
47
45
  }
48
- const resolved = deps.resolveModel(options.model, registry);
46
+ const resolved = resolveModel(options.model, registry);
49
47
  if (typeof resolved === "string") {
50
48
  throw new Error(resolved);
51
49
  }
@@ -55,7 +53,11 @@ export function createSubagentsService(deps: AdapterDeps): SubagentsService {
55
53
  const description = options?.description ?? prompt.slice(0, 80);
56
54
  const isBackground = !(options?.foreground ?? false);
57
55
 
58
- return manager.spawn(session.ctx, type, prompt, {
56
+ const snapshot = buildParentSnapshot(
57
+ session.ctx as ExtensionContext,
58
+ options?.inheritContext,
59
+ );
60
+ return manager.spawn(snapshot, type, prompt, {
59
61
  description,
60
62
  model,
61
63
  maxTurns: options?.maxTurns,
@@ -1,34 +1,31 @@
1
- import type { AgentToolResult, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "@sinclair/typebox";
4
4
  import type { AgentSpawnConfig } from "../agent-manager.js";
5
- import { normalizeMaxTurns } from "../agent-runner.js";
6
5
  import { AgentTypeRegistry } from "../agent-types.js";
7
- import { resolveAgentInvocationConfig } from "../invocation-config.js";
8
- import { resolveInvocationModel } from "../model-resolver.js";
6
+ import type { ParentSnapshot } from "../parent-snapshot.js";
9
7
 
10
- import type { AgentInvocation, AgentRecord, SubagentType } from "../types.js";
8
+ import type { AgentRecord } from "../types.js";
11
9
  import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
12
10
  import { type UICtx } from "../ui/agent-widget.js";
13
11
  import {
14
12
  type AgentDetails,
15
- buildInvocationTags,
16
13
  formatMs,
17
14
  formatTurns,
18
15
  getDisplayName,
19
- getPromptModeLabel,
20
16
  SPINNER,
21
17
  } from "../ui/display.js";
22
18
  import { spawnBackground } from "./background-spawner.js";
23
19
  import { runForeground } from "./foreground-runner.js";
24
20
  import { buildDetails, buildTypeListText, textResult } from "./helpers.js";
21
+ import { type ModelInfo, resolveSpawnConfig } from "./spawn-config.js";
25
22
 
26
23
  // ---- Deps interface ----
27
24
 
28
25
  /** Narrow manager interface — only the methods the Agent tool calls. */
29
26
  export interface AgentToolManager {
30
- spawn: (ctx: ExtensionContext, type: string, prompt: string, opts: AgentSpawnConfig) => string;
31
- spawnAndWait: (ctx: ExtensionContext, type: string, prompt: string, opts: Omit<AgentSpawnConfig, "isBackground">) => Promise<AgentRecord>;
27
+ spawn: (snapshot: ParentSnapshot, type: string, prompt: string, opts: AgentSpawnConfig) => string;
28
+ spawnAndWait: (snapshot: ParentSnapshot, type: string, prompt: string, opts: Omit<AgentSpawnConfig, "isBackground">) => Promise<AgentRecord>;
32
29
  resume: (id: string, prompt: string, signal: AbortSignal) => Promise<AgentRecord | undefined>;
33
30
  getRecord: (id: string) => AgentRecord | undefined;
34
31
  getMaxConcurrent: () => number;
@@ -60,14 +57,30 @@ export interface AgentToolDeps {
60
57
  agentDir: string;
61
58
  /** Narrow settings accessor — only the default max turns is needed here. */
62
59
  settings: { readonly defaultMaxTurns: number | undefined };
60
+ /** Build a ParentSnapshot from the current session context. */
61
+ buildSnapshot: (inheritContext: boolean) => ParentSnapshot;
62
+ /** Model info from the current session context. */
63
+ getModelInfo: () => ModelInfo;
64
+ /** Parent session identity from the current session context. */
65
+ getSessionInfo: () => { parentSessionFile: string; parentSessionId: string };
63
66
  }
64
67
 
65
68
  // ---- Factory ----
66
69
 
67
70
  /** Create the Agent tool definition (without Pi SDK wrapper). */
68
- export function createAgentTool(deps: AgentToolDeps) {
69
- const typeListText = buildTypeListText(deps.registry, deps.agentDir);
70
- const availableTypesText = deps.registry.getAvailableTypes().join(", ");
71
+ export function createAgentTool({
72
+ manager,
73
+ widget,
74
+ agentActivity,
75
+ registry,
76
+ agentDir,
77
+ settings,
78
+ buildSnapshot,
79
+ getModelInfo,
80
+ getSessionInfo,
81
+ }: AgentToolDeps) {
82
+ const typeListText = buildTypeListText(registry, agentDir);
83
+ const availableTypesText = registry.getAvailableTypes().join(", ");
71
84
  return {
72
85
  name: "Agent" as const,
73
86
  label: "Agent",
@@ -101,7 +114,7 @@ Guidelines:
101
114
  description: "A short (3-5 word) description of the task (shown in UI).",
102
115
  }),
103
116
  subagent_type: Type.String({
104
- description: `The type of specialized agent to use. Available types: ${availableTypesText}. Custom agents from .pi/agents/<name>.md (project) or ${deps.agentDir}/agents/<name>.md (global) are also available.`,
117
+ description: `The type of specialized agent to use. Available types: ${availableTypesText}. Custom agents from .pi/agents/<name>.md (project) or ${agentDir}/agents/<name>.md (global) are also available.`,
105
118
  }),
106
119
  model: Type.Optional(
107
120
  Type.String({
@@ -156,7 +169,7 @@ Guidelines:
156
169
 
157
170
  renderCall(args: Record<string, unknown>, theme: any) {
158
171
  const displayName = args.subagent_type
159
- ? getDisplayName(args.subagent_type as string, deps.registry)
172
+ ? getDisplayName(args.subagent_type as string, registry)
160
173
  : "Agent";
161
174
  const desc = (args.description as string) ?? "";
162
175
  return new Text(
@@ -273,71 +286,27 @@ Guidelines:
273
286
  ctx: any,
274
287
  ) => {
275
288
  // Ensure we have UI context for widget rendering
276
- deps.widget.setUICtx(ctx.ui as UICtx);
289
+ widget.setUICtx(ctx.ui as UICtx);
277
290
 
278
291
  // Reload custom agents so new .pi/agents/*.md files are picked up without restart
279
- deps.registry.reload();
280
-
281
- const rawType = params.subagent_type as SubagentType;
282
- const resolved = deps.registry.resolveType(rawType);
283
- const subagentType = resolved ?? "general-purpose";
284
- const fellBack = resolved === undefined;
285
-
286
- const displayName = getDisplayName(subagentType, deps.registry);
287
-
288
- // Get agent config for invocation resolution
289
- const customConfig = deps.registry.resolveAgentConfig(subagentType);
290
-
291
- const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
292
-
293
- // Resolve model from agent config first; tool-call params only fill gaps.
294
- const resolution = resolveInvocationModel(
295
- ctx.model,
296
- resolvedConfig.modelInput,
297
- resolvedConfig.modelFromParams,
298
- ctx.modelRegistry,
292
+ registry.reload();
293
+
294
+ // ---- Config resolution (pure) ----
295
+ const config = resolveSpawnConfig(
296
+ params,
297
+ registry,
298
+ getModelInfo(),
299
+ settings,
299
300
  );
300
- if (resolution.error) return textResult(resolution.error);
301
- const model = resolution.model;
301
+ if ("error" in config) return textResult(config.error);
302
302
 
303
- const thinking = resolvedConfig.thinking;
304
- const inheritContext = resolvedConfig.inheritContext;
305
- const runInBackground = resolvedConfig.runInBackground;
306
- const isolated = resolvedConfig.isolated;
307
- const isolation = resolvedConfig.isolation;
308
-
309
- const parentModelId = ctx.model?.id;
310
- const effectiveModelId = model?.id;
311
- const modelName =
312
- effectiveModelId && effectiveModelId !== parentModelId
313
- ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
314
- : undefined;
315
- const effectiveMaxTurns = normalizeMaxTurns(
316
- resolvedConfig.maxTurns ?? deps.settings.defaultMaxTurns,
317
- );
318
- const agentInvocation: AgentInvocation = {
319
- modelName,
320
- thinking,
321
- maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
322
- isolated,
323
- inheritContext,
324
- runInBackground,
325
- isolation,
326
- };
327
- const modeLabel = getPromptModeLabel(subagentType, deps.registry);
328
- const { tags: invocationTags } = buildInvocationTags(agentInvocation);
329
- const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
330
- const detailBase = {
331
- displayName,
332
- description: params.description as string,
333
- subagentType,
334
- modelName,
335
- tags: agentTags.length > 0 ? agentTags : undefined,
336
- };
303
+ // ---- Boundary extraction (after config so inheritContext is resolved) ----
304
+ const snapshot = buildSnapshot(config.inheritContext);
305
+ const { parentSessionFile, parentSessionId } = getSessionInfo();
337
306
 
338
- // Resume existing agent
307
+ // ---- Resume existing agent ----
339
308
  if (params.resume) {
340
- const existing = deps.manager.getRecord(params.resume as string);
309
+ const existing = manager.getRecord(params.resume as string);
341
310
  if (!existing) {
342
311
  return textResult(
343
312
  `Agent not found: "${params.resume}". It may have been cleaned up.`,
@@ -348,7 +317,7 @@ Guidelines:
348
317
  `Agent "${params.resume}" has no active session to resume.`,
349
318
  );
350
319
  }
351
- const record = await deps.manager.resume(
320
+ const record = await manager.resume(
352
321
  params.resume as string,
353
322
  params.prompt as string,
354
323
  signal ?? new AbortController().signal,
@@ -358,52 +327,26 @@ Guidelines:
358
327
  }
359
328
  return textResult(
360
329
  record.result?.trim() || record.error?.trim() || "No output.",
361
- buildDetails(detailBase, record),
330
+ buildDetails(config.detailBase, record),
362
331
  );
363
332
  }
364
333
 
365
- // Background execution
366
- if (runInBackground) {
334
+ // ---- Background execution ----
335
+ if (config.runInBackground) {
367
336
  return spawnBackground(
368
- { manager: deps.manager, widget: deps.widget, agentActivity: deps.agentActivity },
369
- {
370
- ctx,
371
- subagentType,
372
- prompt: params.prompt as string,
373
- description: params.description as string,
374
- displayName,
375
- toolCallId,
376
- detailBase,
377
- model,
378
- effectiveMaxTurns,
379
- isolated,
380
- inheritContext,
381
- thinking,
382
- isolation,
383
- agentInvocation,
384
- },
337
+ manager,
338
+ widget,
339
+ agentActivity,
340
+ { config, snapshot, parentSessionFile, parentSessionId, toolCallId },
385
341
  );
386
342
  }
387
343
 
388
- // Foreground (synchronous) execution — stream progress via onUpdate
344
+ // ---- Foreground execution — stream progress via onUpdate ----
389
345
  return runForeground(
390
- { manager: deps.manager, widget: deps.widget, agentActivity: deps.agentActivity },
391
- {
392
- ctx,
393
- subagentType,
394
- prompt: params.prompt as string,
395
- description: params.description as string,
396
- detailBase,
397
- rawType,
398
- fellBack,
399
- model,
400
- effectiveMaxTurns,
401
- isolated,
402
- inheritContext,
403
- thinking,
404
- isolation,
405
- agentInvocation,
406
- },
346
+ manager,
347
+ widget,
348
+ agentActivity,
349
+ { config, snapshot, parentSessionFile, parentSessionId },
407
350
  signal,
408
351
  onUpdate,
409
352
  );
@@ -1,15 +1,15 @@
1
- import type { Model } from "@earendil-works/pi-ai";
2
1
  import type { AgentSpawnConfig } from "../agent-manager.js";
3
- import type { AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "../types.js";
2
+ import type { ParentSnapshot } from "../parent-snapshot.js";
3
+ import type { AgentRecord } from "../types.js";
4
4
  import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
5
- import type { AgentDetails } from "../ui/display.js";
6
5
  import { subscribeUIObserver } from "../ui/ui-observer.js";
7
6
  import type { AgentActivityAccess } from "./agent-tool.js";
8
7
  import { textResult } from "./helpers.js";
8
+ import type { ResolvedSpawnConfig } from "./spawn-config.js";
9
9
 
10
10
  /** Narrow manager interface for the background spawner. */
11
11
  export interface BackgroundManagerDeps {
12
- spawn(ctx: any, type: string, prompt: string, opts: AgentSpawnConfig): string;
12
+ spawn(snapshot: ParentSnapshot, type: string, prompt: string, opts: AgentSpawnConfig): string;
13
13
  getRecord(id: string): AgentRecord | undefined;
14
14
  getMaxConcurrent(): number;
15
15
  }
@@ -20,34 +20,13 @@ export interface BackgroundWidgetDeps {
20
20
  update(): void;
21
21
  }
22
22
 
23
- /** Injected collaborators for spawnBackground. */
24
- export interface BackgroundDeps {
25
- manager: BackgroundManagerDeps;
26
- widget: BackgroundWidgetDeps;
27
- agentActivity: AgentActivityAccess;
28
- }
29
-
30
- /** All values the background spawner needs, bundled from shared execute setup. */
23
+ /** All values the background spawner needs beyond the resolved config. */
31
24
  export interface BackgroundParams {
32
- ctx: {
33
- sessionManager: {
34
- getSessionFile(): string;
35
- getSessionId(): string;
36
- };
37
- };
38
- subagentType: string;
39
- prompt: string;
40
- description: string;
41
- displayName: string;
25
+ config: ResolvedSpawnConfig;
26
+ snapshot: ParentSnapshot;
27
+ parentSessionFile: string;
28
+ parentSessionId: string;
42
29
  toolCallId: string;
43
- detailBase: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">;
44
- model: Model<any> | undefined;
45
- effectiveMaxTurns: number | undefined;
46
- isolated: boolean | undefined;
47
- inheritContext: boolean | undefined;
48
- thinking: ThinkingLevel | undefined;
49
- isolation: IsolationMode | undefined;
50
- agentInvocation: AgentInvocation;
51
30
  }
52
31
 
53
32
  /**
@@ -56,25 +35,28 @@ export interface BackgroundParams {
56
35
  * registration, widget update, and launch message formatting.
57
36
  */
58
37
  export function spawnBackground(
59
- deps: BackgroundDeps,
38
+ manager: BackgroundManagerDeps,
39
+ widget: BackgroundWidgetDeps,
40
+ agentActivity: AgentActivityAccess,
60
41
  params: BackgroundParams,
61
42
  ) {
62
- const bgState = new AgentActivityTracker(params.effectiveMaxTurns);
43
+ const { config } = params;
44
+ const bgState = new AgentActivityTracker(config.effectiveMaxTurns);
63
45
 
64
46
  let id: string;
65
47
  try {
66
- id = deps.manager.spawn(params.ctx, params.subagentType, params.prompt, {
67
- parentSessionFile: params.ctx.sessionManager.getSessionFile(),
68
- parentSessionId: params.ctx.sessionManager.getSessionId(),
69
- description: params.description,
70
- model: params.model,
71
- maxTurns: params.effectiveMaxTurns,
72
- isolated: params.isolated,
73
- inheritContext: params.inheritContext,
74
- thinkingLevel: params.thinking,
48
+ id = manager.spawn(params.snapshot, config.subagentType, config.prompt, {
49
+ parentSessionFile: params.parentSessionFile,
50
+ parentSessionId: params.parentSessionId,
51
+ description: config.description,
52
+ model: config.model,
53
+ maxTurns: config.effectiveMaxTurns,
54
+ isolated: config.isolated,
55
+ inheritContext: config.inheritContext,
56
+ thinkingLevel: config.thinking,
75
57
  isBackground: true,
76
- isolation: params.isolation,
77
- invocation: params.agentInvocation,
58
+ isolation: config.isolation,
59
+ invocation: config.agentInvocation,
78
60
  toolCallId: params.toolCallId,
79
61
  onSessionCreated: (session) => {
80
62
  bgState.setSession(session);
@@ -85,27 +67,27 @@ export function spawnBackground(
85
67
  return textResult(err instanceof Error ? err.message : String(err));
86
68
  }
87
69
 
88
- const record = deps.manager.getRecord(id);
70
+ const record = manager.getRecord(id);
89
71
 
90
- deps.agentActivity.set(id, bgState);
91
- deps.widget.ensureTimer();
92
- deps.widget.update();
72
+ agentActivity.set(id, bgState);
73
+ widget.ensureTimer();
74
+ widget.update();
93
75
 
94
76
  const isQueued = record?.status === "queued";
95
77
  return textResult(
96
78
  `Agent ${isQueued ? "queued" : "started"} in background.\n` +
97
79
  `Agent ID: ${id}\n` +
98
- `Type: ${params.displayName}\n` +
99
- `Description: ${params.description}\n` +
80
+ `Type: ${config.displayName}\n` +
81
+ `Description: ${config.description}\n` +
100
82
  (record?.execution?.outputFile ? `Output file: ${record.execution.outputFile}\n` : "") +
101
83
  (isQueued
102
- ? `Position: queued (max ${deps.manager.getMaxConcurrent()} concurrent)\n`
84
+ ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n`
103
85
  : "") +
104
86
  `\nYou will be notified when this agent completes.\n` +
105
87
  `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` +
106
88
  `Do not duplicate this agent's work.`,
107
89
  {
108
- ...params.detailBase,
90
+ ...config.detailBase,
109
91
  toolUses: 0,
110
92
  tokens: "",
111
93
  durationMs: 0,
@@ -1,7 +1,7 @@
1
- import type { Model } from "@earendil-works/pi-ai";
2
1
  import type { AgentToolResult } from "@earendil-works/pi-coding-agent";
3
2
  import type { AgentSpawnConfig } from "../agent-manager.js";
4
- import type { AgentInvocation, AgentRecord, IsolationMode, ThinkingLevel } from "../types.js";
3
+ import type { ParentSnapshot } from "../parent-snapshot.js";
4
+ import type { AgentRecord } from "../types.js";
5
5
  import { AgentActivityTracker } from "../ui/agent-activity-tracker.js";
6
6
  import {
7
7
  type AgentDetails,
@@ -17,11 +17,12 @@ import {
17
17
  getStatusNote,
18
18
  textResult,
19
19
  } from "./helpers.js";
20
+ import type { ResolvedSpawnConfig } from "./spawn-config.js";
20
21
 
21
22
  /** Narrow manager interface for the foreground runner. */
22
23
  export interface ForegroundManagerDeps {
23
24
  spawnAndWait(
24
- ctx: any,
25
+ snapshot: ParentSnapshot,
25
26
  type: string,
26
27
  prompt: string,
27
28
  opts: Omit<AgentSpawnConfig, "isBackground">,
@@ -34,37 +35,12 @@ export interface ForegroundWidgetDeps {
34
35
  markFinished(id: string): void;
35
36
  }
36
37
 
37
- /** Injected collaborators for runForeground. */
38
- export interface ForegroundDeps {
39
- manager: ForegroundManagerDeps;
40
- widget: ForegroundWidgetDeps;
41
- agentActivity: AgentActivityAccess;
42
- }
43
-
44
- /** All values the foreground runner needs, bundled from shared execute setup. */
38
+ /** All values the foreground runner needs beyond the resolved config. */
45
39
  export interface ForegroundParams {
46
- ctx: {
47
- sessionManager: {
48
- getSessionFile(): string;
49
- getSessionId(): string;
50
- };
51
- };
52
- subagentType: string;
53
- prompt: string;
54
- description: string;
55
- detailBase: Pick<
56
- AgentDetails,
57
- "displayName" | "description" | "subagentType" | "modelName" | "tags"
58
- >;
59
- rawType: string;
60
- fellBack: boolean;
61
- model: Model<any> | undefined;
62
- effectiveMaxTurns: number | undefined;
63
- isolated: boolean | undefined;
64
- inheritContext: boolean | undefined;
65
- thinking: ThinkingLevel | undefined;
66
- isolation: IsolationMode | undefined;
67
- agentInvocation: AgentInvocation;
40
+ config: ResolvedSpawnConfig;
41
+ snapshot: ParentSnapshot;
42
+ parentSessionFile: string;
43
+ parentSessionId: string;
68
44
  }
69
45
 
70
46
  /**
@@ -73,21 +49,24 @@ export interface ForegroundParams {
73
49
  * streaming onUpdate callbacks, cleanup, and result formatting.
74
50
  */
75
51
  export async function runForeground(
76
- deps: ForegroundDeps,
52
+ manager: ForegroundManagerDeps,
53
+ widget: ForegroundWidgetDeps,
54
+ agentActivity: AgentActivityAccess,
77
55
  params: ForegroundParams,
78
56
  signal: AbortSignal | undefined,
79
57
  onUpdate: ((update: AgentToolResult<any>) => void) | undefined,
80
58
  ) {
59
+ const { config } = params;
81
60
  let spinnerFrame = 0;
82
61
  const startedAt = Date.now();
83
62
  let fgId: string | undefined;
84
63
 
85
- const fgState = new AgentActivityTracker(params.effectiveMaxTurns);
64
+ const fgState = new AgentActivityTracker(config.effectiveMaxTurns);
86
65
  let unsubUI: (() => void) | undefined;
87
66
 
88
67
  const streamUpdate = () => {
89
68
  const details: AgentDetails = {
90
- ...params.detailBase,
69
+ ...config.detailBase,
91
70
  toolUses: fgState.toolUses,
92
71
  tokens: formatLifetimeTokens(fgState),
93
72
  turnCount: fgState.turnCount,
@@ -113,28 +92,28 @@ export async function runForeground(
113
92
 
114
93
  let record: AgentRecord;
115
94
  try {
116
- record = await deps.manager.spawnAndWait(
117
- params.ctx,
118
- params.subagentType,
119
- params.prompt,
95
+ record = await manager.spawnAndWait(
96
+ params.snapshot,
97
+ config.subagentType,
98
+ config.prompt,
120
99
  {
121
- description: params.description,
122
- model: params.model,
123
- maxTurns: params.effectiveMaxTurns,
124
- isolated: params.isolated,
125
- inheritContext: params.inheritContext,
126
- thinkingLevel: params.thinking,
127
- isolation: params.isolation,
128
- invocation: params.agentInvocation,
100
+ description: config.description,
101
+ model: config.model,
102
+ maxTurns: config.effectiveMaxTurns,
103
+ isolated: config.isolated,
104
+ inheritContext: config.inheritContext,
105
+ thinkingLevel: config.thinking,
106
+ isolation: config.isolation,
107
+ invocation: config.agentInvocation,
129
108
  signal,
130
- parentSessionFile: params.ctx.sessionManager.getSessionFile(),
131
- parentSessionId: params.ctx.sessionManager.getSessionId(),
109
+ parentSessionFile: params.parentSessionFile,
110
+ parentSessionId: params.parentSessionId,
132
111
  onSessionCreated: (session, record) => {
133
112
  fgState.setSession(session);
134
113
  unsubUI = subscribeUIObserver(session, fgState, streamUpdate);
135
114
  fgId = record.id;
136
- deps.agentActivity.set(record.id, fgState);
137
- deps.widget.ensureTimer();
115
+ agentActivity.set(record.id, fgState);
116
+ widget.ensureTimer();
138
117
  },
139
118
  },
140
119
  );
@@ -149,15 +128,15 @@ export async function runForeground(
149
128
 
150
129
  // Clean up foreground agent from widget
151
130
  if (fgId) {
152
- deps.agentActivity.delete(fgId);
153
- deps.widget.markFinished(fgId);
131
+ agentActivity.delete(fgId);
132
+ widget.markFinished(fgId);
154
133
  }
155
134
 
156
135
  const tokenText = formatLifetimeTokens(fgState);
157
- const details = buildDetails(params.detailBase, record, fgState, { tokens: tokenText });
136
+ const details = buildDetails(config.detailBase, record, fgState, { tokens: tokenText });
158
137
 
159
- const fallbackNote = params.fellBack
160
- ? `Note: Unknown agent type "${params.rawType}" \u2014 using general-purpose.\n\n`
138
+ const fallbackNote = config.fellBack
139
+ ? `Note: Unknown agent type "${config.rawType}" using general-purpose.\n\n`
161
140
  : "";
162
141
 
163
142
  if (record.status === "error") {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * spawn-config.ts — Pure config resolution for the Agent tool.
3
+ *
4
+ * Extracts all config resolution logic from execute: type resolution,
5
+ * invocation config merge, model resolution, max-turns normalization,
6
+ * tag building, and detail-base construction.
7
+ */
8
+
9
+ import type { Model } from "@earendil-works/pi-ai";
10
+ import { normalizeMaxTurns } from "../agent-runner.js";
11
+ import type { AgentTypeRegistry } from "../agent-types.js";
12
+ import { resolveAgentInvocationConfig } from "../invocation-config.js";
13
+ import { resolveInvocationModel } from "../model-resolver.js";
14
+ import type { AgentInvocation, IsolationMode, SubagentType, ThinkingLevel } from "../types.js";
15
+ import {
16
+ type AgentDetails,
17
+ buildInvocationTags,
18
+ getDisplayName,
19
+ getPromptModeLabel,
20
+ } from "../ui/display.js";
21
+
22
+ /** Model info extracted from the parent session context. */
23
+ export interface ModelInfo {
24
+ parentModel: { id: string; name?: string } | undefined;
25
+ modelRegistry: unknown;
26
+ }
27
+
28
+ /** Fully resolved config for spawning an agent. */
29
+ export interface ResolvedSpawnConfig {
30
+ subagentType: string;
31
+ rawType: SubagentType;
32
+ fellBack: boolean;
33
+ displayName: string;
34
+ prompt: string;
35
+ description: string;
36
+ model: Model<any> | undefined;
37
+ effectiveMaxTurns: number | undefined;
38
+ thinking: ThinkingLevel | undefined;
39
+ inheritContext: boolean;
40
+ runInBackground: boolean;
41
+ isolated: boolean;
42
+ isolation: IsolationMode | undefined;
43
+ modelName: string | undefined;
44
+ agentInvocation: AgentInvocation;
45
+ agentTags: string[];
46
+ detailBase: Pick<AgentDetails, "displayName" | "description" | "subagentType" | "modelName" | "tags">;
47
+ }
48
+
49
+ /** Error result when model resolution fails. */
50
+ export interface SpawnConfigError {
51
+ error: string;
52
+ }
53
+
54
+ /**
55
+ * Resolve all config for an Agent tool invocation.
56
+ *
57
+ * Pure function — no SDK types, no side effects.
58
+ * Returns either a fully resolved config or an error.
59
+ */
60
+ export function resolveSpawnConfig(
61
+ params: Record<string, unknown>,
62
+ registry: AgentTypeRegistry,
63
+ modelInfo: ModelInfo,
64
+ settings: { readonly defaultMaxTurns: number | undefined },
65
+ ): ResolvedSpawnConfig | SpawnConfigError {
66
+ const rawType = params.subagent_type as SubagentType;
67
+ const resolved = registry.resolveType(rawType);
68
+ const subagentType = resolved ?? "general-purpose";
69
+ const fellBack = resolved === undefined;
70
+
71
+ const displayName = getDisplayName(subagentType, registry);
72
+
73
+ // Merge agent config defaults with tool-call params
74
+ const customConfig = registry.resolveAgentConfig(subagentType);
75
+ const resolvedConfig = resolveAgentInvocationConfig(customConfig, params);
76
+
77
+ // Resolve model
78
+ const resolution = resolveInvocationModel(
79
+ modelInfo.parentModel,
80
+ resolvedConfig.modelInput,
81
+ resolvedConfig.modelFromParams,
82
+ modelInfo.modelRegistry as any,
83
+ );
84
+ if (resolution.error) return { error: resolution.error };
85
+ const model = resolution.model;
86
+
87
+ const thinking = resolvedConfig.thinking;
88
+ const inheritContext = resolvedConfig.inheritContext;
89
+ const runInBackground = resolvedConfig.runInBackground;
90
+ const isolated = resolvedConfig.isolated;
91
+ const isolation = resolvedConfig.isolation;
92
+
93
+ // Compute display model name (only shown when different from parent)
94
+ const parentModelId = modelInfo.parentModel?.id;
95
+ const effectiveModelId = model?.id;
96
+ const modelName =
97
+ effectiveModelId && effectiveModelId !== parentModelId
98
+ ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
99
+ : undefined;
100
+
101
+ const effectiveMaxTurns = normalizeMaxTurns(
102
+ resolvedConfig.maxTurns ?? settings.defaultMaxTurns,
103
+ );
104
+
105
+ const agentInvocation: AgentInvocation = {
106
+ modelName,
107
+ thinking,
108
+ maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
109
+ isolated,
110
+ inheritContext,
111
+ runInBackground,
112
+ isolation,
113
+ };
114
+
115
+ const modeLabel = getPromptModeLabel(subagentType, registry);
116
+ const { tags: invocationTags } = buildInvocationTags(agentInvocation);
117
+ const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
118
+
119
+ const detailBase = {
120
+ displayName,
121
+ description: params.description as string,
122
+ subagentType,
123
+ modelName,
124
+ tags: agentTags.length > 0 ? agentTags : undefined,
125
+ };
126
+
127
+ return {
128
+ subagentType,
129
+ rawType,
130
+ fellBack,
131
+ displayName,
132
+ prompt: params.prompt as string,
133
+ description: params.description as string,
134
+ model,
135
+ effectiveMaxTurns,
136
+ thinking,
137
+ inheritContext,
138
+ runInBackground,
139
+ isolated,
140
+ isolation,
141
+ modelName,
142
+ agentInvocation,
143
+ agentTags,
144
+ detailBase,
145
+ };
146
+ }