@gotgenes/pi-subagents 11.4.0 → 11.6.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,21 @@ 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.6.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v11.5.0...pi-subagents-v11.6.0) (2026-05-29)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-subagents:** publish bundled type declarations and fix stale exports path ([8eda6f6](https://github.com/gotgenes/pi-packages/commit/8eda6f6611a12c60d99a5069f352abd634997e67))
14
+
15
+ ## [11.5.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v11.4.0...pi-subagents-v11.5.0) (2026-05-29)
16
+
17
+
18
+ ### Features
19
+
20
+ * add WorkspaceProvider registration seam to subagents service ([51a9970](https://github.com/gotgenes/pi-packages/commit/51a99701db214c11f08251e9ed5549d01c4d5839))
21
+ * consult workspace provider for child cwd and disposal ([32eeffc](https://github.com/gotgenes/pi-packages/commit/32eeffc1cc31bc7e403c25cdd116e2b351be4527))
22
+
8
23
  ## [11.4.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v11.3.0...pi-subagents-v11.4.0) (2026-05-29)
9
24
 
10
25
 
@@ -0,0 +1,178 @@
1
+ import { ThinkingLevel } from '@earendil-works/pi-ai';
2
+
3
+ /** usage.ts — Token usage: shapes, accumulator operators, session-stats readers. */
4
+ /**
5
+ * Lifetime usage components, accumulated via `message_end` events. Survives
6
+ * compaction (which replaces session.state.messages and would reset any
7
+ * stats-derived sum). cacheRead is excluded because each turn's cacheRead is
8
+ * the cumulative cached prefix re-read on that one call — summing across
9
+ * turns counts the prefix N times. See issue #38.
10
+ */
11
+ type LifetimeUsage = {
12
+ input: number;
13
+ output: number;
14
+ cacheWrite: number;
15
+ };
16
+
17
+ /**
18
+ * types.ts — Type definitions for the subagent system.
19
+ */
20
+
21
+ /** Agent type: any string name (built-in defaults or user-defined). */
22
+ type SubagentType = string;
23
+ /** Isolation mode for agent execution. */
24
+ type IsolationMode = "worktree";
25
+ interface AgentInvocation {
26
+ /** Short display name, e.g. "haiku" — only set when different from parent. */
27
+ modelName?: string;
28
+ thinking?: ThinkingLevel;
29
+ maxTurns?: number;
30
+ isolated?: boolean;
31
+ inheritContext?: boolean;
32
+ runInBackground?: boolean;
33
+ isolation?: IsolationMode;
34
+ }
35
+
36
+ /**
37
+ * agent.ts — Agent class with encapsulated status-transition logic and per-agent behavior.
38
+ *
39
+ * Status transitions (status, result, error, startedAt, completedAt) are owned
40
+ * by the class and exposed via transition methods. External code reads these
41
+ * fields through public properties but cannot write them directly.
42
+ *
43
+ * Stats (toolUses, lifetimeUsage, compactionCount) are owned by the class and
44
+ * accumulated via mutation methods (incrementToolUses, addUsage, incrementCompactions).
45
+ *
46
+ * Behavior (abort, steer buffering, worktree setup) lives on the agent
47
+ * rather than on AgentManager — each agent manages its own lifecycle concerns.
48
+ *
49
+ * Worktree isolation is delegated to an optional WorktreeIsolation collaborator
50
+ * (set at construction when isolation is requested); its presence IS the mode.
51
+ *
52
+ * Phase-specific collaborators (execution, notification) are attached
53
+ * after construction as lifecycle information becomes available.
54
+ */
55
+
56
+ type AgentStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
57
+
58
+ /**
59
+ * workspace.ts — The single generative extension seam (ADR 0002, Phase 16 Step 2).
60
+ *
61
+ * "Where does a child run, and what brackets the run?" is a strategy (git
62
+ * worktree, container, tmpdir, remote sandbox), not core behavior. The core
63
+ * needs only a working directory plus a disposal hook; the default — the
64
+ * parent's cwd, with no setup/teardown — is always correct.
65
+ *
66
+ * Unlike the observational lifecycle events in child-lifecycle.ts, this is a
67
+ * *generative* seam: a registered provider returns a value the core consumes
68
+ * synchronously at run-start. The core has no knowledge of git or worktrees.
69
+ */
70
+
71
+ /** Context the core hands a provider when a child run starts. */
72
+ interface WorkspacePrepareContext {
73
+ agentId: string;
74
+ agentType: SubagentType;
75
+ baseCwd: string;
76
+ invocation?: AgentInvocation;
77
+ }
78
+ /** Outcome the core reports to a workspace when the run ends. */
79
+ interface WorkspaceDisposeOutcome {
80
+ status: AgentStatus;
81
+ description: string;
82
+ }
83
+ /** What dispose may hand back for the core to fold into the child result. */
84
+ interface WorkspaceDisposeResult {
85
+ /** Appended verbatim to the child's result text — the provider owns the wording. */
86
+ resultAddendum?: string;
87
+ }
88
+ /** A prepared working directory plus its bracketed teardown. Born complete. */
89
+ interface Workspace {
90
+ /** The working directory — already exists when the workspace is handed back. */
91
+ readonly cwd: string;
92
+ dispose(outcome: WorkspaceDisposeOutcome): WorkspaceDisposeResult | undefined;
93
+ }
94
+ /** The single generative seam: supplies a child's workspace. */
95
+ interface WorkspaceProvider {
96
+ prepare(ctx: WorkspacePrepareContext): Promise<Workspace | undefined>;
97
+ }
98
+
99
+ /**
100
+ * service.ts — Public API surface for cross-extension access to subagents.
101
+ *
102
+ * Consumers declare this package as an optional peer dependency and use
103
+ * dynamic import to access the accessor functions:
104
+ *
105
+ * const { getSubagentsService } = await import("@gotgenes/pi-subagents");
106
+ * const svc = getSubagentsService();
107
+ * svc?.spawn("Explore", "Check for stale TODOs");
108
+ */
109
+
110
+ type SubagentStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
111
+ /** Serializable snapshot of an agent's state — no live session objects. */
112
+ interface SubagentRecord {
113
+ id: string;
114
+ type: string;
115
+ description: string;
116
+ status: SubagentStatus;
117
+ result?: string;
118
+ error?: string;
119
+ toolUses: number;
120
+ startedAt: number;
121
+ completedAt?: number;
122
+ lifetimeUsage: LifetimeUsage;
123
+ compactionCount: number;
124
+ worktreeResult?: {
125
+ hasChanges: boolean;
126
+ branch?: string;
127
+ };
128
+ }
129
+ /** Options for spawning an agent via the service. */
130
+ interface SpawnOptions {
131
+ description?: string;
132
+ model?: string;
133
+ maxTurns?: number;
134
+ thinkingLevel?: string;
135
+ isolated?: boolean;
136
+ inheritContext?: boolean;
137
+ foreground?: boolean;
138
+ bypassQueue?: boolean;
139
+ isolation?: "worktree";
140
+ }
141
+ /** The public service contract for cross-extension subagent access. */
142
+ interface SubagentsService {
143
+ /** Spawn an agent. Returns the agent ID immediately. */
144
+ spawn(type: string, prompt: string, options?: SpawnOptions): string;
145
+ /** Get a snapshot of an agent's current state. */
146
+ getRecord(id: string): SubagentRecord | undefined;
147
+ /** List all tracked agents, most recent first. */
148
+ listAgents(): SubagentRecord[];
149
+ /** Abort a running or queued agent. Returns false if not found. */
150
+ abort(id: string): boolean;
151
+ /** Send a steering message to a running agent. */
152
+ steer(id: string, message: string): Promise<boolean>;
153
+ /** Wait for all running and queued agents to complete. */
154
+ waitForAll(): Promise<void>;
155
+ /** Whether any agents are running or queued. */
156
+ hasRunning(): boolean;
157
+ /**
158
+ * Register the single workspace provider that supplies a child's working
159
+ * directory plus bracketed setup/teardown. Throws if one is already
160
+ * registered. Returns a disposer that unregisters the provider.
161
+ */
162
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void;
163
+ }
164
+ /** Event channel constants for pi.events subscriptions. */
165
+ declare const SUBAGENT_EVENTS: {
166
+ readonly STARTED: "subagents:started";
167
+ readonly COMPLETED: "subagents:completed";
168
+ readonly ACTIVITY: "subagents:activity";
169
+ };
170
+ /** Publish the SubagentsService on globalThis for cross-extension access. */
171
+ declare function publishSubagentsService(service: SubagentsService): void;
172
+ /** Retrieve the published SubagentsService, or undefined if not yet published. */
173
+ declare function getSubagentsService(): SubagentsService | undefined;
174
+ /** Remove the SubagentsService from globalThis (call on shutdown/reload). */
175
+ declare function unpublishSubagentsService(): void;
176
+
177
+ export { SUBAGENT_EVENTS, getSubagentsService, publishSubagentsService, unpublishSubagentsService };
178
+ export type { LifetimeUsage, SpawnOptions, SubagentRecord, SubagentStatus, SubagentsService, WorkspaceProvider };
@@ -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,69 @@
1
+ ---
2
+ status: accepted
3
+ date: 2026-05-29
4
+ ---
5
+
6
+ # 0003 — Publish a bundled `.d.ts` for the public surface
7
+
8
+ ## Status
9
+
10
+ Accepted.
11
+ Introduces the repository's first build step, scoped to type declarations only.
12
+
13
+ ## Context
14
+
15
+ `@gotgenes/pi-subagents` could not be imported by another TypeScript package in this workspace.
16
+ Issue #263 (extract worktree isolation to `@gotgenes/pi-subagents-worktrees`) is the first intra-repo consumer: it must `implements WorkspaceProvider` and call `getSubagentsService().registerWorkspaceProvider(...)`, both of which require importing the package by name.
17
+
18
+ A `tsc --traceResolution` of a sibling consuming the package surfaced two compounding failures.
19
+
20
+ 1. `package.json` `exports["."]` pointed at `./src/service.ts`, which does not exist — the real module is `./src/service/service.ts`.
21
+ A latent bug, unnoticed because nothing in-repo imported the package by name.
22
+ 2. Once corrected, the public entry's internal alias imports cascade.
23
+ `service/service.ts` imports `type LifetimeUsage` and `type WorkspaceProvider` via the `#src/*` alias.
24
+ When a sibling's `tsc` follows the symlink, the consumer's own `paths` (`#src/*` → `./src/*`) intercept first and resolve into the *consumer's* `src/` — a global-`paths` collision, since both packages define `#src/*`.
25
+ The fallback to the publisher's `package.json` `imports` field also fails: `tsc` cannot resolve the extensionless `.ts` target under Node `imports` semantics ("Import specifier '#src/lifecycle/usage' does not exist in package.json scope").
26
+
27
+ The public entry's type closure is deeply entangled: `WorkspaceProvider` (in `lifecycle/workspace.ts`) reaches `AgentStatus` in the 510-line `lifecycle/agent.ts`, plus `SubagentType`/`AgentInvocation` from `types.ts` (which itself re-exports the `Agent` class).
28
+ A shallow alias-free entry is therefore not achievable without a substantial source restructure.
29
+
30
+ This collides with the ship-source model (ADR 0002): every package ships raw `.ts` executed directly by Pi, with no build step.
31
+
32
+ ## Decision
33
+
34
+ Emit a single, self-contained `dist/public.d.ts` for the public surface and advertise it through a `types` export condition, while the runtime entry continues to serve `.ts` source.
35
+
36
+ ```jsonc
37
+ "exports": {
38
+ ".": {
39
+ "types": "./dist/public.d.ts",
40
+ "default": "./src/service/service.ts"
41
+ }
42
+ }
43
+ ```
44
+
45
+ - `rollup-plugin-dts` rolls the declaration graph rooted at `src/service/service.ts` into one file, inlining the internal `#src/*` types and keeping peer-dependency types (`@earendil-works/*`, `@sinclair/typebox`) external.
46
+ We ship `.ts` source, so only the declaration bundle is emitted — no JS.
47
+ - The bundle is generated at `prepack` time and shipped via a `files` allowlist; it is gitignored and never committed.
48
+ - `default` → `./src/service/service.ts` fixes the stale path and serves runtime consumers; its `import type` lines erase, so no runtime `#src/*` resolution is needed.
49
+ - A `pnpm pack` → throwaway-consumer → `tsc` harness proves external consumability with no publish round-trip and no workspace privileges.
50
+
51
+ This is the repository's first build step.
52
+ It is deliberately narrow: it produces type declarations only and changes nothing about how Pi loads the extension from source (`pi.extensions: ["./src/index.ts"]` is untouched).
53
+
54
+ ## Alternatives considered
55
+
56
+ - Alias-free public entry (restructure the source so the entry's full type closure resolves via same-directory `./` imports).
57
+ Mechanically possible, but it requires moving the `AgentStatus`/`SubagentType`/`AgentInvocation`/`WorkspaceProvider` definitions and untangling the `agent.ts`/`types.ts` graph, with care that inner layers do not import the outer service layer.
58
+ `eslint`'s `no-parent-relative-imports` rule (which forbids `../`) narrows the options further.
59
+ Larger blast radius than emitting a `.d.ts`, and it churns the domain model to serve a packaging concern.
60
+ - A self-contained entry that re-declares the public types inline, guarded by a conformance test.
61
+ Avoids a build step but duplicates the seam/usage/status type definitions, which drift over time.
62
+
63
+ ## Consequences
64
+
65
+ - The repository now has a build step, but it is type-only and isolated to this package; the ship-source model is otherwise intact.
66
+ - Consumers (including `@gotgenes/pi-subagents-worktrees` in #263) consume the packaged public interface like any external developer — no `workspace:*` privileges.
67
+ - The `types` condition points at a build-time artifact; an in-repo workspace-linked consumer that imported the package would need `dist/public.d.ts` present.
68
+ This is acceptable because no in-repo package imports the surface yet; #263 consumes the built artifact from the published tarball.
69
+ - Sequencing: #270 must be published (its release-please PR merged) before #263 edits `pi-subagents` core, so #263's changes do not batch into the same `pi-subagents` release.
@@ -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.