@gotgenes/pi-subagents 13.0.0 → 13.2.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 +17 -0
- package/dist/public.d.ts +1 -1
- package/docs/architecture/architecture.md +80 -43
- package/docs/plans/0265-born-complete-subagent-session.md +330 -0
- package/docs/plans/0277-encapsulate-agent-session.md +304 -0
- package/docs/retro/0264-remove-extension-lifecycle-control.md +41 -0
- package/docs/retro/0265-born-complete-subagent-session.md +97 -0
- package/docs/retro/0277-encapsulate-agent-session.md +39 -0
- package/package.json +1 -1
- package/src/index.ts +3 -3
- package/src/lifecycle/agent-manager.ts +11 -10
- package/src/lifecycle/agent.ts +99 -59
- package/src/lifecycle/create-subagent-session.ts +242 -0
- package/src/lifecycle/subagent-session.ts +234 -0
- package/src/lifecycle/turn-limits.ts +13 -0
- package/src/observation/notification.ts +2 -2
- package/src/runtime.ts +1 -1
- package/src/service/service-adapter.ts +1 -7
- package/src/session/conversation.ts +49 -0
- package/src/session/session-config.ts +8 -8
- package/src/settings.ts +1 -1
- package/src/tools/agent-tool.ts +1 -1
- package/src/tools/background-spawner.ts +4 -3
- package/src/tools/foreground-runner.ts +4 -3
- package/src/tools/get-result-tool.ts +4 -8
- package/src/tools/spawn-config.ts +1 -1
- package/src/tools/steer-tool.ts +7 -13
- package/src/ui/agent-menu.ts +1 -3
- package/src/ui/conversation-viewer.ts +5 -10
- package/src/lifecycle/agent-runner.ts +0 -464
- package/src/lifecycle/execution-state.ts +0 -17
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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
|
+
## [13.2.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v13.1.0...pi-subagents-v13.2.0) (2026-05-30)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add delegate methods to SubagentSession for session encapsulation ([#277](https://github.com/gotgenes/pi-packages/issues/277)) ([038e906](https://github.com/gotgenes/pi-packages/commit/038e906312b00d18ff617caf68bce980db70a243))
|
|
14
|
+
* add session-encapsulation methods to Agent ([#277](https://github.com/gotgenes/pi-packages/issues/277)) ([03b4382](https://github.com/gotgenes/pi-packages/commit/03b43820aa7bd4ab4f9a4cd15ae09a1217c317d4))
|
|
15
|
+
|
|
16
|
+
## [13.1.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v13.0.0...pi-subagents-v13.1.0) (2026-05-30)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Features
|
|
20
|
+
|
|
21
|
+
* add createSubagentSession factory ([62c319d](https://github.com/gotgenes/pi-packages/commit/62c319d6703a6f58a829f372b609daea36170987))
|
|
22
|
+
* add SubagentSession with turn-loop and disposal behavior ([69f8f4b](https://github.com/gotgenes/pi-packages/commit/69f8f4bf78431be990a9eb6fbe592e59cc313912))
|
|
23
|
+
* dissolve the runner; Agent drives SubagentSession directly ([fbe71b0](https://github.com/gotgenes/pi-packages/commit/fbe71b02759551e60b4e22e96bb28299e444feb2))
|
|
24
|
+
|
|
8
25
|
## [13.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v12.1.0...pi-subagents-v13.0.0) (2026-05-30)
|
|
9
26
|
|
|
10
27
|
|
package/dist/public.d.ts
CHANGED
|
@@ -45,7 +45,7 @@ interface AgentInvocation {
|
|
|
45
45
|
* The child's working directory is supplied by a registered WorkspaceProvider
|
|
46
46
|
* (the workspace seam); with no provider the child runs in the parent cwd.
|
|
47
47
|
*
|
|
48
|
-
* Phase-specific collaborators (
|
|
48
|
+
* Phase-specific collaborators (subagentSession, notification) are attached
|
|
49
49
|
* after construction as lifecycle information becomes available.
|
|
50
50
|
*/
|
|
51
51
|
|
|
@@ -53,7 +53,8 @@ flowchart TB
|
|
|
53
53
|
direction TB
|
|
54
54
|
AgentManager["AgentManager<br/>(spawn, abort, collection)"]
|
|
55
55
|
ConcurrencyQueue["ConcurrencyQueue<br/>(scheduling, drain)"]
|
|
56
|
-
|
|
56
|
+
CreateSubagentSession["createSubagentSession<br/>(assembly factory)"]
|
|
57
|
+
SubagentSession["SubagentSession<br/>(turn loop, steer, dispose)"]
|
|
57
58
|
Agent["Agent<br/>(status, behavior: abort/steer/run lifecycle)"]
|
|
58
59
|
ParentSnapshot["ParentSnapshot<br/>(frozen parent state)"]
|
|
59
60
|
Workspace["workspace<br/>(provider seam: child cwd + teardown)"]
|
|
@@ -85,13 +86,15 @@ flowchart TB
|
|
|
85
86
|
end
|
|
86
87
|
|
|
87
88
|
AgentTool --> AgentManager
|
|
88
|
-
AgentManager -->
|
|
89
|
-
|
|
89
|
+
AgentManager --> Agent
|
|
90
|
+
Agent --> CreateSubagentSession & SubagentSession
|
|
91
|
+
CreateSubagentSession --> SubagentSession
|
|
92
|
+
CreateSubagentSession --> SessionConfig
|
|
90
93
|
SessionConfig --> AgentTypeRegistry
|
|
91
94
|
SessionConfig --> Prompts & Env
|
|
92
95
|
AgentTypeRegistry --> DefaultAgents & CustomAgents
|
|
93
|
-
RecordObserver -.->|subscribes|
|
|
94
|
-
UIObserver -.->|subscribes|
|
|
96
|
+
RecordObserver -.->|subscribes| SubagentSession
|
|
97
|
+
UIObserver -.->|subscribes| SubagentSession
|
|
95
98
|
Widget -.->|polls| AgentManager
|
|
96
99
|
```
|
|
97
100
|
|
|
@@ -108,7 +111,7 @@ classDiagram
|
|
|
108
111
|
+error?: string
|
|
109
112
|
+toolUses: number
|
|
110
113
|
+lifetimeUsage: LifetimeUsage
|
|
111
|
-
+
|
|
114
|
+
+subagentSession?: SubagentSession
|
|
112
115
|
+notification?: NotificationState
|
|
113
116
|
+markRunning()
|
|
114
117
|
+markCompleted()
|
|
@@ -120,14 +123,18 @@ classDiagram
|
|
|
120
123
|
+run()
|
|
121
124
|
+resume(prompt, signal)
|
|
122
125
|
+abort(): boolean
|
|
123
|
-
+
|
|
124
|
-
+
|
|
126
|
+
+steer(message): Promise<boolean>
|
|
127
|
+
+isSessionReady(): boolean
|
|
128
|
+
+getConversation(): string | undefined
|
|
129
|
+
+getContextPercent(): number | null
|
|
130
|
+
+subscribeToUpdates(fn): unsub | undefined
|
|
131
|
+
+messages: readonly unknown[]
|
|
125
132
|
+completeRun(result)
|
|
126
133
|
+failRun(err)
|
|
134
|
+
+disposeSession()
|
|
127
135
|
+wireSignal(signal, onAbort)
|
|
128
136
|
+attachObserver(unsub)
|
|
129
137
|
+releaseListeners()
|
|
130
|
-
+setOnRunFinished(fn)
|
|
131
138
|
}
|
|
132
139
|
|
|
133
140
|
class AgentManager {
|
|
@@ -210,24 +217,31 @@ sequenceDiagram
|
|
|
210
217
|
participant Tool as subagent tool
|
|
211
218
|
participant Spawn as spawn-config
|
|
212
219
|
participant Mgr as AgentManager
|
|
213
|
-
participant
|
|
220
|
+
participant Ag as Agent
|
|
221
|
+
participant Factory as createSubagentSession
|
|
214
222
|
participant Asm as assembleSessionConfig
|
|
223
|
+
participant Sub as SubagentSession
|
|
215
224
|
participant Child as Child session
|
|
216
225
|
|
|
217
226
|
LLM->>Tool: subagent(type, prompt, ...)
|
|
218
227
|
Tool->>Spawn: resolveSpawnConfig(params)
|
|
219
228
|
Spawn-->>Tool: ResolvedSpawnConfig
|
|
220
229
|
Tool->>Mgr: spawn(snapshot, type, prompt, config)
|
|
221
|
-
Mgr->>
|
|
222
|
-
|
|
223
|
-
Asm
|
|
224
|
-
|
|
225
|
-
Child
|
|
226
|
-
|
|
227
|
-
Note over
|
|
228
|
-
|
|
230
|
+
Mgr->>Ag: run()
|
|
231
|
+
Ag->>Factory: createSubagentSession(params, deps)
|
|
232
|
+
Factory->>Asm: assembleSessionConfig(type, ctx, opts, env, registry, io)
|
|
233
|
+
Asm-->>Factory: SessionConfig
|
|
234
|
+
Factory->>Child: create session + bind extensions
|
|
235
|
+
Factory-->>Ag: SubagentSession (born complete)
|
|
236
|
+
Note over Ag: agent-observer + ui-observer subscribe to session events
|
|
237
|
+
Ag->>Sub: runTurnLoop(prompt, opts)
|
|
238
|
+
Sub->>Child: prompt + drive turn loop
|
|
239
|
+
Child-->>Sub: result text
|
|
240
|
+
Sub-->>Ag: TurnLoopResult
|
|
241
|
+
Ag-->>Mgr: update Agent
|
|
229
242
|
Mgr-->>Tool: Agent
|
|
230
243
|
Tool-->>LLM: formatted result
|
|
244
|
+
Note over Mgr: disposeSession() fires `disposed` at cleanup (resume-detectable)
|
|
231
245
|
```
|
|
232
246
|
|
|
233
247
|
## Module organization
|
|
@@ -257,17 +271,19 @@ src/
|
|
|
257
271
|
│ ├── prompts.ts system prompt building
|
|
258
272
|
│ ├── content-items.ts shared message content parsing (tool-call names, assistant content)
|
|
259
273
|
│ ├── context.ts parent conversation extraction
|
|
274
|
+
│ ├── conversation.ts render a session's messages as formatted text
|
|
260
275
|
│ ├── env.ts git/platform detection
|
|
261
276
|
│ ├── model-resolver.ts fuzzy model name resolution
|
|
262
277
|
│ └── session-dir.ts session directory derivation
|
|
263
278
|
│
|
|
264
279
|
├── lifecycle/ agent execution and state tracking
|
|
265
280
|
│ ├── agent-manager.ts collection manager + observer wiring
|
|
266
|
-
│ ├──
|
|
281
|
+
│ ├── create-subagent-session.ts assembly factory: session creation, binding, tool filtering
|
|
282
|
+
│ ├── subagent-session.ts born-complete child session: turn loop, steer, dispose
|
|
283
|
+
│ ├── turn-limits.ts normalizeMaxTurns (turn-count policy)
|
|
267
284
|
│ ├── agent.ts owns full execution lifecycle (run, abort, steer, workspace)
|
|
268
285
|
│ ├── concurrency-queue.ts background agent scheduling with configurable concurrency limit
|
|
269
286
|
│ ├── parent-snapshot.ts immutable spawn-time parent state
|
|
270
|
-
│ ├── execution-state.ts session/output phase state
|
|
271
287
|
│ ├── child-lifecycle.ts child-execution lifecycle event publisher
|
|
272
288
|
│ ├── workspace.ts workspace provider seam (generative extension surface)
|
|
273
289
|
│ └── usage.ts token usage tracking
|
|
@@ -318,7 +334,7 @@ Record statistics (tool uses, token usage, compaction counts) are updated by `re
|
|
|
318
334
|
UI streaming (active tools, response text, turn counts) is handled by `ui/ui-observer.ts`, which subscribes to the same session events independently.
|
|
319
335
|
Neither observer wraps or forwards the other — both subscribe directly to the session.
|
|
320
336
|
|
|
321
|
-
The widget reads agent state by polling a shared `Map<string, AgentActivityTracker>` on `SubagentRuntime` every 80 ms. The conversation viewer subscribes
|
|
337
|
+
The widget reads agent state by polling a shared `Map<string, AgentActivityTracker>` on `SubagentRuntime` every 80 ms. The conversation viewer subscribes to session events via `Agent.subscribeToUpdates()` and reads messages via `Agent.messages` — no direct `AgentSession` reference (#277).
|
|
322
338
|
|
|
323
339
|
## Cross-extension architecture
|
|
324
340
|
|
|
@@ -327,7 +343,7 @@ flowchart TD
|
|
|
327
343
|
subgraph core["@gotgenes/pi-subagents"]
|
|
328
344
|
direction TB
|
|
329
345
|
exports["SubagentsService API<br/>publish / getSubagentsService<br/>SubagentRecord, SubagentStatus"]
|
|
330
|
-
engine["Tools: subagent, get_subagent_result,<br/>steer_subagent<br/>AgentManager,
|
|
346
|
+
engine["Tools: subagent, get_subagent_result,<br/>steer_subagent<br/>AgentManager, createSubagentSession, SubagentSession"]
|
|
331
347
|
ui_int["Internal UI: widget, viewer,<br/>/agents menu"]
|
|
332
348
|
end
|
|
333
349
|
|
|
@@ -344,13 +360,14 @@ They declare this package as an optional peer dependency and use dynamic import
|
|
|
344
360
|
- The three tools: `subagent` (née `Agent`), `get_subagent_result`, `steer_subagent`.
|
|
345
361
|
- `AgentManager` — spawn, abort, resume, collection management, observer wiring.
|
|
346
362
|
- `ConcurrencyQueue` — background agent scheduling with configurable concurrency limit.
|
|
347
|
-
- `
|
|
363
|
+
- `createSubagentSession` — assembly factory: session creation and extension binding; returns a born-complete `SubagentSession`.
|
|
364
|
+
- `SubagentSession` — the born-complete child session: drives the turn loop (`runTurnLoop`/`resumeTurnLoop`), steers, and disposes (firing `disposed` at true session disposal, so resume executions are registry-detected).
|
|
348
365
|
- `child-lifecycle` — publishes the child-execution lifecycle (`spawning`, `session-created` before `bindExtensions()`, `completed`, `disposed`) on `pi.events`.
|
|
349
366
|
Reactive consumers subscribe: `@gotgenes/pi-permission-system` registers each child session on `session-created` and unregisters it on `disposed`.
|
|
350
367
|
This replaced the former outbound `permission-bridge` (#261, ADR 0002) — the core no longer looks up a named consumer.
|
|
351
368
|
- `workspace` — the single generative seam (#262, ADR 0002): a registered `WorkspaceProvider` supplies a child's cwd plus bracketed `dispose()` at run-start.
|
|
352
369
|
With no provider, children run in the parent cwd (default unchanged); the git worktree strategy lives behind this seam in `@gotgenes/pi-subagents-worktrees` (#263, the seam's first consumer).
|
|
353
|
-
- `session-config` — pure configuration assembler (
|
|
370
|
+
- `session-config` — pure configuration assembler (called by `createSubagentSession`).
|
|
354
371
|
- `SubagentRuntime` — session-scoped state bag with methods.
|
|
355
372
|
- `ParentSnapshot` — immutable snapshot of parent session state, captured once at spawn time.
|
|
356
373
|
- `record-observer` — session-event observer that updates record statistics without callback threading.
|
|
@@ -532,20 +549,21 @@ This is achieved across phases: Phase 14 (strip policy), Phase 16 (invert depend
|
|
|
532
549
|
These interfaces carry hidden dependencies that obscure true coupling.
|
|
533
550
|
Bags with 10+ fields are the highest priority for decomposition.
|
|
534
551
|
|
|
535
|
-
| Interface
|
|
536
|
-
|
|
|
537
|
-
| `ResolvedSpawnConfig`
|
|
538
|
-
| `AgentSpawnConfig`
|
|
539
|
-
| `
|
|
540
|
-
| `
|
|
541
|
-
| `
|
|
542
|
-
| `
|
|
543
|
-
| `
|
|
544
|
-
| `
|
|
545
|
-
| `
|
|
546
|
-
| `
|
|
547
|
-
| `
|
|
548
|
-
| `
|
|
552
|
+
| Interface | Fields | Consumers | Severity |
|
|
553
|
+
| ----------------------------- | ------------------------------------------------------------ | ------------------------------------------------- | --------- |
|
|
554
|
+
| `ResolvedSpawnConfig` | 3 nested | foreground-runner, background-spawner, agent-tool | ✓ done |
|
|
555
|
+
| `AgentSpawnConfig` | 13 → 13 (ParentSessionInfo nested) | agent-manager (internal) | ✓ done |
|
|
556
|
+
| `CreateSubagentSessionParams` | 6 (snapshot, type, cwd, parentSession, model, thinkingLevel) | create-subagent-session | ✓ done |
|
|
557
|
+
| `TurnLoopOptions` | 4 (maxTurns, defaultMaxTurns, graceTurns, signal) | subagent-session | ✓ done |
|
|
558
|
+
| `SessionConfig` | 6 (flat fields; extensions/noSkills/extras removed in #264) | session-config (output of assembler) | ✓ done |
|
|
559
|
+
| `NotificationDetails` | 10 | notification | Low (DTO) |
|
|
560
|
+
| `ResourceLoaderOptions` | 10 | create-subagent-session (SDK bridge) | Low (SDK) |
|
|
561
|
+
| `SubagentSessionIO` | split → `EnvironmentIO` (3) + `SessionFactoryIO` (5+1) | create-subagent-session | ✓ done |
|
|
562
|
+
| `CreateSessionOptions` | 9 | create-subagent-session (SDK bridge) | Low (SDK) |
|
|
563
|
+
| `AgentToolDeps` | 8 | agent-tool | ✓ done |
|
|
564
|
+
| `AgentMenuDeps` | 8 | agent-menu | ✓ done |
|
|
565
|
+
| `ConversationViewerOptions` | 8 | conversation-viewer | Low |
|
|
566
|
+
| `AgentInit` | 8 | agent | Low |
|
|
549
567
|
|
|
550
568
|
### Complexity hotspots
|
|
551
569
|
|
|
@@ -575,6 +593,22 @@ The prior clone group between `agent-runner.ts` and `message-formatters.ts` was
|
|
|
575
593
|
The 20-line clone group between `agent-config-editor.ts` and `agent-creation-wizard.ts` was resolved in #217 — extracted into `ui/agent-file-writer.ts` (`writeAgentFile`).
|
|
576
594
|
One 11-line internal clone group remains within `agent-config-editor.ts` (lines 135–145 / 173–183).
|
|
577
595
|
|
|
596
|
+
### Session encapsulation debt (Law of Demeter) — resolved by [#277] ✔️
|
|
597
|
+
|
|
598
|
+
All consumer reach-throughs to the raw SDK `AgentSession` via `Agent.session` have been eliminated.
|
|
599
|
+
`Agent.session` is removed; `SubagentSession.session` is marked `@internal` (lifecycle use only).
|
|
600
|
+
The intent-revealing replacements added by [#277]:
|
|
601
|
+
|
|
602
|
+
| Reach-through | Sites | Replacement |
|
|
603
|
+
| ---------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------ |
|
|
604
|
+
| Steer buffer-or-deliver (was duplicated) | `service-adapter.ts`, `steer-tool.ts` | `Agent.steer(message)` |
|
|
605
|
+
| Conversation viewing | `get-result-tool.ts`, `agent-menu.ts`, `conversation-viewer.ts` | `Agent.getConversation()` / `Agent.messages` |
|
|
606
|
+
| Session-readiness guard | `agent-tool.ts`, `agent-manager.ts` | `Agent.isSessionReady()` |
|
|
607
|
+
| Context-window stats | `steer-tool.ts`, `get-result-tool.ts`, `notification.ts`, `conversation-viewer.ts` | `Agent.getContextPercent()` |
|
|
608
|
+
| Live updates (subscription) | `conversation-viewer.ts` | `Agent.subscribeToUpdates(fn)` |
|
|
609
|
+
| Observer callback session param | `background-spawner.ts`, `foreground-runner.ts` | `agent.subagentSession` (narrowed callback) |
|
|
610
|
+
| Session disposal | `agent-manager.ts` | `SubagentSession.dispose()` — resolved by [#265] |
|
|
611
|
+
|
|
578
612
|
### Proposed bag decompositions
|
|
579
613
|
|
|
580
614
|
#### ResolvedSpawnConfig (15 fields → 3 value objects)
|
|
@@ -785,11 +819,13 @@ The `skills` curation axis collapsed symmetrically with `extensions`: `AgentConf
|
|
|
785
819
|
- Depended on: Step 1 (deny-at-use over events).
|
|
786
820
|
- Outcome: the `isolated`/`extensions`/`noSkills`/`skills` axis is gone; the guard is unconditional.
|
|
787
821
|
|
|
788
|
-
#### Step 5: Born-complete child execution; dissolve the runner — [#265]
|
|
822
|
+
#### Step 5: Born-complete child execution; dissolve the runner — [#265] ✅ Delivered
|
|
789
823
|
|
|
790
|
-
|
|
791
|
-
`
|
|
792
|
-
|
|
824
|
+
`createSubagentSession()` is an assembly factory that returns a born-complete `SubagentSession` (session created, extensions bound, recursion guard applied).
|
|
825
|
+
`SubagentSession` owns turn driving (`runTurnLoop`/`resumeTurnLoop`), steering, and disposal.
|
|
826
|
+
`Agent.run()` is coordination, not assembly; `runAgent` / `resumeAgent` / `ConcreteAgentRunner` / `AgentRunner` / `RunOptions` / `RunResult` / `ExecutionState` dissolved.
|
|
827
|
+
`getAgentConversation()` relocated to `session/conversation.ts`; `normalizeMaxTurns()` to `lifecycle/turn-limits.ts`.
|
|
828
|
+
`disposed` now fires at true session disposal (cleanup), so resume executions are registry-detected (closing the gap deferred from #261).
|
|
793
829
|
|
|
794
830
|
- Depends on: Steps 2–4.
|
|
795
831
|
- Outcome: the "runner" concept is gone; `Agent.run()` is coordination, not assembly — the structural goal of the abandoned collaborator plan, reached cleanly.
|
|
@@ -885,7 +921,7 @@ If they land, upstream gains the peer-dep fix and the two RepOne patches.
|
|
|
885
921
|
This fork continues independently regardless.
|
|
886
922
|
|
|
887
923
|
Upstream fixes and ideas are cherry-picked when they align with this fork's scope.
|
|
888
|
-
The upstream test suite is run periodically as a regression canary for the
|
|
924
|
+
The upstream test suite is run periodically as a regression canary for the session assembly core.
|
|
889
925
|
|
|
890
926
|
[earendil-works/pi#4207]: https://github.com/earendil-works/pi/issues/4207
|
|
891
927
|
[gotgenes/pi-packages]: https://github.com/gotgenes/pi-packages
|
|
@@ -915,3 +951,4 @@ The upstream test suite is run periodically as a regression canary for the agent
|
|
|
915
951
|
[#263]: https://github.com/gotgenes/pi-packages/issues/263
|
|
916
952
|
[#264]: https://github.com/gotgenes/pi-packages/issues/264
|
|
917
953
|
[#265]: https://github.com/gotgenes/pi-packages/issues/265
|
|
954
|
+
[#277]: https://github.com/gotgenes/pi-packages/issues/277
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 265
|
|
3
|
+
issue_title: "Born-complete child execution; dissolve the runner"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Born-complete `SubagentSession`; dissolve the runner
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
Phase 16, Step 5 of ADR 0002.
|
|
11
|
+
Today a subagent run is assembled by a monolithic `runAgent()` (the "runner") in `src/lifecycle/agent-runner.ts`: it creates the child session, binds extensions, drives the turn loop, collects the result, and emits the child-execution lifecycle events.
|
|
12
|
+
`Agent` then sequences workspace teardown and status transitions around it through an injected `AgentRunner` interface.
|
|
13
|
+
With the cwd now resolved through the `WorkspaceProvider` seam (Step 2) and worktrees evicted to a sibling package (Step 3), there is nothing left for a separate runner layer to assemble.
|
|
14
|
+
|
|
15
|
+
Child-session creation should instead produce a *born-complete* value object — a `SubagentSession` that wraps one SDK `AgentSession` plus its turn-driving and teardown — and the runner concept should dissolve.
|
|
16
|
+
`Agent.run()` becomes coordination, not assembly.
|
|
17
|
+
|
|
18
|
+
This step also closes the determinism gap deferred from #261: today `session-created`/`disposed` bracket only the first turn loop ("executing now"), so a resume — a *second* turn loop on the same session — fires no events and the permission system falls back to its filesystem-path heuristic.
|
|
19
|
+
Moving unregistration to true session disposal shifts the registry from "executing now" to "exists", and resume executions become registry-detected for free.
|
|
20
|
+
|
|
21
|
+
## Goals
|
|
22
|
+
|
|
23
|
+
- Introduce a born-complete `SubagentSession` (`{ session, outputFile?, dispose() }` plus turn-driving behavior) produced by a `createSubagentSession()` factory.
|
|
24
|
+
- `Agent` owns session interaction directly: it tells `SubagentSession` to run/resume turn loops, steer, and dispose — no injected runner.
|
|
25
|
+
- Dissolve the runner: remove `runAgent`, `resumeAgent`, `ConcreteAgentRunner`, `AgentRunner`, `RunOptions`, `RunResult`, `ResumeOptions`.
|
|
26
|
+
- Retain `getAgentConversation()` and `normalizeMaxTurns()` by relocating them to focused homes.
|
|
27
|
+
- Move child-session registration to creation and unregistration to true session disposal, so resume executions are registry-detected (the `disposed` event fires at cleanup, not at run-completion).
|
|
28
|
+
- No two-phase `setup()` / late-bound `cwd`: the factory receives a resolved `cwd` value and builds a session that is fully usable the moment it is returned.
|
|
29
|
+
|
|
30
|
+
This is an internal structural refactor.
|
|
31
|
+
The public `SubagentsService` surface (`spawn`, `resume`, `steer`, `registerWorkspaceProvider`) is unchanged, so the change is **non-breaking** for consumers — no `feat!:`.
|
|
32
|
+
The one externally observable change is positive: the permission registry now detects resume executions.
|
|
33
|
+
|
|
34
|
+
## Non-Goals
|
|
35
|
+
|
|
36
|
+
- Renaming the `Agent` class to `Subagent` — deferred to its own follow-up issue (mechanical, ~19 files, orthogonal to this structural change).
|
|
37
|
+
In this issue the class stays `Agent`; only the new object is named `SubagentSession` (consistent with the existing `SubagentType` / `SubagentSessionDir` naming family).
|
|
38
|
+
- Retiring the remaining `agent.session` reach-throughs (steer tool/service buffer-or-deliver, conversation viewing, resume-readiness guards) — tracked in #277.
|
|
39
|
+
`SubagentSession` exposes a `.session` accessor so the existing observer wiring and consumers keep working unchanged; #277 retires those.
|
|
40
|
+
- A resume-aware workspace lifecycle (re-establishing a worktree before a resume).
|
|
41
|
+
A worktree's natural lifetime is one turn loop, not the session; worktree + resume is already degenerate today and stays so.
|
|
42
|
+
See Open Questions.
|
|
43
|
+
- UI extraction (Phase 17).
|
|
44
|
+
|
|
45
|
+
## Background
|
|
46
|
+
|
|
47
|
+
Relevant modules:
|
|
48
|
+
|
|
49
|
+
- `src/lifecycle/agent-runner.ts` — the runner being dissolved.
|
|
50
|
+
`runAgent()` does assembly + turn loop + result collection + lifecycle events; `resumeAgent()` re-prompts an existing session; `ConcreteAgentRunner` wraps both behind the `AgentRunner` interface injected into `AgentManager`.
|
|
51
|
+
Also currently the home of the retained `getAgentConversation()` and `normalizeMaxTurns()`, plus the SDK-bridge IO interfaces (`EnvironmentIO`, `SessionFactoryIO`, `RunnerIO`, `ResourceLoaderOptions`, `CreateSessionOptions`) and the recursion guard `filterActiveTools()`.
|
|
52
|
+
- `src/lifecycle/agent.ts` — `Agent` holds `runner`, `execution: ExecutionState`, workspace prepare/dispose, status transitions, steer buffering, and `run()`/`resume()`.
|
|
53
|
+
- `src/lifecycle/execution-state.ts` — `ExecutionState { session, outputFile }`, attached to `Agent` on session creation.
|
|
54
|
+
Subsumed by `SubagentSession`.
|
|
55
|
+
- `src/lifecycle/agent-manager.ts` — constructs `Agent`s with the injected `runner`; disposes sessions in `removeRecord`/`dispose`/`cleanup`.
|
|
56
|
+
- `src/lifecycle/child-lifecycle.ts` — the `ChildLifecyclePublisher` (`spawning`, `sessionCreated`, `completed`, `disposed`).
|
|
57
|
+
Unchanged here; only *when* `disposed` fires moves.
|
|
58
|
+
- `src/lifecycle/workspace.ts` — the abstract `WorkspaceProvider`/`Workspace` seam.
|
|
59
|
+
The core has zero git/worktree knowledge; all worktree mechanics live in `@gotgenes/pi-subagents-worktrees`, untouched by this issue.
|
|
60
|
+
- `src/session/session-config.ts` — `assembleSessionConfig()`, the pure assembler `runAgent()` calls.
|
|
61
|
+
Unchanged; the factory calls it instead.
|
|
62
|
+
|
|
63
|
+
Registry semantics (the determinism gap): The permission system (`pi-permission-system/src/subagent-lifecycle-events.ts`) registers on `subagents:child:session-created` and unregisters on `subagents:child:disposed`, keyed by `sessionDir`.
|
|
64
|
+
That subscription code does **not** change.
|
|
65
|
+
Today `disposed` fires in `runAgent`'s `finally` (end of the first turn loop), so the registry entry is gone before any resume.
|
|
66
|
+
After this change `disposed` fires when the session is truly disposed (`AgentManager` cleanup / session switch / shutdown), so the entry spans the session's whole existence — every turn loop, including resumes.
|
|
67
|
+
|
|
68
|
+
The two-lifetimes fact (why Option A): A workspace's natural lifetime is **one turn loop** (the run): the `WorkspaceProvider`'s `dispose()` returns a `resultAddendum` that is folded into the run's result, so it must be called at run-completion.
|
|
69
|
+
A session's lifetime spans **many turn loops** (run + resumes) and ends at cleanup.
|
|
70
|
+
Different clocks ⇒ different resources.
|
|
71
|
+
The workspace therefore stays a separate `Agent`-sequenced resource (prepare at run-start, dispose at run-completion, exactly as today); only the session becomes the born-complete object.
|
|
72
|
+
|
|
73
|
+
AGENTS.md constraints that apply:
|
|
74
|
+
|
|
75
|
+
- Ship-source package with a public type bundle (ADR 0003): none of the dissolved types (`RunOptions`, `RunResult`, `AgentRunner`) are part of `service.ts`, so `public.d.ts` is unaffected.
|
|
76
|
+
Run `pnpm run verify:public-types` is **not** required (no public-surface change), but `pnpm run check` is.
|
|
77
|
+
- fallow dead-code: new exports (`SubagentSession`, `createSubagentSession`) must have a production consumer by the end of the work; transient intermediate commits where they are consumed only by tests are acceptable because fallow runs at pre-completion, against the final state.
|
|
78
|
+
- `#src/` path-alias imports only; ES2024 target.
|
|
79
|
+
|
|
80
|
+
## Design Overview
|
|
81
|
+
|
|
82
|
+
Two new lifecycle modules replace the runner.
|
|
83
|
+
|
|
84
|
+
### `SubagentSession` — the born-complete object (owns runtime behavior)
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
/** Outcome of one turn loop. */
|
|
88
|
+
export interface TurnLoopResult {
|
|
89
|
+
responseText: string;
|
|
90
|
+
aborted: boolean; // hard-aborted (max turns + grace exceeded)
|
|
91
|
+
steered: boolean; // soft-limit steer fired, finished in time
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface TurnLoopOptions {
|
|
95
|
+
maxTurns?: number;
|
|
96
|
+
graceTurns?: number;
|
|
97
|
+
signal?: AbortSignal;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One child AgentSession plus its turn-driving and teardown — born complete.
|
|
102
|
+
* Construction (createSubagentSession) yields a fully usable instance: the
|
|
103
|
+
* session exists, extensions are bound, the recursion guard is applied.
|
|
104
|
+
*/
|
|
105
|
+
export class SubagentSession {
|
|
106
|
+
constructor(
|
|
107
|
+
private readonly _session: AgentSession,
|
|
108
|
+
private readonly meta: {
|
|
109
|
+
outputFile: string | undefined;
|
|
110
|
+
sessionDir: string;
|
|
111
|
+
agentName: string;
|
|
112
|
+
lifecycle: ChildLifecyclePublisher;
|
|
113
|
+
},
|
|
114
|
+
) {}
|
|
115
|
+
|
|
116
|
+
/** Wrapped session — exposed for observer wiring + consumers; retired by #277. */
|
|
117
|
+
get session(): AgentSession { return this._session; }
|
|
118
|
+
get outputFile(): string | undefined { return this.meta.outputFile; }
|
|
119
|
+
|
|
120
|
+
/** Drive the initial run's turn loop; emits `completed` on success. */
|
|
121
|
+
runTurnLoop(prompt: string, opts: TurnLoopOptions): Promise<TurnLoopResult>;
|
|
122
|
+
|
|
123
|
+
/** Re-prompt the same session (resume); does not emit `completed`. */
|
|
124
|
+
resumeTurnLoop(prompt: string, signal?: AbortSignal): Promise<string>;
|
|
125
|
+
|
|
126
|
+
/** Deliver a steer to the live session. */
|
|
127
|
+
steer(message: string): Promise<void>;
|
|
128
|
+
|
|
129
|
+
/** Tear down: session.dispose() + emit `disposed` (registry unregister). */
|
|
130
|
+
dispose(): void;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`runTurnLoop` / `resumeTurnLoop` absorb the turn-counting, soft/hard-limit steer+abort, abort-signal forwarding, and response-text collection currently inside `runAgent`/`resumeAgent`, plus the private helpers `collectResponseText`, `getLastAssistantText`, `forwardAbortSignal`.
|
|
135
|
+
Placing them on `SubagentSession` (the object that owns the `AgentSession`) — rather than reaching through `subagentSession.session` from `Agent` — keeps the design free of the Law-of-Demeter violation that an inline-on-`Agent` or free-function approach would introduce.
|
|
136
|
+
|
|
137
|
+
### `createSubagentSession` — the assembly factory
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
export interface SubagentSessionDeps { // (was RunnerDeps)
|
|
141
|
+
io: SubagentSessionIO; // EnvironmentIO & SessionFactoryIO (moved verbatim)
|
|
142
|
+
exec: ShellExec;
|
|
143
|
+
registry: AgentConfigLookup;
|
|
144
|
+
lifecycle: ChildLifecyclePublisher;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface CreateSubagentSessionParams {
|
|
148
|
+
snapshot: ParentSnapshot;
|
|
149
|
+
type: SubagentType;
|
|
150
|
+
cwd?: string; // resolved workspace cwd; undefined → parent cwd
|
|
151
|
+
parentSession?: ParentSessionInfo;
|
|
152
|
+
model?: Model<any>;
|
|
153
|
+
thinkingLevel?: ThinkingLevel;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function createSubagentSession(
|
|
157
|
+
params: CreateSubagentSessionParams,
|
|
158
|
+
deps: SubagentSessionDeps,
|
|
159
|
+
): Promise<SubagentSession>;
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Body (the assembly portion of `runAgent`, unchanged in substance):
|
|
163
|
+
|
|
164
|
+
1. `lifecycle.spawning(...)`.
|
|
165
|
+
2. `detectEnv(exec, cwd ?? snapshot.cwd)` → `assembleSessionConfig(...)`.
|
|
166
|
+
3. `createResourceLoader` → `reload()`; `createSessionManager` → `newSession(...)`; `createSession(...)`.
|
|
167
|
+
4. Construct `SubagentSession` (session, outputFile, sessionDir, agentName, lifecycle).
|
|
168
|
+
5. `lifecycle.sessionCreated({ sessionDir, agentName, parentSessionId })` — synchronous, before `bindExtensions()` (the pre-bind ordering the permission registry depends on).
|
|
169
|
+
6. `try { await session.bindExtensions({}); applyRecursionGuard(session); } catch (err) { subagentSession.dispose(); throw err; }` — if binding fails *after* `sessionCreated`, dispose (emit `disposed` + `session.dispose()`) before rethrowing, so registration is never leaked.
|
|
170
|
+
7. Return the `SubagentSession`.
|
|
171
|
+
|
|
172
|
+
Note the factory takes a resolved `cwd` value, never the `WorkspaceProvider`.
|
|
173
|
+
The provider stays inside `Agent` (Option A): threading the provider + its prepare-context through the factory just to call `prepare()` would be a parameter-relay smell; `cwd` is a value the factory consumes directly (`detectEnv`, `assembleSessionConfig`, `createSession`).
|
|
174
|
+
|
|
175
|
+
### Lifecycle-event ownership
|
|
176
|
+
|
|
177
|
+
| Event | Emitted by | When |
|
|
178
|
+
| ----------------- | ----------------------------- | --------------------------------------------------------------- |
|
|
179
|
+
| `spawning` | `createSubagentSession` | run start, before session creation |
|
|
180
|
+
| `session-created` | `createSubagentSession` | after creation, before `bindExtensions()` |
|
|
181
|
+
| `completed` | `SubagentSession.runTurnLoop` | end of the run's turn loop (success path) |
|
|
182
|
+
| `disposed` | `SubagentSession.dispose` | true session disposal (cleanup) — **moved** from run-completion |
|
|
183
|
+
|
|
184
|
+
`resume` neither creates a session nor emits `completed`/`disposed` — it re-prompts the live session, preserving today's behavior.
|
|
185
|
+
|
|
186
|
+
### `Agent.run()` — coordination, not assembly (consumer call-site sketch)
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
async run(): Promise<void> {
|
|
190
|
+
this.markRunning(Date.now());
|
|
191
|
+
this.observer?.onStarted?.(this);
|
|
192
|
+
this.wireSignal(this._signal, () => this.abort());
|
|
193
|
+
|
|
194
|
+
let cwd: string | undefined;
|
|
195
|
+
try { // workspace prepare — unchanged
|
|
196
|
+
const provider = this._getWorkspaceProvider?.();
|
|
197
|
+
if (provider) { this._workspace = await provider.prepare({ ... }); cwd = this._workspace?.cwd; }
|
|
198
|
+
} catch (err) { this.markError(err); this.releaseListeners(); this.observer?.onRunFinished?.(this); return; }
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
this.subagentSession = await this._createSubagentSession({
|
|
202
|
+
snapshot: this._snapshot!, type: this.type, cwd,
|
|
203
|
+
parentSession: this._parentSession, model: this._model, thinkingLevel: this._thinkingLevel,
|
|
204
|
+
});
|
|
205
|
+
} catch (err) { this.failRun(err); return; } // factory already disposed its own session
|
|
206
|
+
|
|
207
|
+
this.flushPendingSteers(); // → this.subagentSession.steer(msg)
|
|
208
|
+
this.attachObserver(subscribeAgentObserver(this.subagentSession.session, this, { ... }));
|
|
209
|
+
this.observer?.onSessionCreated?.(this, this.subagentSession.session);
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const result = await this.subagentSession.runTurnLoop(this._prompt!, {
|
|
213
|
+
maxTurns: this._maxTurns, graceTurns: cfg?.graceTurns, signal: this.abortController.signal,
|
|
214
|
+
// (maxTurns resolution stays: per-call ?? agentMaxTurns ?? defaultMaxTurns, via normalizeMaxTurns)
|
|
215
|
+
});
|
|
216
|
+
this.completeRun(result); // workspace teardown + status; no execution rebuild
|
|
217
|
+
} catch (err) { this.failRun(err); }
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`Agent.resume()` becomes `await this.subagentSession!.resumeTurnLoop(prompt, signal)` wrapped in the existing reset/observer/markCompleted/markError/releaseListeners scaffolding — no runner.
|
|
222
|
+
|
|
223
|
+
`completeRun(result: TurnLoopResult)` drops the `session`/`sessionFile` fields (the `SubagentSession` already holds them) and no longer rebuilds `execution`; it does workspace teardown (folding `resultAddendum`) and the status transition, exactly as today.
|
|
224
|
+
|
|
225
|
+
`Agent.execution: ExecutionState` becomes `Agent.subagentSession?: SubagentSession`; the `session` / `outputFile` getters delegate to it.
|
|
226
|
+
A new `Agent.disposeSession()` calls `this.subagentSession?.dispose()`, invoked by `AgentManager` where `record.session?.dispose?.()` is called today.
|
|
227
|
+
|
|
228
|
+
The `subscribeAgentObserver(subagentSession.session, ...)` wiring and `observer.onSessionCreated(agent, session)` still pass the raw `AgentSession`; these are the observer reach-throughs explicitly deferred to #277.
|
|
229
|
+
The `Agent.session` getter likewise still exposes the wrapped session for the external consumers (steer tool, get-result, menu) that #277 retires.
|
|
230
|
+
|
|
231
|
+
### Edge cases
|
|
232
|
+
|
|
233
|
+
- Creation failure after `session-created`: the factory disposes (emit `disposed` + `session.dispose()`) before rethrowing → no registry leak; symmetric with the success path.
|
|
234
|
+
- Turn-loop throw: `SubagentSession` exists and stays registered; `Agent.failRun` runs (workspace teardown + error status); `disposed` fires later at cleanup — symmetric register/unregister regardless of run success or failure.
|
|
235
|
+
- Graceful abort (max turns + grace): `runTurnLoop` returns `{ aborted: true }` and emits `completed` (matching today); a *thrown* error skips `completed`.
|
|
236
|
+
|
|
237
|
+
## Module-Level Changes
|
|
238
|
+
|
|
239
|
+
New:
|
|
240
|
+
|
|
241
|
+
- `src/lifecycle/subagent-session.ts` — `SubagentSession` class, `TurnLoopResult`, `TurnLoopOptions`, and the private turn-loop helpers (`collectResponseText`, `getLastAssistantText`, `forwardAbortSignal`).
|
|
242
|
+
- `src/lifecycle/create-subagent-session.ts` — `createSubagentSession`, `SubagentSessionDeps`, `CreateSubagentSessionParams`, the SDK-bridge IO interfaces moved from `agent-runner.ts` (`EnvironmentIO`, `SessionFactoryIO`, `SubagentSessionIO`, `ResourceLoaderLike`, `SessionManagerLike`, `ResourceLoaderOptions`, `CreateSessionOptions`), and the recursion guard `applyRecursionGuard`/`filterActiveTools` + `EXCLUDED_TOOL_NAMES`.
|
|
243
|
+
- `src/lifecycle/turn-limits.ts` — `normalizeMaxTurns`.
|
|
244
|
+
- `src/session/conversation.ts` — `getAgentConversation` + `formatAttribution`.
|
|
245
|
+
|
|
246
|
+
Changed:
|
|
247
|
+
|
|
248
|
+
- `src/lifecycle/agent.ts` — drop `runner`/`AgentRunner`/`RunResult`/`ExecutionState`; add injected `createSubagentSession` factory dep; `execution` → `subagentSession`; rewrite `run()`/`resume()`; `completeRun(result: TurnLoopResult)`; add `disposeSession()`; `flushPendingSteers()` delegates to `subagentSession.steer`; update the "missing runner" guard messages.
|
|
249
|
+
- `src/lifecycle/agent-manager.ts` — `AgentManagerOptions.runner: AgentRunner` → `createSubagentSession: (params) => Promise<SubagentSession>`; pass it into each `Agent`; `removeRecord`/`dispose` call `record.disposeSession()` instead of `record.session?.dispose?.()`.
|
|
250
|
+
- `src/index.ts` — drop `ConcreteAgentRunner`/`RunnerDeps`; build `SubagentSessionDeps`; pass `createSubagentSession: (p) => createSubagentSession(p, deps)` to `AgentManager`.
|
|
251
|
+
- `src/tools/get-result-tool.ts` — import `getAgentConversation` from `#src/session/conversation`.
|
|
252
|
+
- `src/tools/spawn-config.ts` — import `normalizeMaxTurns` from `#src/lifecycle/turn-limits`.
|
|
253
|
+
- `src/settings.ts` — update the `normalizeMaxTurns()` doc-comment reference.
|
|
254
|
+
- `src/runtime.ts` — update the `RunConfig` doc comment that mentions `RunOptions`.
|
|
255
|
+
- `src/session/session-config.ts` — update doc comments referencing `runAgent()` → `createSubagentSession()`.
|
|
256
|
+
- `docs/architecture/architecture.md` — update the domain dependency diagram (drop `agent-runner` node, add `SubagentSession`/`createSubagentSession`), the execution-flow sequence diagram, the current-layout listing (lifecycle dir), the dependency-bag inventory rows (`RunOptions`, `RunnerIO`, `CreateSessionOptions`, `ResourceLoaderOptions` now belong to the factory module), and mark Step 5 delivered.
|
|
257
|
+
- `.pi/skills/package-pi-subagents/SKILL.md` — the "Lifecycle domain" table lists `agent-runner.ts`; update to the new modules.
|
|
258
|
+
|
|
259
|
+
Removed (final step):
|
|
260
|
+
|
|
261
|
+
- `src/lifecycle/agent-runner.ts` — `runAgent`, `resumeAgent`, `ConcreteAgentRunner`, `AgentRunner`, `RunOptions`, `RunResult`, `ResumeOptions`, `RunContext`, `RunnerDeps`, `RunnerIO` (all migrated or deleted).
|
|
262
|
+
- `src/lifecycle/execution-state.ts` — `ExecutionState` (subsumed by `SubagentSession`).
|
|
263
|
+
|
|
264
|
+
Symbol-removal sweep (grep before finalizing each removal step): `runAgent`, `resumeAgent`, `ConcreteAgentRunner`, `AgentRunner`, `RunResult`, `RunOptions`, `ResumeOptions`, `RunContext`, `RunnerDeps`, `RunnerIO`, `ExecutionState`, `execution-state`, `agent-runner`.
|
|
265
|
+
|
|
266
|
+
## Test Impact Analysis
|
|
267
|
+
|
|
268
|
+
New unit tests the extraction enables:
|
|
269
|
+
|
|
270
|
+
- `SubagentSession` in isolation — construct with a mock `AgentSession` and a `ChildLifecyclePublisher` mock; assert `runTurnLoop` turn-limit behavior (soft steer, hard abort, grace window), response capture, `completed` emission, `resumeTurnLoop` re-prompt, `steer` delegation, and `dispose` (session.dispose + `disposed`).
|
|
271
|
+
Previously these lived as `runAgent`/`resumeAgent` tests entangled with assembly.
|
|
272
|
+
- `createSubagentSession` — assembly + `spawning`/`session-created` ordering + dispose-on-creation-failure, with no turn-loop noise.
|
|
273
|
+
|
|
274
|
+
Tests that become redundant / simplified:
|
|
275
|
+
|
|
276
|
+
- `test/lifecycle/concrete-agent-runner.test.ts` — deleted; `ConcreteAgentRunner` is gone, its delegation coverage absorbed by the factory + `SubagentSession` tests.
|
|
277
|
+
- `Agent.run()` tests no longer re-drive turn events through a mock runner; they assert coordination against a stub `SubagentSession` whose `runTurnLoop` resolves to a canned `TurnLoopResult`.
|
|
278
|
+
|
|
279
|
+
Tests that must stay (genuinely exercise the layer):
|
|
280
|
+
|
|
281
|
+
- The turn-limit behavior tests (now retargeted from the runner to `SubagentSession.runTurnLoop`).
|
|
282
|
+
- The recursion-guard / extension-tool filtering tests (now `createSubagentSession`).
|
|
283
|
+
- The child-lifecycle ordering tests (now split across the factory and `SubagentSession`).
|
|
284
|
+
- The workspace prepare/dispose tests in `agent.test.ts` — unchanged (Option A leaves that path intact); only assertions that read `runner.run`'s args switch to reading the `createSubagentSession` factory params.
|
|
285
|
+
|
|
286
|
+
## TDD Order
|
|
287
|
+
|
|
288
|
+
Lift-and-shift: introduce the new modules alongside the runner, swap consumers atomically, delete the runner last.
|
|
289
|
+
Each step compiles and the suite passes; run `pnpm run check` after every step that touches a shared interface.
|
|
290
|
+
|
|
291
|
+
1. Extract `normalizeMaxTurns` → `src/lifecycle/turn-limits.ts`; update `agent-runner.ts` (internal use), `spawn-config.ts`, and the `settings.ts` comment; move `agent-runner-settings.test.ts` → `turn-limits.test.ts`.
|
|
292
|
+
Commit `refactor: extract normalizeMaxTurns to turn-limits`.
|
|
293
|
+
2. Extract `getAgentConversation` → `src/session/conversation.ts`; update `get-result-tool.ts` import and `test/agent-conversation.test.ts` import.
|
|
294
|
+
Commit `refactor: extract getAgentConversation to session/conversation`.
|
|
295
|
+
3. Add `SubagentSession` (`src/lifecycle/subagent-session.ts`) with `runTurnLoop`/`resumeTurnLoop`/`steer`/`dispose` + the turn-loop helpers (copied from `agent-runner.ts`; the originals are deleted in step 6 — transient duplication).
|
|
296
|
+
New `test/lifecycle/subagent-session.test.ts` (turn limits, response capture, `completed`/`disposed` emission, resume) — retargeted from the runner's turn-limit + final-output tests.
|
|
297
|
+
Commit `feat: add SubagentSession with turn-loop and disposal behavior`.
|
|
298
|
+
4. Add `createSubagentSession` (`src/lifecycle/create-subagent-session.ts`) + `SubagentSessionDeps`/`CreateSubagentSessionParams` + the IO interfaces (moved; re-export from `agent-runner.ts` if still needed there, else copied) + the recursion guard.
|
|
299
|
+
New `test/lifecycle/create-subagent-session.test.ts` and `create-subagent-session-extension-tools.test.ts` — from the runner's assembly, `spawning`/`session-created` ordering, and recursion-guard tests, plus a dispose-on-creation-failure test.
|
|
300
|
+
Commit `feat: add createSubagentSession factory`.
|
|
301
|
+
5. Swap `Agent` + `AgentManager` + `index.ts` to the factory/`SubagentSession`; drop `runner` from `AgentInit`/`AgentManagerOptions`/`index`; `execution` → `subagentSession`; add `disposeSession()`; `disposed` now fires at cleanup.
|
|
302
|
+
This is the atomic call-site swap (the `runner` dep is type-coupled across `Agent` ↔ `AgentManager` ↔ `index`), so it lands with its test updates in one commit: `agent.test.ts` (run/resume/completeRun/workspace/disposeSession sections), `agent-manager.test.ts` (`createManager` helper + the dispose-on-cleanup test), `test/helpers/manager-stubs.ts` (runner stubs → factory stubs), `test/print-mode.test.ts` (mock `createSubagentSession` instead of `runAgent`).
|
|
303
|
+
These are localized edits to large files, not full rewrites — the bulk of `agent.test.ts`/`agent-manager.test.ts` (status transitions, getters, queue) is untouched.
|
|
304
|
+
Commit `feat: dissolve the runner; Agent drives SubagentSession directly`.
|
|
305
|
+
6. Delete `agent-runner.ts`, `execution-state.ts`, and `concrete-agent-runner.test.ts`; rename `test/helpers/runner-io.ts` → `subagent-session-io.ts` (and its factory functions); update `session-config.ts` / `runtime.ts` doc comments; run the symbol-removal grep sweep.
|
|
306
|
+
Commit `refactor: remove agent-runner and ExecutionState`.
|
|
307
|
+
7. Update `docs/architecture/architecture.md` (diagrams, layout, bag inventory, mark Step 5 delivered) and the `package-pi-subagents` skill's lifecycle-domain table.
|
|
308
|
+
Commit `docs: record runner dissolution and SubagentSession (#265)`.
|
|
309
|
+
|
|
310
|
+
## Risks and Mitigations
|
|
311
|
+
|
|
312
|
+
- **Registry entry persists longer (cross-package behavior).**
|
|
313
|
+
`disposed` now fires at session disposal, so permission-registry entries live from creation to cleanup.
|
|
314
|
+
Mitigation: `AgentManager.dispose()` (session_shutdown) disposes every `SubagentSession`, firing `disposed` for each; the permission system's subscription is unchanged.
|
|
315
|
+
Verify with `pnpm -r run test` (the permission system mocks the bus, so no timing coupling).
|
|
316
|
+
- **Transient duplication (steps 3–5).**
|
|
317
|
+
The turn-loop helpers and assembly exist in both `agent-runner.ts` and the new modules until step 6.
|
|
318
|
+
Mitigation: deleted in step 6; fallow runs at pre-completion against the final state.
|
|
319
|
+
- **Large-file test edits in step 5.**
|
|
320
|
+
Mitigation: edits are confined to the run/resume/dispose describe blocks and the `createManager` helper; the new turn-limit/assembly coverage already lives in steps 3–4's dedicated files, so step 5 only adapts coordination assertions.
|
|
321
|
+
- **`disposed` not fired on a path that disposes the raw session directly.**
|
|
322
|
+
Mitigation: grep for every `session?.dispose` / `.dispose()` on a session in `agent-manager.ts` and route all of them through `record.disposeSession()`.
|
|
323
|
+
|
|
324
|
+
## Open Questions
|
|
325
|
+
|
|
326
|
+
- Resume-aware workspaces: should a resumed worktree agent re-establish (or reattach) a workspace before the next `session.prompt()`?
|
|
327
|
+
Today it runs in the removed worktree directory (degenerate).
|
|
328
|
+
This needs `WorkspaceProvider` support for resume and is out of scope; capture as a follow-up if it becomes a real need.
|
|
329
|
+
- Whether `completed` should also fire on resume (it does not today).
|
|
330
|
+
Deferred — preserve current behavior; revisit only with a concrete consumer.
|