@gotgenes/pi-subagents 16.1.0 → 16.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,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [16.2.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.1.1...pi-subagents-v16.2.0) (2026-06-14)
9
+
10
+
11
+ ### Features
12
+
13
+ * encapsulate Subagent.start(), promise, and notification ([#374](https://github.com/gotgenes/pi-packages/issues/374)) ([048b4a0](https://github.com/gotgenes/pi-packages/commit/048b4a0a859ec83e1c73c1386484a747e37ba224))
14
+
15
+ ## [16.1.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.1.0...pi-subagents-v16.1.1) (2026-06-14)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * abort all subagents on parent interrupt ([#403](https://github.com/gotgenes/pi-packages/issues/403)) ([0c951d3](https://github.com/gotgenes/pi-packages/commit/0c951d3da27123a61c95a7f9a07ddb4cf5ed7e89))
21
+
8
22
  ## [16.1.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.0.0...pi-subagents-v16.1.0) (2026-06-14)
9
23
 
10
24
 
package/dist/public.d.ts CHANGED
@@ -29,6 +29,25 @@ type LifetimeUsage = {
29
29
  cacheWrite: number;
30
30
  };
31
31
 
32
+ /**
33
+ * subagent-state.ts — SubagentState value object: lifecycle status and metrics.
34
+ *
35
+ * Owns the passive, readable state of a subagent — status, result, error,
36
+ * timestamps, and stats (toolUses, lifetimeUsage, compactionCount) — together
37
+ * with the transition methods (markRunning, markCompleted, …) and accumulation
38
+ * methods (incrementToolUses, addUsage, incrementCompactions) that mutate it.
39
+ *
40
+ * State is encapsulated behind getters; external code reads through them but
41
+ * mutates only via the transition/accumulation methods. The value object owns
42
+ * all of its own mutations — no field is written from outside.
43
+ *
44
+ * Subagent holds one of these privately and delegates its getters and mutation
45
+ * methods to it. Extracting it lets the lifecycle state machine and the
46
+ * session-event observer be unit-tested without constructing an executor.
47
+ */
48
+
49
+ type SubagentStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
50
+
32
51
  /**
33
52
  * workspace.ts — The single generative extension seam (ADR 0002, Phase 16 Step 2).
34
53
  *
@@ -70,28 +89,6 @@ interface WorkspaceProvider {
70
89
  prepare(ctx: WorkspacePrepareContext): Promise<Workspace | undefined>;
71
90
  }
72
91
 
73
- /**
74
- * subagent.ts — Subagent class with encapsulated status-transition logic and per-subagent behavior.
75
- *
76
- * Status transitions (status, result, error, startedAt, completedAt) are owned
77
- * by the class and exposed via transition methods. External code reads these
78
- * fields through public properties but cannot write them directly.
79
- *
80
- * Stats (toolUses, lifetimeUsage, compactionCount) are owned by the class and
81
- * accumulated via mutation methods (incrementToolUses, addUsage, incrementCompactions).
82
- *
83
- * Behavior (abort, steer buffering) lives on the subagent rather than on
84
- * SubagentManager — each subagent manages its own lifecycle concerns.
85
- *
86
- * The child's working directory is supplied by a registered WorkspaceProvider
87
- * (the workspace seam); with no provider the child runs in the parent cwd.
88
- *
89
- * Phase-specific collaborators (subagentSession, notification) are attached
90
- * after construction as lifecycle information becomes available.
91
- */
92
-
93
- type SubagentStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
94
-
95
92
  /**
96
93
  * service.ts — Public API surface for cross-extension access to subagents.
97
94
  *
@@ -107,6 +107,8 @@ classDiagram
107
107
  +id: string
108
108
  +type: SubagentType
109
109
  +description: string
110
+ -state: SubagentState
111
+ -execution: SubagentExecution
110
112
  +status: SubagentStatus
111
113
  +result?: string
112
114
  +error?: string
@@ -114,13 +116,8 @@ classDiagram
114
116
  +lifetimeUsage: LifetimeUsage
115
117
  +subagentSession?: SubagentSession
116
118
  +notification?: NotificationState
117
- +markRunning()
118
- +markCompleted()
119
- +markAborted()
120
- +markSteered()
121
- +markError()
122
- +markStopped()
123
- +resetForResume()
119
+ +markRunning() delegates
120
+ +markCompleted() delegates
124
121
  +run()
125
122
  +resume(prompt, signal)
126
123
  +abort(): boolean
@@ -138,6 +135,34 @@ classDiagram
138
135
  +releaseListeners()
139
136
  }
140
137
 
138
+ class SubagentState {
139
+ +status: SubagentStatus
140
+ +result?: string
141
+ +error?: string
142
+ +startedAt: number
143
+ +completedAt?: number
144
+ +toolUses: number
145
+ +lifetimeUsage: LifetimeUsage
146
+ +compactionCount: number
147
+ +markRunning() ... markStopped()
148
+ +resetForResume()
149
+ +incrementToolUses()
150
+ +addUsage(delta)
151
+ +incrementCompactions()
152
+ }
153
+
154
+ class SubagentExecution {
155
+ +createSubagentSession(params)
156
+ +snapshot: ParentSnapshot
157
+ +prompt: string
158
+ +baseCwd: string
159
+ +observer?: SubagentLifecycleObserver
160
+ +getRunConfig?()
161
+ +getWorkspaceProvider?()
162
+ +model?, maxTurns?, thinkingLevel?
163
+ +parentSession?, signal?
164
+ }
165
+
141
166
  class SubagentManager {
142
167
  +spawn(snapshot, type, prompt, config)
143
168
  +spawnAndWait(snapshot, type, prompt, config)
@@ -173,6 +198,8 @@ classDiagram
173
198
  }
174
199
 
175
200
  SubagentManager --> Subagent : creates/manages
201
+ Subagent --> SubagentState : owns (private)
202
+ Subagent --> SubagentExecution : runs via (mandatory)
176
203
  SubagentManager --> ParentSnapshot : receives at spawn
177
204
  SubagentsService --> SubagentManager : wraps via adapter
178
205
  SubagentManager --> AgentTypeRegistry : resolves types
@@ -247,7 +274,7 @@ sequenceDiagram
247
274
 
248
275
  ## Module organization
249
276
 
250
- The extension has 56 source files organized into six domains plus entry-point wiring.
277
+ The extension has 58 source files organized into six domains plus entry-point wiring.
251
278
  All eight domains have directories: `config/`, `session/`, `lifecycle/`, `observation/`, `service/`, `tools/`, `ui/`, and `handlers/`.
252
279
  Issue #164 moved the 26 previously flat root-level files into five new domain directories, reducing the root to 5 files + 8 directories.
253
280
 
@@ -283,6 +310,7 @@ src/
283
310
  │ ├── subagent-session.ts born-complete child session: turn loop, steer, dispose
284
311
  │ ├── turn-limits.ts normalizeMaxTurns (turn-count policy)
285
312
  │ ├── subagent.ts owns full execution lifecycle (run, abort, steer, workspace)
313
+ │ ├── subagent-state.ts lifecycle status + metrics value object (transitions, accumulators)
286
314
  │ ├── concurrency-limiter.ts background admission gate: schedules run thunks FIFO against the limit
287
315
  │ ├── parent-snapshot.ts immutable spawn-time parent state
288
316
  │ ├── child-lifecycle.ts child-execution lifecycle event publisher
@@ -325,6 +353,7 @@ src/
325
353
 
326
354
  └── handlers/ event handlers
327
355
  ├── index.ts barrel re-export
356
+ ├── interrupt.ts turn_start handler — abort all subagents on parent interrupt (ESC)
328
357
  ├── lifecycle.ts session_start, session_before_switch, session_shutdown
329
358
  └── tool-start.ts tool_execution_start handler
330
359
  ```
@@ -646,7 +675,8 @@ Bags with 10+ fields are the highest priority for decomposition.
646
675
  | `AgentToolDeps` | 8 | agent-tool | ✓ done |
647
676
  | `AgentMenuDeps` | 8 | agent-menu | ✓ done |
648
677
  | `ConversationViewerOptions` | 8 | conversation-viewer | Low |
649
- | `SubagentInit` | 8 | subagent | Low |
678
+ | `SubagentInit` | 5 (id, type, description, invocation, execution, state) | subagent (one production site) | ✓ done |
679
+ | `SubagentExecution` | 12 (4 mandatory: factory, snapshot, prompt, baseCwd) | subagent (mandatory collaborator) | ✓ done |
650
680
 
651
681
  ### Complexity hotspots
652
682
 
@@ -864,7 +894,7 @@ Updated health metrics (fallow, package-wide including tests):
864
894
  | Metric | Phase 16 baseline | Current |
865
895
  | -------------------------- | ------------------------------ | --------------------------------------------- |
866
896
  | Health score | 78/100 (B) | 78/100 (B) |
867
- | Source LOC | 7,778 (57 files) | ~7,400 (56 files) |
897
+ | Source LOC | 7,778 (57 files) | ~7,400 (57 files) |
868
898
  | Dead code | 0 files, 0 exports | 0 files, 0 exports |
869
899
  | Maintainability index | 90.8 (good) | 90.8 (good) |
870
900
  | Avg / P90 cyclomatic | 1.4 / 2 | 1.4 / 2 |
@@ -910,11 +940,11 @@ Priority = Impact × (6 − Risk).
910
940
  - Targets: `src/lifecycle/concurrency-queue.ts` (→ `concurrency-limiter.ts`), `src/lifecycle/subagent-manager.ts`, `src/index.ts`, `test/lifecycle/concurrency-queue.test.ts`, `test/lifecycle/subagent-manager.test.ts`.
911
941
  - Smell: Category C (forward references: the queue's ID-registry design forces a start callback that reaches back into the manager, duplicated between `index.ts` and the test helper) and Category A (dual counting: the queue's `running` counter is fed by `markStarted`/`markFinished` relays in the manager's observer, mirroring state the agents already carry).
912
942
  - Change: replace the ID-registry queue with a `ConcurrencyLimiter` that schedules thunks FIFO against a dynamic `getLimit()` — the injected limiter knows nothing about agents, IDs, or the manager.
913
- Spawn gates background runs with `limiter.schedule(() => record.run())` (the thunk guards on `queued` status, covering abort-while-queued; Step 3 later folds the guard into `Subagent.start()`); foreground and `bypassQueue` runs invoke directly.
943
+ Spawn gates background runs with `limiter.schedule(() => record.start())` `start()` owns the abort-while-queued status guard and stores the promise internally; foreground and `bypassQueue` runs invoke `record.start()` directly.
914
944
  The settings `onMaxConcurrentChanged` hook wires to `limiter.recheck()` in `index.ts`; `dispose()` calls `limiter.clear()` to drop pending thunks.
915
945
  - Outcome: dependency direction is strictly manager → limiter (no callback back-edge; the `prefer-const` eslint-disable in the test helper is deleted); the observer's two queue relays are gone; every spawned agent has a `promise` at spawn, collapsing `waitForAll`'s `while (true)` drain loop and its eslint-disable.
916
946
 
917
- #### Step 2 — Extract `SubagentState`; make `Subagent` execution deps mandatory ([#373])
947
+ #### Step 2 — Extract `SubagentState`; make `Subagent` execution deps mandatory ([#373]) ✅ Complete
918
948
 
919
949
  - Targets: `src/lifecycle/subagent.ts` (state fields, transition/accumulation methods, constructor, `run()` guards), `src/lifecycle/subagent-manager.ts` (`spawn`), `test/helpers/make-subagent.ts`, `test/lifecycle/subagent.test.ts`, `test/observation/record-observer.test.ts`.
920
950
  - Smell: Category B (god interface — ~20 fields) and Category D (constructibility: "optional for tests" fields with compensating runtime throws).
@@ -926,18 +956,21 @@ Priority = Impact × (6 − Risk).
926
956
  - Outcome: state-machine and observer tests target `SubagentState` directly (no stub execution); `Subagent` is construct-complete with no optional execution fields and no runtime throws (grep-verifiable: no "not configured for execution" in `subagent.ts`); the record-vs-executor duality is resolved, not type-encoded.
927
957
  - Scope boundary: stats stay on `SubagentState` for now.
928
958
  Hoisting **metrics** into a projection over the child session's event stream and extracting **result delivery** (`notification`/`resultConsumed`) into its own domain are the remaining two of the four domains, deferred to a later phase per the refinement.
929
- - The issue ([#373]) is filed under the prior "decompose `SubagentInit` into present-or-absent bags" framing; update its description to this stronger target before implementation.
959
+ - Landed: `SubagentState` (`src/lifecycle/subagent-state.ts`) owns status/result/error/timestamps/stats and the transition/accumulation methods; `Subagent` delegates getters and `markX`/`incrementX`/`addUsage` to it.
960
+ `subscribeSubagentObserver` targets `SubagentState`, so observer and state-machine tests no longer stub execution.
961
+ `SubagentExecution` is a mandatory constructor collaborator (production wires it in the single `spawn()` site; passive records build via `make-subagent.ts`), and the two `run()` throws are gone.
930
962
 
931
- #### Step 3 — Encapsulate run start and notification attachment on Subagent ([#374])
963
+ #### Step 3 — Encapsulate run start and notification attachment on Subagent ([#374]) ✅ Complete
932
964
 
933
- - Targets: `src/lifecycle/subagent.ts`, `src/lifecycle/subagent-manager.ts`, `test/tools/get-result-tool.test.ts`, `test/lifecycle/subagent-manager.test.ts`, `test/service/service-adapter.test.ts`, `test/observation/notification.test.ts`, `test/helpers/make-subagent.ts`.
934
- - Smell: Category C — output arguments: external writes to `record.promise` (3 production/test sites) and `record.notification` (7 test sites).
935
- - Change: add `Subagent.start()` that runs and stores its own promise (plus an awaitable accessor for `spawnAndWait`/`waitForAll`); make `promise` and `notification` externally read-only; tests attach notification state through `SubagentExecution.parentSession.toolCallId` or a dedicated options field.
936
- - Outcome: zero external writes to `Subagent` fields outside its own methods (grep-verifiable: `\.promise =` and `\.notification =` appear only inside `subagent.ts`).
965
+ - Targets: `src/lifecycle/subagent.ts`, `src/lifecycle/subagent-manager.ts`, `test/tools/get-result-tool.test.ts`, `test/lifecycle/subagent-manager.test.ts`, `test/service/service-adapter.test.ts`, `test/observation/notification.test.ts`, `test/helpers/make-subagent.test.ts`, `test/lifecycle/subagent.test.ts`.
966
+ - Smell: Category C — output arguments: external writes to `record.promise` (2 production sites in `subagent-manager.ts`, 4 test sites) and `record.notification` (7 test sites; the production path was resolved in Step 2 — the constructor creates `notification` from `execution.parentSession?.toolCallId`, so Step 3's remaining work is making the field read-only and updating tests to supply it via `parentSession`).
967
+ - Change: add `Subagent.start()` that runs and stores its own promise (plus an awaitable accessor for `spawnAndWait`/`waitForAll`); make `promise` and `notification` externally read-only (private `_promise`/`_notification` fields backed by public getters); the abort-while-queued status guard folds into `start()`, removing the inline check from the limiter callback; tests use `createTestSubagent({ toolCallId })` or spawn with `parentSession.toolCallId` instead of post-construction assignment.
968
+ - Outcome: zero external writes to `Subagent` fields outside its own methods (grep-verifiable: `\.promise =` and `\.notification =` appear only inside `subagent.ts`); 6 new unit tests for `start()` behaviour; test count +6 (975 → 981).
969
+ - Landed: `Subagent.start()` in `src/lifecycle/subagent.ts` owns the promise and status guard; `SubagentManager.spawn()` calls `record.start()` (scheduled or immediate); `TestSubagentOptions.toolCallId` wires notification state via the constructor path.
937
970
 
938
971
  #### Step 4 — Extract run-listener and workspace-bracket collaborators from Subagent ([#375])
939
972
 
940
- - Targets: `src/lifecycle/subagent.ts` (533 LOC — largest source file, accelerating churn).
973
+ - Targets: `src/lifecycle/subagent.ts` (455 LOC after Step 2 extracted SubagentState still the largest source file).
941
974
  - Smell: Category B (oversized class; per-run listener fields declared mid-class) and Category C (state owns its mutations: workspace dispose logic appears in `run()`'s catch, `completeRun`, and `failRun`).
942
975
  - Change: extract a `RunListeners` object owning the observer-unsubscribe and signal-detach handles (`attach`/`release`), and a workspace-bracket collaborator owning prepare/dispose-with-addendum, so the three dispose paths collapse into one.
943
976
  - Outcome: `subagent.ts` ≤ 450 LOC; workspace disposal logic in exactly one place; listener handles no longer raw nullable fields.
@@ -0,0 +1,250 @@
1
+ ---
2
+ issue: 373
3
+ issue_title: "Extract SubagentState; make Subagent execution deps mandatory"
4
+ ---
5
+
6
+ # Extract `SubagentState`; make `Subagent` execution deps mandatory
7
+
8
+ ## Problem Statement
9
+
10
+ `Subagent` is simultaneously a passive record and an executor.
11
+ Its `SubagentInit` carries ~20 fields — nearly all optional with "required for `run()`, optional for tests" semantics — and `run()` compensates with two runtime guards that throw `"Subagent not configured for execution"`.
12
+ This violates the construct-complete principle: one class is both a display-only snapshot (tests and the UI read `.status`/`.result`/stats) and an executor (production wires the session factory, observer, run config, and workspace provider).
13
+
14
+ The earlier framing — split `SubagentInit` into a present-or-absent `SubagentRunSpec` + `SubagentExecutionDeps` pair — only *type-encodes* the duality; it does not remove it.
15
+ The stronger move (captured in the architecture doc's "First-principles refinement") observes that the passive-record need is *test-only*: production constructs a `Subagent` in exactly one place (`SubagentManager.spawn`), always fully wired.
16
+ So the readable state is the common denominator (extract it as a value object), and the execution deps are the optional-only-for-tests part (make them mandatory; push the passive case into the test factory).
17
+
18
+ ## Goals
19
+
20
+ - Extract `SubagentState` — a value object owning status, result, error, timestamps, and stats (`toolUses`, `lifetimeUsage`, `compactionCount`) plus their transition (`markRunning`, `markCompleted`, …) and accumulation (`incrementToolUses`, `addUsage`, `incrementCompactions`) methods.
21
+ - `Subagent` holds one `SubagentState` privately; its existing getters and `markX`/`incrementX`/`addUsage` methods become one-line delegations, leaving the ~40 read sites and the external mutation callers (`markStopped` in the manager, `markCompleted` in `get-result-tool.test.ts`) unchanged.
22
+ - Collapse the ~12 remaining execution inputs into a single **mandatory** `SubagentExecution` collaborator; `SubagentManager.spawn` always supplies it.
23
+ - Delete the two `"not configured for execution"` throws in `run()` — impossible by construction.
24
+ - Move the passive-record construction entirely into `test/helpers/make-subagent.ts`.
25
+ - Retarget `subscribeSubagentObserver` at `SubagentState` so state-machine and observer tests need no stub execution.
26
+
27
+ This change is **not breaking** for the published service surface.
28
+ `src/service/service.ts` exposes `SubagentRecord`, `SubagentStatus`, and the spawn-config types — not `SubagentInit` or the `Subagent` constructor.
29
+ The read API (getters, `SubagentStatus`) is unchanged; only the internal constructor signature changes.
30
+
31
+ ## Non-Goals
32
+
33
+ - Hoisting **metrics** (tool uses, token usage, compaction count) into a projection over the child session's event stream — stats stay on `SubagentState` for now (the third of four domains in the refinement, deferred).
34
+ - Extracting **result delivery** (`notification` / `resultConsumed`) into its own domain — the fourth domain, deferred.
35
+ - Encapsulating run start / making `promise` and `notification` read-only — that is Step 3 (#374); `notification` stays created in the constructor here.
36
+ - Extracting run-listener / workspace-bracket collaborators — Step 4 (#375).
37
+ - Renaming `record-observer.ts` — only its parameter type changes.
38
+
39
+ ## Background
40
+
41
+ - `src/lifecycle/subagent.ts` — the class under change.
42
+ State fields (`_status`, `_result`, `_error`, `_startedAt`, `_completedAt`, `_toolUses`, `_lifetimeUsage`, `_compactionCount`) sit behind getters; transition/accumulation methods mutate them.
43
+ `run()` reads the execution inputs and throws if `createSubagentSession`/`snapshot`/`prompt` are missing.
44
+ `resume()` reads `observer` and throws only on a missing session (a genuine runtime condition — kept).
45
+ - `src/lifecycle/subagent-manager.ts` — `spawn()` (line ~139) is the **only** production `new Subagent(...)` site; it sets the initial status (`queued` for background so the limiter thunk's `status !== "queued"` guard works, `running` for foreground).
46
+ - `src/observation/record-observer.ts` — `subscribeSubagentObserver(session, record, { onCompact })` calls `record.incrementToolUses()`, `record.addUsage(…)`, `record.incrementCompactions()`, and forwards `onCompact(record, info)`.
47
+ - `src/lifecycle/usage.ts` — `LifetimeUsage` type and the `addUsage(into, delta)` accumulator that `SubagentState` will own.
48
+ - `test/helpers/make-subagent.ts` — `createTestSubagent` builds passive records; stat shorthands apply via mutation methods.
49
+ - `test/lifecycle/subagent.test.ts` (~700 LOC) and `test/observation/record-observer.test.ts` — the test files that construct `Subagent` directly.
50
+
51
+ AGENTS.md / code-design constraints that apply:
52
+
53
+ - Keep Pi SDK imports out of `SubagentState` — it is a pure value object (imports only `LifetimeUsage`/`addUsage`).
54
+ - `Subagent` is exported from the `types.ts` barrel; verify the barrel re-export still resolves after the file split.
55
+ - `SubagentStatus` is re-exported by `src/service/service.ts` from `#src/lifecycle/subagent` — keep that import path valid (re-export `SubagentStatus` from `subagent.ts` even if its definition moves to `subagent-state.ts`), so the public type bundle path is unchanged.
56
+
57
+ ## Design Overview
58
+
59
+ ### `SubagentState` value object (`src/lifecycle/subagent-state.ts`)
60
+
61
+ A pure, independently constructible value object. `SubagentStatus` moves here (its natural home) and is re-exported from `subagent.ts`.
62
+
63
+ ```ts
64
+ export type SubagentStatus =
65
+ | "queued" | "running" | "completed" | "steered"
66
+ | "aborted" | "stopped" | "error";
67
+
68
+ export interface SubagentStateInit {
69
+ status?: SubagentStatus;
70
+ result?: string;
71
+ error?: string;
72
+ startedAt?: number;
73
+ completedAt?: number;
74
+ // stats always start at zero; callers accumulate via mutation methods
75
+ }
76
+
77
+ export class SubagentState {
78
+ constructor(init?: SubagentStateInit); // status ?? "queued", startedAt ?? Date.now()
79
+ // getters: status, result, error, startedAt, completedAt,
80
+ // toolUses, lifetimeUsage (Readonly), compactionCount
81
+ // transitions: markRunning, markCompleted, markAborted, markSteered,
82
+ // markError, markStopped, resetForResume
83
+ // accumulators: incrementToolUses, addUsage, incrementCompactions
84
+ }
85
+ ```
86
+
87
+ It owns its mutations (no output arguments — methods write only its own private fields) and carries no upstream dependency beyond `usage.ts`.
88
+
89
+ ### `SubagentExecution` collaborator (in `src/lifecycle/subagent.ts`)
90
+
91
+ ```ts
92
+ export interface SubagentExecution {
93
+ createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
94
+ snapshot: ParentSnapshot;
95
+ prompt: string;
96
+ baseCwd: string;
97
+ observer?: SubagentLifecycleObserver;
98
+ getRunConfig?: () => RunConfig;
99
+ getWorkspaceProvider?: () => WorkspaceProvider | undefined;
100
+ model?: Model<any>;
101
+ maxTurns?: number;
102
+ thinkingLevel?: ThinkingLevel;
103
+ parentSession?: ParentSessionInfo;
104
+ signal?: AbortSignal;
105
+ }
106
+ ```
107
+
108
+ The four fields the old `run()` guards required (`createSubagentSession`, `snapshot`, `prompt`, plus `baseCwd`) are mandatory; the genuinely-optional behavior knobs stay optional.
109
+
110
+ ### `Subagent` constructor
111
+
112
+ ```ts
113
+ export interface SubagentInit {
114
+ id: string;
115
+ type: SubagentType;
116
+ description: string;
117
+ invocation?: AgentInvocation;
118
+ execution: SubagentExecution; // mandatory — no more "optional for tests"
119
+ state?: SubagentState; // defaults to new SubagentState() (fresh "queued")
120
+ }
121
+ ```
122
+
123
+ `SubagentInit` drops from ~20 fields to 5.
124
+ `Subagent` keeps `id`/`type`/`description`/`invocation`, `abortController`, `subagentSession?`, `notification?`, steer buffer, and per-run listener handles; it holds `private readonly state` and `private readonly execution`.
125
+ Getters and mutation methods delegate one line to `this.state`.
126
+ `notification` is still created in the constructor from `execution.parentSession?.toolCallId` (Step 3 moves it).
127
+
128
+ ### Consumer call site — `SubagentManager.spawn`
129
+
130
+ ```ts
131
+ const execution: SubagentExecution = {
132
+ createSubagentSession: this.createSubagentSession,
133
+ snapshot, prompt, baseCwd: this.baseCwd,
134
+ observer: this.buildObserver(options),
135
+ getRunConfig: this.getRunConfig,
136
+ getWorkspaceProvider: () => this._workspaceProvider,
137
+ model: options.model, maxTurns: options.maxTurns,
138
+ thinkingLevel: options.thinkingLevel,
139
+ parentSession: options.parentSession, signal: options.signal,
140
+ };
141
+ const record = new Subagent({
142
+ id, type, description: options.description, invocation: options.invocation,
143
+ execution,
144
+ state: new SubagentState({ status: options.isBackground ? "queued" : "running", startedAt: Date.now() }),
145
+ });
146
+ ```
147
+
148
+ Tell-Don't-Ask: the execution bundle is assembled once and handed over; nothing reaches back into `spawn`'s locals afterward.
149
+
150
+ ### Observer retarget (`src/observation/record-observer.ts`)
151
+
152
+ The observer accumulates *stats*, not lifecycle — point it at `SubagentState` and drop the record from `onCompact`:
153
+
154
+ ```ts
155
+ export function subscribeSubagentObserver(
156
+ session: SubscribableSession,
157
+ state: SubagentState,
158
+ options?: { onCompact?: (info: CompactionInfo) => void },
159
+ ): () => void
160
+ ```
161
+
162
+ `Subagent.run()`/`resume()` wire it with `this.state` and close over `this` to forward itself to the lifecycle observer:
163
+
164
+ ```ts
165
+ this.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
166
+ onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
167
+ }));
168
+ ```
169
+
170
+ This removes the observer's only dependency on `Subagent`, so observer tests construct a `SubagentState` directly.
171
+ `SubagentLifecycleObserver.onCompacted(agent, info)` is unchanged — it still receives the `Subagent`.
172
+
173
+ Decision: the observer takes `SubagentState` concretely rather than a one-off narrow `incrementToolUses`/`addUsage`/`incrementCompactions` interface.
174
+ `SubagentState` is a cohesive owned value object, not a wide dependency bag, and a single internal call site does not justify a speculative interface (recorded in Open Questions).
175
+
176
+ ### Edge cases
177
+
178
+ - **Initial status for the limiter guard** — production passes `state: new SubagentState({ status: queued|running })`; the background path still observes `queued` so `limiter.schedule(() => record.status !== "queued" ? … : record.run())` behaves identically.
179
+ - **`resume()` without a prior run** — the `"not configured for resume — missing session"` throw stays; it guards a genuine runtime state (no session was ever created), not a construction concern.
180
+ - **`make-subagent.ts` passive records** — built as `state: new SubagentState({ status: "completed", result, startedAt, completedAt })` plus a `makeStubExecution()`; stat shorthands keep delegating through `Subagent`'s methods.
181
+
182
+ ## Module-Level Changes
183
+
184
+ - **`src/lifecycle/subagent-state.ts`** (new) — `SubagentState`, `SubagentStateInit`, and the `SubagentStatus` type (moved from `subagent.ts`).
185
+ - **`src/lifecycle/subagent.ts`** —
186
+ - Re-export `SubagentStatus` from `subagent-state.ts` (keeps service.ts import path valid).
187
+ - Add `SubagentExecution`; rewrite `SubagentInit` to the 5-field shape; remove the flat execution/run-config fields.
188
+ - Remove the (unused) `isBackground?` field from `SubagentInit` — never read in this class.
189
+ - Hold `private readonly state` / `private readonly execution`; convert getters and `markX`/`incrementX`/`addUsage`/`resetForResume` to delegations.
190
+ - `run()`: delete the two `"not configured for execution"` throws; read inputs from `this.execution`; wire the observer at `this.state`.
191
+ - `resume()`: read `observer` from `this.execution`; keep the missing-session throw.
192
+ - **`src/lifecycle/subagent-manager.ts`** — `spawn()` builds the `SubagentExecution` bundle and the initial `SubagentState`; no other change (`markStopped` calls untouched).
193
+ - **`src/observation/record-observer.ts`** — param `record: Subagent` → `state: SubagentState`; `onCompact` signature `(record, info)` → `(info)`; update the JSDoc bullet wording.
194
+ - **`test/helpers/make-subagent.ts`** — add `makeStubExecution()`; build the passive record via `state` + `execution`.
195
+ - **`test/lifecycle/subagent.test.ts`** — funnel constructions through a local helper (prep refactor); move the pure state-machine `describe` blocks to the new state test; supply `execution` everywhere; update the missing-factory test (the throw is gone — replace with a type-level/construction assertion or remove).
196
+ - **`test/lifecycle/subagent-state.test.ts`** (new) — state-machine + accumulation tests targeting `SubagentState` directly.
197
+ - **`test/observation/record-observer.test.ts`** — `makeRecord` → build a `SubagentState`; assert stats on it; `onCompact` test uses `(info)`.
198
+ - **Docs** —
199
+ - `docs/architecture/architecture.md`: add `subagent-state.ts` to the lifecycle directory listing; update the `Subagent` class diagram (state delegations, new collaborators); mark **Step 2 ✅ Complete**; refresh the Phase 17 problem-statement prose (line ~879) and the type-complexity table (`SubagentInit` row at line ~649, add `SubagentExecution`).
200
+ - `.pi/skills/package-pi-subagents/SKILL.md`: Lifecycle domain `10 → 11` modules (add `subagent-state.ts`); total `56 → 57` files.
201
+
202
+ Grep sweep confirmed no other `src/`, `test/`, or `SKILL.md` references to the removed flat fields or the `"not configured for execution"` string outside `subagent.ts`.
203
+
204
+ ## Test Impact Analysis
205
+
206
+ 1. **New tests the extraction enables** — `test/lifecycle/subagent-state.test.ts` exercises every transition and accumulator on `SubagentState` with no `Subagent`, no execution stub, no session.
207
+ This is the construct-complete payoff: the state machine is now unit-testable in isolation.
208
+ 2. **Tests that become redundant** — the state-machine `describe` blocks in `subagent.test.ts` (`markRunning`, `markCompleted`, `markAborted`, `markSteered`, `markError`, `markStopped`, `incrementToolUses`, `addUsage`, `incrementCompactions`, `resetForResume`, and the pure constructor-defaults cases) move out, leaving `subagent.test.ts` to cover identity, session-encapsulation delegation, abort, `run()`/`resume()`, `completeRun`/`failRun`, and listeners.
209
+ `record-observer.test.ts` no longer constructs a `Subagent` — it builds a `SubagentState`.
210
+ 3. **Tests that must stay as-is** — `run()`/`resume()` integration tests genuinely exercise the executor (factory wiring, observer lifecycle, workspace bracket); the `createTestSubagent` consumers across `test/ui/` and `test/tools/` exercise read sites and are unaffected (the helper absorbs the construction change).
211
+
212
+ ## TDD Order
213
+
214
+ 1. **Extract `SubagentState` (pure addition; lift-and-shift prep).**
215
+ - Create `src/lifecycle/subagent-state.ts` and `test/lifecycle/subagent-state.test.ts` (red → green for every transition/accumulator).
216
+ - Refactor `Subagent` to hold a `SubagentState` built from the *existing* `SubagentInit` fields; getters/mutators delegate. `SubagentStatus` moves to `subagent-state.ts`, re-exported from `subagent.ts`.
217
+ - In `subagent.test.ts`, introduce a local construction helper and route call sites through it; move the pure state-machine `describe` blocks into `subagent-state.test.ts`.
218
+ - No consumer breaks (init shape unchanged). `pnpm run check` + `test`.
219
+ - Commit: `refactor: extract SubagentState value object (#373)`.
220
+ 2. **Retarget the observer at `SubagentState`.**
221
+ - Change `subscribeSubagentObserver` to accept `SubagentState` and an `onCompact(info)`; update the two `subagent.ts` call sites (pass `this.state`, close over `this`).
222
+ - Rewrite `record-observer.test.ts` to build a `SubagentState`.
223
+ - Commit: `refactor: target SubagentState in subagent observer (#373)`.
224
+ 3. **Make execution mandatory (the atomic construction flip).**
225
+ - Add `SubagentExecution`; rewrite `SubagentInit` to the 5-field shape; remove flat execution/run-config fields and `isBackground?`; delete the two `run()` throws; read from `this.execution`.
226
+ - Update the single production site (`subagent-manager.ts spawn`) and `make-subagent.ts` (`makeStubExecution` + `state`) and the `subagent.test.ts` helper/`createRunnableAgent`/`createResumableAgent` in the **same commit** — removing the optional fields breaks every construction at the type level simultaneously.
227
+ - Adjust the now-obsolete "missing session factory" test (throw is gone).
228
+ - `pnpm run check`, `lint`, `test`, `fallow dead-code`.
229
+ - Commit: `refactor: make Subagent execution deps mandatory (#373)`.
230
+ 4. **Docs.**
231
+ - Update `architecture.md` (file listing, class diagram, mark Step 2 complete, prose, type table) and `SKILL.md` (domain counts).
232
+ - Commit: `docs: record SubagentState extraction in architecture and skill (#373)`.
233
+
234
+ Optionally run `pnpm run verify:public-types` after Step 3 to confirm the public bundle is unaffected (expected: no change — the bundle does not reference `SubagentInit`).
235
+
236
+ ## Risks and Mitigations
237
+
238
+ - **Large test file churn (`subagent.test.ts`, ~700 LOC).**
239
+ Mitigated by lift-and-shift: Step 1 funnels constructions through a local helper and moves state tests out, so Step 3's mandatory-execution flip edits the helper + two run/resume factories rather than rewriting the file.
240
+ - **Initial-status regression for the limiter.**
241
+ Production explicitly sets `state` status in `spawn`; a `subagent-manager` test should assert a background spawn reports `queued` before its slot opens (existing coverage; re-verify).
242
+ - **`SubagentStatus` re-export path.**
243
+ Keep `SubagentStatus` re-exported from `subagent.ts`; `verify:public-types` (and `pnpm run check`) confirm `service.ts` still resolves it.
244
+ - **Circular import (`subagent.ts` ↔ `subagent-state.ts`).**
245
+ Avoided: `subagent-state.ts` defines `SubagentStatus` and imports nothing from `subagent.ts`; `subagent.ts` imports `SubagentState`/`SubagentStatus` from `subagent-state.ts`.
246
+
247
+ ## Open Questions
248
+
249
+ - Whether the observer should accept a narrow `{ incrementToolUses; addUsage; incrementCompactions }` interface instead of `SubagentState` concretely — deferred; revisit if a second consumer of the stats sink appears (defer-until-needed, per the metrics-projection work in a later phase).
250
+ - Whether the `make-subagent.ts` stat shorthands should construct `SubagentState` directly rather than delegating through `Subagent` — cosmetic; left until Step 7's fixture consolidation (#378).