@gotgenes/pi-subagents 11.4.0 → 11.5.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,14 @@ 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
+ ## [11.5.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v11.4.0...pi-subagents-v11.5.0) (2026-05-29)
9
+
10
+
11
+ ### Features
12
+
13
+ * add WorkspaceProvider registration seam to subagents service ([51a9970](https://github.com/gotgenes/pi-packages/commit/51a99701db214c11f08251e9ed5549d01c4d5839))
14
+ * consult workspace provider for child cwd and disposal ([32eeffc](https://github.com/gotgenes/pi-packages/commit/32eeffc1cc31bc7e403c25cdd116e2b351be4527))
15
+
8
16
  ## [11.4.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v11.3.0...pi-subagents-v11.4.0) (2026-05-29)
9
17
 
10
18
 
@@ -275,6 +275,7 @@ src/
275
275
  │ ├── parent-snapshot.ts immutable spawn-time parent state
276
276
  │ ├── execution-state.ts session/output phase state
277
277
  │ ├── child-lifecycle.ts child-execution lifecycle event publisher
278
+ │ ├── workspace.ts workspace provider seam (generative extension surface)
278
279
  │ ├── worktree.ts git worktree isolation
279
280
  │ ├── worktree-isolation.ts worktree lifecycle collaborator
280
281
  │ └── usage.ts token usage tracking
@@ -355,6 +356,8 @@ They declare this package as an optional peer dependency and use dynamic import
355
356
  - `child-lifecycle` — publishes the child-execution lifecycle (`spawning`, `session-created` before `bindExtensions()`, `completed`, `disposed`) on `pi.events`.
356
357
  Reactive consumers subscribe: `@gotgenes/pi-permission-system` registers each child session on `session-created` and unregisters it on `disposed`.
357
358
  This replaced the former outbound `permission-bridge` (#261, ADR 0002) — the core no longer looks up a named consumer.
359
+ - `workspace` — the single generative seam (#262, ADR 0002): a registered `WorkspaceProvider` supplies a child's cwd plus bracketed `dispose()` at run-start.
360
+ With no provider, children run in the parent cwd (default unchanged); the git worktree strategy moves behind this seam in #263.
358
361
  - `session-config` — pure configuration assembler (extracted from `agent-runner`).
359
362
  - `SubagentRuntime` — session-scoped state bag with methods.
360
363
  - `ParentSnapshot` — immutable snapshot of parent session state, captured once at spawn time.
@@ -719,6 +722,14 @@ See [phase-14-strip-policy.md](history/phase-14-strip-policy.md) for details.
719
722
  [#239]: https://github.com/gotgenes/pi-packages/issues/239
720
723
  [#242]: https://github.com/gotgenes/pi-packages/issues/242
721
724
 
725
+ ## Phase 15 (complete)
726
+
727
+ Phase 15 evolved `Agent` from a passive state machine (`AgentRecord`) into an object that owns its entire execution lifecycle.
728
+ Before Phase 15, `AgentManager` orchestrated everything: calling the runner, handling session creation, wiring observers, and cleaning up worktrees — reaching into Agent 10+ times across `spawn()` and `startAgent()`.
729
+ After Phase 15, Agent is born complete with all dependencies and configuration, owns `run()` and `resume()`, and manages its own observer and worktree lifecycle.
730
+ All six steps are closed: [#227], [#228], [#231], [#229], [#230], [#232].
731
+ See [phase-15-domain-model-evolution.md](history/phase-15-domain-model-evolution.md) for details.
732
+
722
733
  ## Improvement roadmap (Phase 16 — invert dependencies: extensions on a minimal core)
723
734
 
724
735
  Phase 16 reclaims its original intent — invert the core's outbound dependencies — and extends it: worktree isolation joins permissions as an *extension* on a minimal core, leaving pi-subagents a pure child-session orchestrator.
@@ -751,12 +762,16 @@ Migrate `@gotgenes/pi-permission-system` to subscribe to `session-created`/`disp
751
762
  - Outcome: the core stops reaching out to a named consumer; permission detection rides events.
752
763
  - Deferred: removing the now-caller-less `registerSubagentSession`/`unregisterSubagentSession` from `PermissionsService` → #267; registry-detected resume ("executing now" → "exists" semantics) → #265.
753
764
 
754
- #### Step 2: Define the `WorkspaceProvider` seam — [#262]
765
+ #### Step 2: Define the `WorkspaceProvider` seam — [#262] ✅ Delivered
755
766
 
756
- Add the `WorkspaceProvider` / `Workspace` interfaces and `SubagentsService.registerWorkspaceProvider`.
757
- At run-start the core consults the registered provider (if any) for the child's cwd and a disposal handle; with no provider, the child runs in the parent's cwd.
767
+ Added the `WorkspaceProvider` / `Workspace` interfaces (`src/lifecycle/workspace.ts`) and `SubagentsService.registerWorkspaceProvider` (single provider, throws on duplicate, returns an unregister disposer).
768
+ Only `WorkspaceProvider` is named-re-exported from `service.ts`; `Workspace` and the context types resolve via inference when a consumer assigns to `WorkspaceProvider` (the worktrees package adds named re-exports in #263 when it imports them by name).
769
+ At run-start `Agent.run()` consults the registered provider (provider-first precedence) for the child's cwd and a disposal handle; with no provider it falls back to the legacy worktree collaborator, and with neither the child runs in the parent's cwd.
770
+ On completion the core calls `Workspace.dispose({ status, description })` and appends the returned `resultAddendum` verbatim — the provider owns the wording.
758
771
 
772
+ - The seam is additive and non-breaking: the existing `isolation: "worktree"` path is untouched (its eviction is Step 3).
759
773
  - Land alongside its first consumer (Step 3) to avoid a vacant hook — the "no vacant hooks" rule.
774
+ Within #262 the seam is exercised only by test fakes; do not cut a release containing the seam without `@gotgenes/pi-subagents-worktrees`.
760
775
  - Outcome: a single generative seam; the core no longer knows what an "isolation strategy" is.
761
776
 
762
777
  #### Step 3: Extract worktrees to `@gotgenes/pi-subagents-worktrees` — [#263]
@@ -895,4 +910,14 @@ The upstream test suite is run periodically as a regression canary for the agent
895
910
  [#217]: https://github.com/gotgenes/pi-packages/issues/217
896
911
  [#218]: https://github.com/gotgenes/pi-packages/issues/218
897
912
  [#219]: https://github.com/gotgenes/pi-packages/issues/219
913
+ [#227]: https://github.com/gotgenes/pi-packages/issues/227
914
+ [#228]: https://github.com/gotgenes/pi-packages/issues/228
915
+ [#229]: https://github.com/gotgenes/pi-packages/issues/229
916
+ [#230]: https://github.com/gotgenes/pi-packages/issues/230
898
917
  [#231]: https://github.com/gotgenes/pi-packages/issues/231
918
+ [#232]: https://github.com/gotgenes/pi-packages/issues/232
919
+ [#261]: https://github.com/gotgenes/pi-packages/issues/261
920
+ [#262]: https://github.com/gotgenes/pi-packages/issues/262
921
+ [#263]: https://github.com/gotgenes/pi-packages/issues/263
922
+ [#264]: https://github.com/gotgenes/pi-packages/issues/264
923
+ [#265]: https://github.com/gotgenes/pi-packages/issues/265
@@ -0,0 +1,262 @@
1
+ ---
2
+ issue: 262
3
+ issue_title: "Add WorkspaceProvider extension seam"
4
+ ---
5
+
6
+ # Add the WorkspaceProvider extension seam
7
+
8
+ ## Problem Statement
9
+
10
+ Phase 16, Step 2 of ADR 0002 (`packages/pi-subagents/docs/decisions/0002-extensions-on-a-minimal-core.md`).
11
+ The core needs only a working directory and a disposal hook for a child run; the default — the parent's cwd, with no setup or teardown — is always correct.
12
+ "Where does a child run, and what brackets the run?"
13
+ is a *strategy* (git worktree, container, tmpdir, remote sandbox), not core behavior.
14
+ ADR 0002 classifies this as the single *generative* extension surface: a concern that must return a value the core consumes synchronously attaches through a rationed provider seam, not an observational event.
15
+ This issue adds that seam — `WorkspaceProvider` / `Workspace` plus `SubagentsService.registerWorkspaceProvider` — without the core gaining any knowledge of what an "isolation strategy" is.
16
+
17
+ ## Goals
18
+
19
+ - Define the `WorkspaceProvider` and `Workspace` interfaces in the core, with zero git or worktree knowledge.
20
+ - Add `SubagentsService.registerWorkspaceProvider(provider): () => void` — a single-provider seam (chaining is out of scope) that throws if a provider is already registered and returns an unregister disposer.
21
+ - At run-start, consult the registered provider for the child's cwd and a disposal handle; with no provider, the child runs in `baseCwd` (parent cwd — default behavior unchanged).
22
+ - Call `dispose()` after the run and append the returned `resultAddendum` to the child's result.
23
+ - This change is **additive and non-breaking** — the existing `isolation: "worktree"` path is left intact (its eviction is #263).
24
+
25
+ ## Non-Goals
26
+
27
+ - Removing `worktree.ts`, `worktree-isolation.ts`, `GitWorktreeManager`, or the `isolation: "worktree"` spawn mode — deferred to #263.
28
+ - Removing `isolated` / `extensions: false` / `noSkills` — deferred to #264.
29
+ - Born-complete child execution / dissolving the runner — deferred to #265.
30
+ - Multiple/chained providers — out of scope per the issue; one provider only.
31
+ - Shipping a concrete provider implementation — the worktrees package (#263) is the seam's first real consumer.
32
+ Within this issue the seam is exercised only by test fakes; see Risks for the "no vacant hooks" release-coordination constraint.
33
+
34
+ ## Background
35
+
36
+ Relevant existing modules:
37
+
38
+ - `src/lifecycle/agent.ts` — `Agent.run()` calls `this.worktree?.setup()` at run-start to obtain a cwd, threads it into `runner.run({ context: { cwd } })`, and on completion calls `this.worktree?.cleanup(description)`, appending a "Changes saved to branch …" addendum.
39
+ This is exactly the prepare/dispose shape the seam generalizes.
40
+ - `src/lifecycle/worktree-isolation.ts` — `WorktreeIsolation` is the current run-scoped collaborator: `setup()` returns a path, `cleanup(description)` returns a `WorktreeCleanupResult`.
41
+ The seam is its abstraction; #263 will reimplement it as a `WorkspaceProvider` in a separate package.
42
+ - `src/lifecycle/agent-manager.ts` — constructs each `Agent`, owns the injected `WorktreeManager`, and threads `getRunConfig` as a getter.
43
+ The same getter pattern is reused for the workspace provider.
44
+ - `src/service/service.ts` — the package's public API surface (`package.json` `exports` points at `./src/service.ts`).
45
+ `SubagentsService`, `SpawnOptions`, and `SubagentRecord` all live here; the seam types are re-exported here so the worktrees package can implement them.
46
+ - `src/service/service-adapter.ts` — `SubagentsServiceAdapter implements SubagentsService`, wrapping the `AgentManagerLike` narrow interface.
47
+ - `src/lifecycle/child-lifecycle.ts` — the *observational* lifecycle events from #261 (`spawning`, `session-created`, `completed`, `disposed`).
48
+ The provider seam is orthogonal: events tell consumers what happened; the provider returns a value the core consumes.
49
+
50
+ AGENTS.md constraints that apply:
51
+
52
+ - Pi SDK imports stay out of library modules — the seam interfaces and `AgentManager` accept the provider as a parameter; `index.ts` (the SDK edge) supplies `baseCwd: process.cwd()`.
53
+ - Do not read `process.cwd()` inside library functions — `baseCwd` is injected into `AgentManager` from `index.ts`.
54
+ - When adding a public API pattern, follow the established convention: the repo's registration/subscription convention is an unsubscribe **function** (`() => void`, as in `SubscribableSession.subscribe` and `pi.events.on`), not a `Symbol.dispose` `Disposable`.
55
+ The seam therefore returns `() => void`; this is a deliberate divergence from the issue's literal `Disposable` to match the codebase convention.
56
+
57
+ ## Design Overview
58
+
59
+ ### Seam interfaces
60
+
61
+ Defined in a new core module `src/lifecycle/workspace.ts` (sibling to `child-lifecycle.ts`), re-exported from `service.ts` for public consumers.
62
+ The `status` field reuses the core `AgentStatus` union (from `agent.ts`), re-exported publicly so the worktrees package can name it.
63
+
64
+ ```typescript
65
+ import type { AgentStatus } from "#src/lifecycle/agent";
66
+ import type { AgentInvocation, SubagentType } from "#src/types";
67
+
68
+ /** Context the core hands a provider when a child run starts. */
69
+ export interface WorkspacePrepareContext {
70
+ agentId: string;
71
+ agentType: SubagentType;
72
+ baseCwd: string;
73
+ invocation?: AgentInvocation;
74
+ }
75
+
76
+ /** Outcome the core reports to a workspace when the run ends. */
77
+ export interface WorkspaceDisposeOutcome {
78
+ status: AgentStatus;
79
+ description: string;
80
+ }
81
+
82
+ /** What dispose may hand back for the core to fold into the child result. */
83
+ export interface WorkspaceDisposeResult {
84
+ resultAddendum?: string;
85
+ }
86
+
87
+ /** A prepared working directory plus its bracketed teardown. Born complete. */
88
+ export interface Workspace {
89
+ readonly cwd: string; // the directory already exists
90
+ dispose(outcome: WorkspaceDisposeOutcome): WorkspaceDisposeResult | void;
91
+ }
92
+
93
+ /** The single generative seam: supplies a child's workspace. */
94
+ export interface WorkspaceProvider {
95
+ prepare(ctx: WorkspacePrepareContext): Promise<Workspace | undefined>;
96
+ }
97
+ ```
98
+
99
+ Note the addendum-formatting boundary: the core appends `resultAddendum` *verbatim*.
100
+ The provider owns its own separator and wording (the worktrees package owns the "Changes saved to branch …" string in #263).
101
+ The core never formats branch text.
102
+
103
+ ### Registration — single provider, throw on duplicate
104
+
105
+ `AgentManager` holds an optional provider and exposes registration:
106
+
107
+ ```typescript
108
+ private workspaceProvider?: WorkspaceProvider;
109
+
110
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
111
+ if (this.workspaceProvider) {
112
+ throw new Error(
113
+ "A WorkspaceProvider is already registered; only one is supported.",
114
+ );
115
+ }
116
+ this.workspaceProvider = provider;
117
+ return () => {
118
+ if (this.workspaceProvider === provider) this.workspaceProvider = undefined;
119
+ };
120
+ }
121
+ ```
122
+
123
+ The throw surfaces a misconfiguration loudly (two workspace extensions installed at once).
124
+ The disposer clears the slot only if the same provider is still active, so a stale disposer cannot evict a later registration.
125
+ `SubagentsServiceAdapter.registerWorkspaceProvider` delegates straight through; `AgentManagerLike` gains the method.
126
+
127
+ ### Run-start consultation (Tell-Don't-Ask call site)
128
+
129
+ `Agent.run()` consults the provider at the point where it currently calls `worktree?.setup()`.
130
+ Provider-first precedence: when a provider supplies a workspace, the core routes cwd and dispose through it and skips the legacy worktree collaborator; with no provider it falls back to the existing worktree path; with neither it runs in `baseCwd` (cwd undefined → SDK uses the parent cwd).
131
+
132
+ ```typescript
133
+ // run() — replacing the worktree?.setup() block
134
+ let cwd: string | undefined;
135
+ try {
136
+ const provider = this._getWorkspaceProvider?.();
137
+ if (provider) {
138
+ this._workspace = await provider.prepare({
139
+ agentId: this.id,
140
+ agentType: this.type,
141
+ baseCwd: this._baseCwd,
142
+ invocation: this.invocation,
143
+ });
144
+ cwd = this._workspace?.cwd;
145
+ } else {
146
+ this.worktree?.setup();
147
+ cwd = this.worktree?.path;
148
+ }
149
+ } catch (err) {
150
+ this.markError(err);
151
+ this.releaseListeners();
152
+ this.observer?.onRunFinished?.(this);
153
+ return;
154
+ }
155
+ // … runner.run({ context: { cwd, parentSession }, … })
156
+ ```
157
+
158
+ On completion (`completeRun`) the core computes the final status, then disposes:
159
+
160
+ ```typescript
161
+ const finalStatus: AgentStatus =
162
+ result.aborted ? "aborted" : result.steered ? "steered" : "completed";
163
+ if (this._workspace) {
164
+ const out = this._workspace.dispose({ status: finalStatus, description: this.description });
165
+ if (out?.resultAddendum) finalResult += out.resultAddendum;
166
+ } else {
167
+ const wt = this.worktree?.cleanup(this.description);
168
+ if (wt?.hasChanges && wt.branch) finalResult += `\n\n---\nChanges saved to branch \`${wt.branch}\`…`;
169
+ }
170
+ ```
171
+
172
+ `failRun` mirrors this in a `try/catch`, disposing with `status: "error"` and discarding any addendum (matching the existing error-path behavior, which does not append branch text).
173
+
174
+ The provider getter is injected into each `Agent` by `AgentManager.spawn` (`getWorkspaceProvider: () => this.workspaceProvider`), exactly like `getRunConfig`.
175
+ `baseCwd` is injected into `AgentManager` from `index.ts` and threaded to each `Agent`.
176
+
177
+ ### Why the worktree path stays (scope decision A)
178
+
179
+ Per the clarification, #262 is the additive seam only; the legacy `isolation: "worktree"` orchestration is untouched and removed in #263.
180
+ A genuinely separate strategy could register a provider today and get correct cwd + dispose behavior; worktree spawns keep working unchanged.
181
+ Provider-first precedence means the two never silently conflict, and #263 collapses the branch by deleting the worktree arm.
182
+
183
+ ### Edge cases
184
+
185
+ - `prepare()` resolves `undefined` → `cwd` is undefined → runner uses `baseCwd` (parent cwd).
186
+ No dispose call (no workspace).
187
+ - `prepare()` rejects → `markError`, release listeners, notify observer, return (same shape as a worktree `setup()` failure today).
188
+ - `dispose()` returns `void` or no `resultAddendum` → result unchanged.
189
+ - Duplicate `registerWorkspaceProvider` → throws synchronously.
190
+
191
+ ## Module-Level Changes
192
+
193
+ | File | Change |
194
+ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
195
+ | `src/lifecycle/workspace.ts` | **New.** Defines `WorkspaceProvider`, `Workspace`, `WorkspacePrepareContext`, `WorkspaceDisposeOutcome`, `WorkspaceDisposeResult`. No behavior. |
196
+ | `src/lifecycle/agent.ts` | `AgentInit` gains optional `baseCwd?: string` and `getWorkspaceProvider?: () => WorkspaceProvider \| undefined`. New private fields `_baseCwd`, `_getWorkspaceProvider`, `_workspace?: Workspace`. `run()` provider-first prepare; `completeRun`/`failRun` dispose + verbatim `resultAddendum`. Export `AgentStatus` is already public. |
197
+ | `src/lifecycle/agent-manager.ts` | `AgentManagerOptions` gains required `baseCwd: string`. New `workspaceProvider` field, `registerWorkspaceProvider()` (throw on dup, unregister disposer). `spawn()` passes `baseCwd` and the `getWorkspaceProvider` getter into each `Agent`. |
198
+ | `src/service/service.ts` | Re-export the five seam types and `AgentStatus`. Add `registerWorkspaceProvider(provider: WorkspaceProvider): () => void` to the `SubagentsService` interface. |
199
+ | `src/service/service-adapter.ts` | `AgentManagerLike` gains `registerWorkspaceProvider(provider): () => void`. `SubagentsServiceAdapter` implements the method, delegating to the manager. |
200
+ | `src/index.ts` | Pass `baseCwd: process.cwd()` to the `new AgentManager({…})` construction (alongside the existing `GitWorktreeManager(process.cwd())`). |
201
+ | `docs/architecture/architecture.md` | Mark Phase 16 Step 2 (#262) as landed in the roadmap; note the seam exists and `workspace.ts` is added to the lifecycle domain listing. |
202
+
203
+ No exports are removed or renamed, so no `src/`/`test/` removed-symbol grep is required.
204
+ No file in Module-Level Changes is also claimed as unchanged in Non-Goals (the worktree *modules* are non-goals; `agent.ts` touches the worktree *call path* additively, which is consistent).
205
+
206
+ ### Grep checklist before finalizing
207
+
208
+ - Objects typed as `SubagentsService` in tests: `test/service/service.test.ts` casts `{ spawn: () => "id" } as unknown as SubagentsService`, so adding an interface method does **not** break it (verified).
209
+ - `new AgentManager(` call sites: `src/index.ts` (one) and `test/lifecycle/agent-manager.test.ts` `createManager` (one) — both updated for required `baseCwd` in the same step.
210
+ - `AgentManagerLike` mocks in `test/service/service-adapter.test.ts` (`defaultManager`, inline `spawn:` stubs) — add `registerWorkspaceProvider` stub in the same step.
211
+
212
+ ## Test Impact Analysis
213
+
214
+ This is an additive seam, so the work is dominated by *new* tests; little existing coverage is affected.
215
+
216
+ 1. New unit tests the seam enables: provider registration (throw-on-duplicate, disposer-unregisters), run-start consultation (cwd from `prepare`, `resultAddendum` appended on dispose), `prepare` returns undefined → `baseCwd`, `prepare` rejects → `markError`, and adapter delegation.
217
+ These were impossible before because there was no provider abstraction to substitute.
218
+ 2. Redundant existing tests: none.
219
+ The seam does not subsume worktree tests — they exercise the legacy path, which is preserved.
220
+ 3. Existing tests that must stay as-is: all `worktree.test.ts`, `worktree-isolation.test.ts`, and the AgentManager worktree-isolation tests (`calls worktrees.create` / `cleanup`) — they genuinely exercise the fallback path that remains in #262.
221
+ The Agent no-provider tests assert unchanged worktree behavior.
222
+
223
+ ## TDD Order
224
+
225
+ 1. **Seam types + registration surface** — `feat`.
226
+ New `src/lifecycle/workspace.ts`; re-export seam types + `AgentStatus` from `service.ts`; add `registerWorkspaceProvider` to `SubagentsService`, `AgentManagerLike`, and `SubagentsServiceAdapter` (delegating); add required `baseCwd` + provider field + `registerWorkspaceProvider` (throw on dup, disposer) to `AgentManager`; update `index.ts` and the `createManager` test factory for `baseCwd`.
227
+ Tests: `agent-manager.test.ts` registration (throws on second register; disposer clears only the active provider; getter returns the registered provider) and `service-adapter.test.ts` delegation.
228
+ This whole surface lands in one commit because the `SubagentsService` interface method forces the adapter to implement it and the required `baseCwd` forces both construction sites — splitting would not type-check.
229
+ Suggested message: `feat: add WorkspaceProvider registration seam to subagents service`.
230
+ Run `pnpm run check` immediately after (shared-interface change).
231
+
232
+ 2. **Run-start consumption + dispose** — `feat`.
233
+ `Agent`: `AgentInit` gains `baseCwd`/`getWorkspaceProvider`; new private fields; `run()` provider-first prepare; `completeRun`/`failRun` dispose + verbatim `resultAddendum`.
234
+ `AgentManager.spawn` passes `baseCwd` and the `getWorkspaceProvider` getter (sole extra construction site, folded in).
235
+ Tests: `agent.test.ts` — provider `prepare` supplies cwd to the runner; `dispose` `resultAddendum` appended to the result; `prepare` undefined → cwd falls back to `baseCwd`; `prepare` rejects → `markError` + `onRunFinished`; no-provider path still uses the worktree collaborator (regression guard).
236
+ Suggested message: `feat: consult workspace provider for child cwd and disposal`.
237
+ Run `pnpm run check` after (AgentInit change).
238
+
239
+ 3. **Architecture doc update** — `docs`.
240
+ Mark Phase 16 Step 2 (#262) landed in the roadmap; add `workspace.ts` to the lifecycle domain listing; cross-link the seam.
241
+ Suggested message: `docs: record WorkspaceProvider seam in phase 16 roadmap`.
242
+
243
+ ## Risks and Mitigations
244
+
245
+ - **Vacant hook (the headline risk).**
246
+ ADR 0002's "no vacant hooks" rule says a provider seam with no consumer is a speculative abstraction that `fallow` flags as dead.
247
+ Within #262 the seam is exercised only by test fakes.
248
+ Mitigation: land #262 **alongside** #263 (its first real consumer, `@gotgenes/pi-subagents-worktrees`) — do not cut a release that contains the seam without the worktrees package.
249
+ Track this as a release-coordination constraint; the architecture roadmap already pairs Steps 2 and 3.
250
+ - **Dual cwd path confusion.**
251
+ Provider-first precedence keeps worktree and provider from silently conflicting; the branch is documented and removed in #263.
252
+ - **`baseCwd` source.**
253
+ Injecting `process.cwd()` from `index.ts` matches the existing `GitWorktreeManager(process.cwd())` construction; no new global-state read enters a library module.
254
+ - **Status timing in dispose.**
255
+ The final status is computed before the status-transition methods mutate, so `dispose`'s outcome reflects the true terminal status.
256
+
257
+ ## Open Questions
258
+
259
+ - Should `baseCwd` eventually come from the parent `SessionContext.cwd` rather than `process.cwd()`?
260
+ Deferred — `process.cwd()` preserves current worktree behavior; revisit during the born-complete work (#265).
261
+ - Should the `disposed` lifecycle event (#261) and `Workspace.dispose` be reconciled into one teardown notion?
262
+ Deferred — they serve different surfaces (observational vs generative); revisit if #265 dissolves the runner.
@@ -0,0 +1,44 @@
1
+ ---
2
+ issue: 262
3
+ issue_title: "Add WorkspaceProvider extension seam"
4
+ ---
5
+
6
+ # Retro: #262 — Add WorkspaceProvider extension seam
7
+
8
+ ## Stage: Planning (2026-05-29T14:51:15Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced a numbered implementation plan for the Phase 16, Step 2 `WorkspaceProvider` seam (ADR 0002).
13
+ The plan adds the seam additively — `WorkspaceProvider` / `Workspace` interfaces, `SubagentsService.registerWorkspaceProvider`, run-start consultation, and `dispose` with a verbatim `resultAddendum` — while leaving the existing `isolation: "worktree"` path untouched for #263 to evict.
14
+ Three TDD steps (two `feat`, one `docs`).
15
+
16
+ ### Observations
17
+
18
+ - Two ambiguous choices were surfaced via `ask_user` and resolved: **scope = additive seam only** (Option A — leave the legacy worktree path; #263 evicts it), and **duplicate registration = throw** (loud misconfiguration surface, disposer clears only the active provider).
19
+ - The package's public surface is `./src/service.ts` (per `package.json` `exports`), so the seam types are defined in a new core `src/lifecycle/workspace.ts` and re-exported from `service.ts` — avoiding a `service ↔ lifecycle` import cycle while still exposing them to the worktrees consumer.
20
+ - Diverged from the issue's literal `Disposable` return type: the repo convention for unsubscribe/unregister is a plain `() => void` (matching `SubscribableSession.subscribe` and `pi.events.on`); no `Symbol.dispose` usage exists anywhere in the codebase.
21
+ - Provider-first precedence was chosen so the new seam and the legacy worktree collaborator never silently conflict during the transient dual-path window (#263 collapses the branch).
22
+ - Headline risk is the ADR "no vacant hooks" rule: within #262 the seam is exercised only by test fakes, so it must land **alongside** #263 (`@gotgenes/pi-subagents-worktrees`) and not ship in a release on its own.
23
+ - Step 1 bundles the entire registration surface (types, `SubagentsService` method, adapter impl, `AgentManagerLike`, required `baseCwd`) into one commit because the interface method forces the adapter and the required field forces both construction sites — splitting would not type-check.
24
+ - Verified `test/service/service.test.ts` casts its mock `as unknown as SubagentsService`, so adding an interface method does not break it; flagged the `createManager` and `AgentManagerLike` mock updates for the `baseCwd` and registration additions.
25
+
26
+ ## Stage: Implementation — TDD (2026-05-29T15:09:49Z)
27
+
28
+ ### Session summary
29
+
30
+ Implemented the `WorkspaceProvider` seam across three TDD cycles (two `feat`, one `docs`): the registration surface (`AgentManager.registerWorkspaceProvider` + service/adapter delegation + `workspace.ts` types), run-start consumption in `Agent.run()` with provider-first precedence and `dispose`/`resultAddendum`, and an architecture-doc update.
31
+ Test count went from 1049 to 1061 (+12 new tests; +6 in `agent.test.ts`, +4 registration in `agent-manager.test.ts`, +1 adapter delegation, plus existing-helper additions).
32
+ All deterministic gates green: `check`, `lint`, `test`, and `fallow dead-code` (run from repo root).
33
+
34
+ ### Observations
35
+
36
+ - Deviation from plan (Module-Level Changes): the plan said `service.ts` would re-export "the five seam types and `AgentStatus`", but `fallow dead-code` flagged those five re-exports as unused (no consumer until #263), and AGENTS.md forbids speculative re-exports.
37
+ Resolved by re-exporting only `WorkspaceProvider` — a consumer assigning to it gets `Workspace` and the context types via inference; #263 adds named re-exports when it imports them.
38
+ This is the concrete manifestation of the plan's headline "vacant hook" risk surfacing in the dead-code gate.
39
+ - Lint surprise: `WorkspaceDisposeResult | void` tripped eslint `no-invalid-void-type`.
40
+ Changed the `dispose` return type to `WorkspaceDisposeResult | undefined` (equivalent — a side-effecting `dispose` that falls off the end returns `undefined`); minor divergence from the issue's literal `| void`.
41
+ - Three test mock factories implement `AgentManagerLike` in `service-adapter.test.ts` (`createMockManager`, `defaultManager`, `createTestManager`) — all three needed the new `registerWorkspaceProvider` stub; `tsc` caught the third after the first two were updated.
42
+ - Used `git commit --fixup` + `--autosquash` rebase twice (unpushed history) to fold the fallow trim into the Step 1 `feat` commit and the reviewer's doc-wording fix into the Step 3 `docs` commit, keeping each commit self-consistent.
43
+ - Pre-completion reviewer: WARN — all blocking checks pass; one non-blocking doc finding (architecture.md overstated that `Workspace` is re-exported).
44
+ Addressed before finishing.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "11.4.0",
3
+ "version": "11.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/service.ts"
package/src/index.ts CHANGED
@@ -167,6 +167,7 @@ export default function (pi: ExtensionAPI) {
167
167
  const manager = new AgentManager({
168
168
  runner: new ConcreteAgentRunner(runnerDeps),
169
169
  worktrees: new GitWorktreeManager(process.cwd()),
170
+ baseCwd: process.cwd(),
170
171
  observer,
171
172
  queue,
172
173
  getRunConfig: () => settings,
@@ -13,6 +13,7 @@ import { Agent, type AgentLifecycleObserver } from "#src/lifecycle/agent";
13
13
  import type { AgentRunner } from "#src/lifecycle/agent-runner";
14
14
  import type { ConcurrencyQueue } from "#src/lifecycle/concurrency-queue";
15
15
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
16
+ import type { WorkspaceProvider } from "#src/lifecycle/workspace";
16
17
  import type { WorktreeManager } from "#src/lifecycle/worktree";
17
18
  import { WorktreeIsolation } from "#src/lifecycle/worktree-isolation";
18
19
 
@@ -33,6 +34,8 @@ export interface AgentManagerOptions {
33
34
  worktrees: WorktreeManager;
34
35
  /** Concurrency queue — owns scheduling, limit checks, and drain logic. */
35
36
  queue: ConcurrencyQueue;
37
+ /** Base working directory handed to a workspace provider (the parent cwd). */
38
+ baseCwd: string;
36
39
  getRunConfig?: () => RunConfig;
37
40
  observer?: AgentManagerObserver;
38
41
  }
@@ -70,12 +73,20 @@ export class AgentManager {
70
73
  private readonly runner: AgentRunner;
71
74
  private readonly worktrees: WorktreeManager;
72
75
  private readonly queue: ConcurrencyQueue;
76
+ private readonly baseCwd: string;
73
77
  private getRunConfig?: () => RunConfig;
78
+ private _workspaceProvider?: WorkspaceProvider;
79
+
80
+ /** The registered workspace provider, or undefined when none is registered. */
81
+ get workspaceProvider(): WorkspaceProvider | undefined {
82
+ return this._workspaceProvider;
83
+ }
74
84
 
75
85
  constructor(options: AgentManagerOptions) {
76
86
  this.runner = options.runner;
77
87
  this.worktrees = options.worktrees;
78
88
  this.queue = options.queue;
89
+ this.baseCwd = options.baseCwd;
79
90
  this.observer = options.observer;
80
91
  this.getRunConfig = options.getRunConfig;
81
92
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
@@ -83,6 +94,23 @@ export class AgentManager {
83
94
  this.cleanupInterval.unref();
84
95
  }
85
96
 
97
+ /**
98
+ * Register the single workspace provider. Throws if one is already
99
+ * registered (chaining is out of scope — see ADR 0002). Returns a disposer
100
+ * that clears the slot only if this provider is still the active one.
101
+ */
102
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
103
+ if (this._workspaceProvider) {
104
+ throw new Error(
105
+ "A WorkspaceProvider is already registered; only one is supported.",
106
+ );
107
+ }
108
+ this._workspaceProvider = provider;
109
+ return () => {
110
+ if (this._workspaceProvider === provider) this._workspaceProvider = undefined;
111
+ };
112
+ }
113
+
86
114
  /** Compose a per-agent lifecycle observer from manager and spawn-config concerns. */
87
115
  private buildObserver(options: AgentSpawnConfig): AgentLifecycleObserver {
88
116
  return {
@@ -140,6 +168,8 @@ export class AgentManager {
140
168
  : undefined,
141
169
  observer: this.buildObserver(options),
142
170
  getRunConfig: this.getRunConfig,
171
+ baseCwd: this.baseCwd,
172
+ getWorkspaceProvider: () => this._workspaceProvider,
143
173
  });
144
174
  this.agents.set(id, record);
145
175
 
@@ -26,6 +26,7 @@ import type { ExecutionState } from "#src/lifecycle/execution-state";
26
26
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
27
27
  import type { LifetimeUsage } from "#src/lifecycle/usage";
28
28
  import { addUsage } from "#src/lifecycle/usage";
29
+ import type { Workspace, WorkspaceProvider } from "#src/lifecycle/workspace";
29
30
  import type { WorktreeIsolation } from "#src/lifecycle/worktree-isolation";
30
31
  import { NotificationState } from "#src/observation/notification-state";
31
32
  import { subscribeAgentObserver } from "#src/observation/record-observer";
@@ -72,6 +73,10 @@ export interface AgentInit {
72
73
  worktree?: WorktreeIsolation;
73
74
  observer?: AgentLifecycleObserver;
74
75
  getRunConfig?: () => RunConfig;
76
+ /** Resolves the registered workspace provider (if any) at run-start. */
77
+ getWorkspaceProvider?: () => WorkspaceProvider | undefined;
78
+ /** Parent working directory handed to a workspace provider's prepare(). */
79
+ baseCwd?: string;
75
80
 
76
81
  // Run config (required for run(), optional for tests)
77
82
  snapshot?: ParentSnapshot;
@@ -129,6 +134,10 @@ export class Agent {
129
134
  readonly worktree?: WorktreeIsolation;
130
135
  readonly observer?: AgentLifecycleObserver;
131
136
  private readonly _getRunConfig?: () => RunConfig;
137
+ private readonly _getWorkspaceProvider?: () => WorkspaceProvider | undefined;
138
+ private readonly _baseCwd: string;
139
+ /** Workspace prepared at run-start by a provider — undefined when none is registered. */
140
+ private _workspace?: Workspace;
132
141
 
133
142
  // Run config — optional (required for run())
134
143
  private readonly _snapshot?: ParentSnapshot;
@@ -186,6 +195,8 @@ export class Agent {
186
195
  this.worktree = init.worktree;
187
196
  this.observer = init.observer;
188
197
  this._getRunConfig = init.getRunConfig;
198
+ this._getWorkspaceProvider = init.getWorkspaceProvider;
199
+ this._baseCwd = init.baseCwd ?? "";
189
200
 
190
201
  // Run config
191
202
  this._snapshot = init.snapshot;
@@ -223,8 +234,23 @@ export class Agent {
223
234
  this.observer?.onStarted?.(this);
224
235
  this.wireSignal(this._signal, () => this.abort());
225
236
 
237
+ let cwd: string | undefined;
226
238
  try {
227
- this.worktree?.setup();
239
+ // Provider-first: a registered workspace provider supplies the cwd and
240
+ // owns teardown; otherwise fall back to the legacy worktree collaborator.
241
+ const provider = this._getWorkspaceProvider?.();
242
+ if (provider) {
243
+ this._workspace = await provider.prepare({
244
+ agentId: this.id,
245
+ agentType: this.type,
246
+ baseCwd: this._baseCwd,
247
+ invocation: this.invocation,
248
+ });
249
+ cwd = this._workspace?.cwd;
250
+ } else {
251
+ this.worktree?.setup();
252
+ cwd = this.worktree?.path;
253
+ }
228
254
  } catch (err) {
229
255
  this.markError(err);
230
256
  this.releaseListeners();
@@ -236,7 +262,7 @@ export class Agent {
236
262
  try {
237
263
  const result = await this._runner.run(this._snapshot, this.type, this._prompt, {
238
264
  context: {
239
- cwd: this.worktree?.path,
265
+ cwd,
240
266
  parentSession: this._parentSession,
241
267
  },
242
268
  model: this._model,
@@ -442,9 +468,19 @@ export class Agent {
442
468
  this.releaseListeners();
443
469
 
444
470
  let finalResult = result.responseText;
445
- const wtResult = this.worktree?.cleanup(this.description);
446
- if (wtResult?.hasChanges && wtResult.branch) {
447
- finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
471
+ if (this._workspace) {
472
+ const finalStatus: AgentStatus = result.aborted
473
+ ? "aborted"
474
+ : result.steered
475
+ ? "steered"
476
+ : "completed";
477
+ const disposeResult = this._workspace.dispose({ status: finalStatus, description: this.description });
478
+ if (disposeResult?.resultAddendum) finalResult += disposeResult.resultAddendum;
479
+ } else {
480
+ const wtResult = this.worktree?.cleanup(this.description);
481
+ if (wtResult?.hasChanges && wtResult.branch) {
482
+ finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
483
+ }
448
484
  }
449
485
 
450
486
  if (result.aborted) this.markAborted(finalResult);
@@ -465,8 +501,9 @@ export class Agent {
465
501
  this.releaseListeners();
466
502
 
467
503
  try {
468
- this.worktree?.cleanup(this.description);
469
- } catch (cleanupErr) { debugLog("cleanupWorktree on agent error", cleanupErr); }
504
+ if (this._workspace) this._workspace.dispose({ status: "error", description: this.description });
505
+ else this.worktree?.cleanup(this.description);
506
+ } catch (cleanupErr) { debugLog("workspace dispose on agent error", cleanupErr); }
470
507
 
471
508
  this.observer?.onRunFinished?.(this);
472
509
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * workspace.ts — The single generative extension seam (ADR 0002, Phase 16 Step 2).
3
+ *
4
+ * "Where does a child run, and what brackets the run?" is a strategy (git
5
+ * worktree, container, tmpdir, remote sandbox), not core behavior. The core
6
+ * needs only a working directory plus a disposal hook; the default — the
7
+ * parent's cwd, with no setup/teardown — is always correct.
8
+ *
9
+ * Unlike the observational lifecycle events in child-lifecycle.ts, this is a
10
+ * *generative* seam: a registered provider returns a value the core consumes
11
+ * synchronously at run-start. The core has no knowledge of git or worktrees.
12
+ */
13
+
14
+ import type { AgentStatus } from "#src/lifecycle/agent";
15
+ import type { AgentInvocation, SubagentType } from "#src/types";
16
+
17
+ /** Context the core hands a provider when a child run starts. */
18
+ export interface WorkspacePrepareContext {
19
+ agentId: string;
20
+ agentType: SubagentType;
21
+ baseCwd: string;
22
+ invocation?: AgentInvocation;
23
+ }
24
+
25
+ /** Outcome the core reports to a workspace when the run ends. */
26
+ export interface WorkspaceDisposeOutcome {
27
+ status: AgentStatus;
28
+ description: string;
29
+ }
30
+
31
+ /** What dispose may hand back for the core to fold into the child result. */
32
+ export interface WorkspaceDisposeResult {
33
+ /** Appended verbatim to the child's result text — the provider owns the wording. */
34
+ resultAddendum?: string;
35
+ }
36
+
37
+ /** A prepared working directory plus its bracketed teardown. Born complete. */
38
+ export interface Workspace {
39
+ /** The working directory — already exists when the workspace is handed back. */
40
+ readonly cwd: string;
41
+ dispose(outcome: WorkspaceDisposeOutcome): WorkspaceDisposeResult | undefined;
42
+ }
43
+
44
+ /** The single generative seam: supplies a child's workspace. */
45
+ export interface WorkspaceProvider {
46
+ prepare(ctx: WorkspacePrepareContext): Promise<Workspace | undefined>;
47
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
9
+ import type { WorkspaceProvider } from "#src/lifecycle/workspace";
9
10
  import type { SpawnOptions, SubagentRecord, SubagentsService } from "#src/service/service";
10
11
  import type { ModelRegistry } from "#src/session/model-resolver";
11
12
  import type { Agent, SessionContext } from "#src/types";
@@ -18,6 +19,7 @@ export interface AgentManagerLike {
18
19
  abort(id: string): boolean;
19
20
  waitForAll(): Promise<void>;
20
21
  hasRunning(): boolean;
22
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void;
21
23
  }
22
24
 
23
25
  /**
@@ -107,6 +109,10 @@ export class SubagentsServiceAdapter implements SubagentsService {
107
109
  hasRunning(): boolean {
108
110
  return this.manager.hasRunning();
109
111
  }
112
+
113
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
114
+ return this.manager.registerWorkspaceProvider(provider);
115
+ }
110
116
  }
111
117
 
112
118
  /**
@@ -10,8 +10,13 @@
10
10
  */
11
11
 
12
12
  import type { LifetimeUsage } from "#src/lifecycle/usage";
13
+ import type { WorkspaceProvider } from "#src/lifecycle/workspace";
13
14
 
14
- export type { LifetimeUsage };
15
+ // Generative extension seam (ADR 0002, Phase 16 Step 2). Only the provider
16
+ // entry-point type is re-exported here; a consumer assigning to
17
+ // `WorkspaceProvider` gets `Workspace` and the context types via inference.
18
+ // The worktrees package (#263) adds named re-exports when it imports them.
19
+ export type { LifetimeUsage, WorkspaceProvider };
15
20
 
16
21
  export type SubagentStatus =
17
22
  | "queued"
@@ -73,6 +78,13 @@ export interface SubagentsService {
73
78
 
74
79
  /** Whether any agents are running or queued. */
75
80
  hasRunning(): boolean;
81
+
82
+ /**
83
+ * Register the single workspace provider that supplies a child's working
84
+ * directory plus bracketed setup/teardown. Throws if one is already
85
+ * registered. Returns a disposer that unregisters the provider.
86
+ */
87
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void;
76
88
  }
77
89
 
78
90
  /** Event channel constants for pi.events subscriptions. */