@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 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 (execution, notification) are attached
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
- AgentRunner["agent-runner<br/>(session, turns, results)"]
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 --> AgentRunner
89
- AgentRunner --> SessionConfig
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| AgentRunner
94
- UIObserver -.->|subscribes| AgentRunner
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
- +execution?: ExecutionState
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
- +queueSteer(message)
124
- +flushPendingSteers(session)
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 Runner as agent-runner
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->>Runner: runAgent(record, snapshot, options, io)
222
- Runner->>Asm: assembleSessionConfig(type, ctx, opts, env, registry, io)
223
- Asm-->>Runner: SessionConfig
224
- Runner->>Child: create session + run turn loop
225
- Child-->>Runner: result text
226
- Runner-->>Mgr: update Agent
227
- Note over Mgr: agent-observer subscribes to session events for stats
228
- Note over Mgr: ui-observer subscribes for streaming state
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
- │ ├── agent-runner.ts session creation, turn loop, tool filtering
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 directly to `AgentSession` objects.
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, agent-runner"]
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
- - `agent-runner` — session creation, turn loop, extension binding.
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 (extracted from `agent-runner`).
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 | Fields | Consumers | Severity |
536
- | --------------------------- | ----------------------------------------------------------- | ------------------------------------------------- | --------- |
537
- | `ResolvedSpawnConfig` | 3 nested | foreground-runner, background-spawner, agent-tool | ✓ done |
538
- | `AgentSpawnConfig` | 13 → 13 (ParentSessionInfo nested) | agent-manager (internal) | ✓ done |
539
- | `RunOptions` | 9 (`RunContext` nested) | agent-runner | ✓ done |
540
- | `SessionConfig` | 6 (flat fields; extensions/noSkills/extras removed in #264) | agent-runner (output of assembler) | ✓ done |
541
- | `NotificationDetails` | 10 | notification | Low (DTO) |
542
- | `ResourceLoaderOptions` | 10 | agent-runner (SDK bridge) | Low (SDK) |
543
- | `RunnerIO` | split `EnvironmentIO` (3) + `SessionFactoryIO` (5+1) | agent-runner | ✓ done |
544
- | `CreateSessionOptions` | 9 | agent-runner (SDK bridge) | Low (SDK) |
545
- | `AgentToolDeps` | 8 | agent-tool | done |
546
- | `AgentMenuDeps` | 8 | agent-menu | ✓ done |
547
- | `ConversationViewerOptions` | 8 | conversation-viewer | Low |
548
- | `AgentInit` | 8 | agent | Low |
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
- With the cwd resolved through the provider seam (not relayed by `Agent`), child-session creation produces a born-complete execution (`{ session, outputFile?, dispose() }`).
791
- `Agent` owns session interaction directly (prompt, steer, abort, resume, turn limits, response collection); `runAgent` / `resumeAgent` / `ConcreteAgentRunner` / `RunOptions` / `RunResult` dissolve.
792
- Retain `getAgentConversation()` and `normalizeMaxTurns()`.
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 agent-runner core.
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.