@gotgenes/pi-subagents 13.0.0 → 13.1.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,15 @@ 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.1.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v13.0.0...pi-subagents-v13.1.0) (2026-05-30)
9
+
10
+
11
+ ### Features
12
+
13
+ * add createSubagentSession factory ([62c319d](https://github.com/gotgenes/pi-packages/commit/62c319d6703a6f58a829f372b609daea36170987))
14
+ * add SubagentSession with turn-loop and disposal behavior ([69f8f4b](https://github.com/gotgenes/pi-packages/commit/69f8f4bf78431be990a9eb6fbe592e59cc313912))
15
+ * dissolve the runner; Agent drives SubagentSession directly ([fbe71b0](https://github.com/gotgenes/pi-packages/commit/fbe71b02759551e60b4e22e96bb28299e444feb2))
16
+
8
17
  ## [13.0.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v12.1.0...pi-subagents-v13.0.0) (2026-05-30)
9
18
 
10
19
 
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()
@@ -121,13 +124,13 @@ classDiagram
121
124
  +resume(prompt, signal)
122
125
  +abort(): boolean
123
126
  +queueSteer(message)
124
- +flushPendingSteers(session)
127
+ +flushPendingSteers()
125
128
  +completeRun(result)
126
129
  +failRun(err)
130
+ +disposeSession()
127
131
  +wireSignal(signal, onAbort)
128
132
  +attachObserver(unsub)
129
133
  +releaseListeners()
130
- +setOnRunFinished(fn)
131
134
  }
132
135
 
133
136
  class AgentManager {
@@ -210,24 +213,31 @@ sequenceDiagram
210
213
  participant Tool as subagent tool
211
214
  participant Spawn as spawn-config
212
215
  participant Mgr as AgentManager
213
- participant Runner as agent-runner
216
+ participant Ag as Agent
217
+ participant Factory as createSubagentSession
214
218
  participant Asm as assembleSessionConfig
219
+ participant Sub as SubagentSession
215
220
  participant Child as Child session
216
221
 
217
222
  LLM->>Tool: subagent(type, prompt, ...)
218
223
  Tool->>Spawn: resolveSpawnConfig(params)
219
224
  Spawn-->>Tool: ResolvedSpawnConfig
220
225
  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
226
+ Mgr->>Ag: run()
227
+ Ag->>Factory: createSubagentSession(params, deps)
228
+ Factory->>Asm: assembleSessionConfig(type, ctx, opts, env, registry, io)
229
+ Asm-->>Factory: SessionConfig
230
+ Factory->>Child: create session + bind extensions
231
+ Factory-->>Ag: SubagentSession (born complete)
232
+ Note over Ag: agent-observer + ui-observer subscribe to session events
233
+ Ag->>Sub: runTurnLoop(prompt, opts)
234
+ Sub->>Child: prompt + drive turn loop
235
+ Child-->>Sub: result text
236
+ Sub-->>Ag: TurnLoopResult
237
+ Ag-->>Mgr: update Agent
229
238
  Mgr-->>Tool: Agent
230
239
  Tool-->>LLM: formatted result
240
+ Note over Mgr: disposeSession() fires `disposed` at cleanup (resume-detectable)
231
241
  ```
232
242
 
233
243
  ## Module organization
@@ -257,17 +267,19 @@ src/
257
267
  │ ├── prompts.ts system prompt building
258
268
  │ ├── content-items.ts shared message content parsing (tool-call names, assistant content)
259
269
  │ ├── context.ts parent conversation extraction
270
+ │ ├── conversation.ts render a session's messages as formatted text
260
271
  │ ├── env.ts git/platform detection
261
272
  │ ├── model-resolver.ts fuzzy model name resolution
262
273
  │ └── session-dir.ts session directory derivation
263
274
 
264
275
  ├── lifecycle/ agent execution and state tracking
265
276
  │ ├── agent-manager.ts collection manager + observer wiring
266
- │ ├── agent-runner.ts session creation, turn loop, tool filtering
277
+ │ ├── create-subagent-session.ts assembly factory: session creation, binding, tool filtering
278
+ │ ├── subagent-session.ts born-complete child session: turn loop, steer, dispose
279
+ │ ├── turn-limits.ts normalizeMaxTurns (turn-count policy)
267
280
  │ ├── agent.ts owns full execution lifecycle (run, abort, steer, workspace)
268
281
  │ ├── concurrency-queue.ts background agent scheduling with configurable concurrency limit
269
282
  │ ├── parent-snapshot.ts immutable spawn-time parent state
270
- │ ├── execution-state.ts session/output phase state
271
283
  │ ├── child-lifecycle.ts child-execution lifecycle event publisher
272
284
  │ ├── workspace.ts workspace provider seam (generative extension surface)
273
285
  │ └── usage.ts token usage tracking
@@ -327,7 +339,7 @@ flowchart TD
327
339
  subgraph core["@gotgenes/pi-subagents"]
328
340
  direction TB
329
341
  exports["SubagentsService API<br/>publish / getSubagentsService<br/>SubagentRecord, SubagentStatus"]
330
- engine["Tools: subagent, get_subagent_result,<br/>steer_subagent<br/>AgentManager, agent-runner"]
342
+ engine["Tools: subagent, get_subagent_result,<br/>steer_subagent<br/>AgentManager, createSubagentSession, SubagentSession"]
331
343
  ui_int["Internal UI: widget, viewer,<br/>/agents menu"]
332
344
  end
333
345
 
@@ -344,13 +356,14 @@ They declare this package as an optional peer dependency and use dynamic import
344
356
  - The three tools: `subagent` (née `Agent`), `get_subagent_result`, `steer_subagent`.
345
357
  - `AgentManager` — spawn, abort, resume, collection management, observer wiring.
346
358
  - `ConcurrencyQueue` — background agent scheduling with configurable concurrency limit.
347
- - `agent-runner` — session creation, turn loop, extension binding.
359
+ - `createSubagentSession` — assembly factory: session creation and extension binding; returns a born-complete `SubagentSession`.
360
+ - `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
361
  - `child-lifecycle` — publishes the child-execution lifecycle (`spawning`, `session-created` before `bindExtensions()`, `completed`, `disposed`) on `pi.events`.
349
362
  Reactive consumers subscribe: `@gotgenes/pi-permission-system` registers each child session on `session-created` and unregisters it on `disposed`.
350
363
  This replaced the former outbound `permission-bridge` (#261, ADR 0002) — the core no longer looks up a named consumer.
351
364
  - `workspace` — the single generative seam (#262, ADR 0002): a registered `WorkspaceProvider` supplies a child's cwd plus bracketed `dispose()` at run-start.
352
365
  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`).
366
+ - `session-config` — pure configuration assembler (called by `createSubagentSession`).
354
367
  - `SubagentRuntime` — session-scoped state bag with methods.
355
368
  - `ParentSnapshot` — immutable snapshot of parent session state, captured once at spawn time.
356
369
  - `record-observer` — session-event observer that updates record statistics without callback threading.
@@ -532,20 +545,21 @@ This is achieved across phases: Phase 14 (strip policy), Phase 16 (invert depend
532
545
  These interfaces carry hidden dependencies that obscure true coupling.
533
546
  Bags with 10+ fields are the highest priority for decomposition.
534
547
 
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 |
548
+ | Interface | Fields | Consumers | Severity |
549
+ | ----------------------------- | ------------------------------------------------------------ | ------------------------------------------------- | --------- |
550
+ | `ResolvedSpawnConfig` | 3 nested | foreground-runner, background-spawner, agent-tool | ✓ done |
551
+ | `AgentSpawnConfig` | 13 → 13 (ParentSessionInfo nested) | agent-manager (internal) | ✓ done |
552
+ | `CreateSubagentSessionParams` | 6 (snapshot, type, cwd, parentSession, model, thinkingLevel) | create-subagent-session | ✓ done |
553
+ | `TurnLoopOptions` | 4 (maxTurns, defaultMaxTurns, graceTurns, signal) | subagent-session | ✓ done |
554
+ | `SessionConfig` | 6 (flat fields; extensions/noSkills/extras removed in #264) | session-config (output of assembler) | ✓ done |
555
+ | `NotificationDetails` | 10 | notification | Low (DTO) |
556
+ | `ResourceLoaderOptions` | 10 | create-subagent-session (SDK bridge) | Low (SDK) |
557
+ | `SubagentSessionIO` | split `EnvironmentIO` (3) + `SessionFactoryIO` (5+1) | create-subagent-session | ✓ done |
558
+ | `CreateSessionOptions` | 9 | create-subagent-session (SDK bridge) | Low (SDK) |
559
+ | `AgentToolDeps` | 8 | agent-tool | ✓ done |
560
+ | `AgentMenuDeps` | 8 | agent-menu | ✓ done |
561
+ | `ConversationViewerOptions` | 8 | conversation-viewer | Low |
562
+ | `AgentInit` | 8 | agent | Low |
549
563
 
550
564
  ### Complexity hotspots
551
565
 
@@ -575,6 +589,22 @@ The prior clone group between `agent-runner.ts` and `message-formatters.ts` was
575
589
  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
590
  One 11-line internal clone group remains within `agent-config-editor.ts` (lines 135–145 / 173–183).
577
591
 
592
+ ### Session encapsulation debt (Law of Demeter) — [#277]
593
+
594
+ Discovered while planning [#265].
595
+ Consumers reach the raw SDK `AgentSession` through the `Agent.session` getter (`this.subagentSession?.session`) and operate on it directly, instead of telling the owning agent what they want.
596
+ These are Law of Demeter / Tell-Don't-Ask reach-throughs; the fix is intent-revealing methods on the owning object (`Subagent` / `SubagentSession`, introduced by [#265]).
597
+
598
+ | Reach-through | Sites | Missing method |
599
+ | ------------------------------------- | --------------------------------------------------- | ------------------------------------------------ |
600
+ | Steer (buffer-or-deliver, duplicated) | `service-adapter.ts` (~L93), `steer-tool.ts` (~L43) | `Subagent.steer(message)` |
601
+ | Conversation viewing | `get-result-tool.ts:84`, `agent-menu.ts:255` | `Subagent.getConversation()` |
602
+ | Session-readiness guard | `agent-tool.ts:111`, `agent-manager.ts:203` | `Subagent.isSessionReady()` |
603
+ | Session disposal | `agent-manager.ts:235,309` | `SubagentSession.dispose()` — resolved by [#265] |
604
+
605
+ [#265] introduces `SubagentSession` and routes the run / resume / dispose path (and steering during a run) through it; the consumer-facing reach-throughs above are deferred to [#277].
606
+ The steer buffer-or-deliver decision is duplicated across two call sites — two callers performing the same reach-through plus the same decision is the signal for the missing method.
607
+
578
608
  ### Proposed bag decompositions
579
609
 
580
610
  #### ResolvedSpawnConfig (15 fields → 3 value objects)
@@ -785,11 +815,13 @@ The `skills` curation axis collapsed symmetrically with `extensions`: `AgentConf
785
815
  - Depended on: Step 1 (deny-at-use over events).
786
816
  - Outcome: the `isolated`/`extensions`/`noSkills`/`skills` axis is gone; the guard is unconditional.
787
817
 
788
- #### Step 5: Born-complete child execution; dissolve the runner — [#265]
818
+ #### Step 5: Born-complete child execution; dissolve the runner — [#265] ✅ Delivered
789
819
 
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()`.
820
+ `createSubagentSession()` is an assembly factory that returns a born-complete `SubagentSession` (session created, extensions bound, recursion guard applied).
821
+ `SubagentSession` owns turn driving (`runTurnLoop`/`resumeTurnLoop`), steering, and disposal.
822
+ `Agent.run()` is coordination, not assembly; `runAgent` / `resumeAgent` / `ConcreteAgentRunner` / `AgentRunner` / `RunOptions` / `RunResult` / `ExecutionState` dissolved.
823
+ `getAgentConversation()` relocated to `session/conversation.ts`; `normalizeMaxTurns()` to `lifecycle/turn-limits.ts`.
824
+ `disposed` now fires at true session disposal (cleanup), so resume executions are registry-detected (closing the gap deferred from #261).
793
825
 
794
826
  - Depends on: Steps 2–4.
795
827
  - Outcome: the "runner" concept is gone; `Agent.run()` is coordination, not assembly — the structural goal of the abandoned collaborator plan, reached cleanly.
@@ -885,7 +917,7 @@ If they land, upstream gains the peer-dep fix and the two RepOne patches.
885
917
  This fork continues independently regardless.
886
918
 
887
919
  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.
920
+ The upstream test suite is run periodically as a regression canary for the session assembly core.
889
921
 
890
922
  [earendil-works/pi#4207]: https://github.com/earendil-works/pi/issues/4207
891
923
  [gotgenes/pi-packages]: https://github.com/gotgenes/pi-packages
@@ -915,3 +947,4 @@ The upstream test suite is run periodically as a regression canary for the agent
915
947
  [#263]: https://github.com/gotgenes/pi-packages/issues/263
916
948
  [#264]: https://github.com/gotgenes/pi-packages/issues/264
917
949
  [#265]: https://github.com/gotgenes/pi-packages/issues/265
950
+ [#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.