@gotgenes/pi-subagents 16.2.0 → 16.2.2

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.2](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.2.1...pi-subagents-v16.2.2) (2026-06-15)
9
+
10
+
11
+ ### Documentation
12
+
13
+ * **pi-subagents:** replace fork notice with upstream comparison ([513df4d](https://github.com/gotgenes/pi-packages/commit/513df4d6149178c5c8074cf07d8ad248c50d4c47))
14
+
15
+ ## [16.2.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.2.0...pi-subagents-v16.2.1) (2026-06-15)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * restore at-spawn promise for queued subagents ([#374](https://github.com/gotgenes/pi-packages/issues/374)) ([4f08c6c](https://github.com/gotgenes/pi-packages/commit/4f08c6c37814b5386a23cd60479efc39c4b22612))
21
+
8
22
  ## [16.2.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.1.1...pi-subagents-v16.2.0) (2026-06-14)
9
23
 
10
24
 
package/README.md CHANGED
@@ -6,11 +6,8 @@ A [pi](https://pi.dev) extension that brings **Claude Code-style autonomous sub-
6
6
  Spawn specialized agents that run in isolated sessions — each with its own tools, system prompt, model, and thinking level.
7
7
  Run them in foreground or background, steer them mid-run, resume completed sessions, and define your own custom agent types.
8
8
 
9
- > **Fork notice:** This package is a friendly fork of [`tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents), published to npm as `@gotgenes/pi-subagents`.
10
- > It carries a small number of patches on top of upstream — peer-dep migration to `@earendil-works/pi-*`, a post-`bindExtensions` active-tool re-filter, and an `<active_agent>` system-prompt tag for permission resolution.
11
- > See [Deviations from upstream](#deviations-from-upstream) at the bottom of this README for details.
12
- >
13
- > **Status:** Early release.
9
+ > Originally forked from [`tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) by [@tintinweb](https://github.com/tintinweb), now an independently maintained hard fork.
10
+ > See [Comparison with upstream](./docs/comparison-with-upstream.md) for a feature-by-feature comparison and guidance on which to choose.
14
11
 
15
12
  <img width="600" alt="pi-subagents screenshot" src="https://github.com/gotgenes/pi-subagents/raw/main/media/screenshot.png" />
16
13
 
@@ -436,73 +433,18 @@ When `@gotgenes/pi-permission-system` is not installed, the lifecycle events hav
436
433
 
437
434
  ## Architecture
438
435
 
439
- See `docs/architecture/architecture.md` for the full architecture document with domain decomposition, Mermaid diagrams, and improvement roadmap.
436
+ This extension is a minimal, composable core: it owns agent spawning, execution, and result retrieval, and exposes a typed `SubagentsService` plus lifecycle events that other extensions build on.
440
437
 
441
- ```text
442
- src/
443
- index.ts # Extension entry: tool/command registration, rendering
444
- runtime.ts # Session-scoped state bag with methods
445
- types.ts # Shared type definitions
446
- settings.ts # Persistent settings (concurrency, turn limits)
447
- config/ # Agent type registry and configuration
448
- agent-types.ts # Unified agent registry (defaults + custom)
449
- default-agents.ts # Embedded default agent configs
450
- custom-agents.ts # Load user-defined agents from .pi/agents/*.md
451
- invocation-config.ts # Per-call merge of tool params + agent config
452
- session/ # Pure session assembly
453
- session-config.ts # Session configuration assembler
454
- prompts.ts # Config-driven system prompt builder
455
- context.ts # Parent conversation context for inherit_context
456
- conversation.ts # Render a session's messages as formatted text
457
- content-items.ts # Shared message content parsing
458
- env.ts # Environment detection (git, platform)
459
- model-resolver.ts # Fuzzy model matching
460
- session-dir.ts # Session directory derivation
461
- lifecycle/ # Agent execution and state tracking
462
- agent-manager.ts # Collection manager + observer wiring
463
- agent.ts # Full execution lifecycle (run, abort, steer, workspace)
464
- create-subagent-session.ts # Assembly factory: session creation, binding, tool filtering
465
- subagent-session.ts # Born-complete child session: turn loop, steer, dispose
466
- child-lifecycle.ts # Child-execution lifecycle event publisher
467
- concurrency-queue.ts # Background agent scheduling
468
- parent-snapshot.ts # Immutable spawn-time parent state
469
- turn-limits.ts # Turn-count policy (normalizeMaxTurns)
470
- workspace.ts # Workspace provider seam
471
- usage.ts # Token usage tracking
472
- observation/ # Progress tracking and notification
473
- record-observer.ts # Session-event stats observer
474
- notification.ts # Completion nudges
475
- notification-state.ts # Notification state tracking
476
- renderer.ts # Notification rendering
477
- service/ # Cross-extension API boundary
478
- service.ts # SubagentsService interface + Symbol.for() accessors
479
- service-adapter.ts # SubagentsService wrapper around AgentManager
480
- tools/ # LLM-facing tools
481
- ui/ # Widget, conversation viewer, /agents menu
482
- ```
438
+ See [`docs/architecture/architecture.md`](./docs/architecture/architecture.md) for the full architecture document — design principles, domain decomposition, module dependency flow, Mermaid diagrams, and the improvement roadmap.
483
439
 
484
- ## Deviations from upstream
440
+ ## Relationship to upstream
485
441
 
486
- This fork carries three divergences from [`tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents).
487
- Each has a corresponding upstream PR:
442
+ This package is an independently maintained hard fork of [`tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) by [@tintinweb](https://github.com/tintinweb).
443
+ It has diverged substantially in scope and architecture: a minimal core with a typed service API and lifecycle events, with tool-restriction policy and worktree isolation delegated to companion packages.
444
+ Upstream remains the batteries-included option, keeping scheduling, cross-extension RPC, model-scope enforcement, and a built-in tool denylist in a single package.
488
445
 
489
- 1. **Peer-dep migration to `@earendil-works/pi-*`** `peerDependencies` and all imports point at `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` (the active scope on npm) instead of the deprecated `@mariozechner/pi-*` scope.
490
- Also fixes a latent bug where `ThinkingLevel` was imported from `pi-agent-core` (an undeclared transitive dep that breaks under pnpm).
491
- Upstream PR: [tintinweb/pi-subagents#71](https://github.com/tintinweb/pi-subagents/pull/71).
492
- 2. **Post-`bindExtensions` active-tool re-filter** (`src/agent-runner.ts`) — `runAgent` re-runs its active-tool filter after `session.bindExtensions(...)` so the `EXCLUDED_TOOL_NAMES` recursion guard applies to extension-registered tools (which join the active set during `bindExtensions`).
493
- Upstream PR: [tintinweb/pi-subagents#72](https://github.com/tintinweb/pi-subagents/pull/72).
494
- 3. **`<active_agent>` system-prompt tag** (`src/prompts.ts`) — `buildAgentPrompt` includes `<active_agent name="${config.name}"/>` in every assembled child system prompt (both `replace` and `append` modes); the tag follows the cacheable parent-prompt prefix in both modes.
495
- Downstream extensions like [`@gotgenes/pi-permission-system`](https://github.com/gotgenes/pi-permission-system) parse this tag to resolve per-agent `permission:` frontmatter inside the child session.
496
- Upstream PR: [tintinweb/pi-subagents#73](https://github.com/tintinweb/pi-subagents/pull/73).
497
- 4. **Child-execution lifecycle events** (`src/lifecycle/child-lifecycle.ts`) — the child-session execution lifecycle is published as ordered events on `pi.events` (`subagents:child:spawning`, `session-created`, `completed`, `disposed`).
498
- `session-created` fires synchronously before `bindExtensions()` so consumers (e.g. `@gotgenes/pi-permission-system`) can register the child session before binding proceeds.
499
- This inverts the former outbound `permission-bridge` pattern ([ADR-0002] / [#261]) — the core publishes, consumers subscribe.
500
- No upstream equivalent — this feature is specific to the `@gotgenes` fork.
501
-
502
- The upstream `vitest` suite plus tests added for each patch all pass on every commit.
446
+ See [Comparison with upstream](./docs/comparison-with-upstream.md) for a full feature-by-feature comparison against the current upstream release and guidance on which to choose.
503
447
 
504
448
  ## License
505
449
 
506
450
  MIT — [tintinweb](https://github.com/tintinweb) (upstream) and [Chris Lasher](https://github.com/gotgenes) (fork)
507
-
508
- [ADR-0002]: docs/decisions/0002-extensions-on-a-minimal-core.md
@@ -130,9 +130,6 @@ classDiagram
130
130
  +completeRun(result)
131
131
  +failRun(err)
132
132
  +disposeSession()
133
- +wireSignal(signal, onAbort)
134
- +attachObserver(unsub)
135
- +releaseListeners()
136
133
  }
137
134
 
138
135
  class SubagentState {
@@ -309,8 +306,10 @@ src/
309
306
  │ ├── create-subagent-session.ts assembly factory: session creation, binding, tool filtering
310
307
  │ ├── subagent-session.ts born-complete child session: turn loop, steer, dispose
311
308
  │ ├── turn-limits.ts normalizeMaxTurns (turn-count policy)
312
- │ ├── subagent.ts owns full execution lifecycle (run, abort, steer, workspace)
309
+ │ ├── subagent.ts owns full execution lifecycle (run, abort, steer)
313
310
  │ ├── subagent-state.ts lifecycle status + metrics value object (transitions, accumulators)
311
+ │ ├── run-listeners.ts per-run observer-unsub and signal-detach handles
312
+ │ ├── workspace-bracket.ts child workspace prepare/dispose lifecycle
314
313
  │ ├── concurrency-limiter.ts background admission gate: schedules run thunks FIFO against the limit
315
314
  │ ├── parent-snapshot.ts immutable spawn-time parent state
316
315
  │ ├── child-lifecycle.ts child-execution lifecycle event publisher
@@ -644,17 +643,17 @@ That method — testability friction as a boundary probe, with its limits — is
644
643
 
645
644
  ### Health metrics
646
645
 
647
- | Metric | Value |
648
- | -------------------------- | --------------------------------- |
649
- | Health score | 78/100 (B) |
650
- | Total LOC | 7,778 (57 files) |
651
- | Dead code | 0 files, 0 exports |
652
- | Maintainability index | 90.8 (good) |
653
- | Avg cyclomatic complexity | 1.4 |
654
- | P90 cyclomatic complexity | 2 |
655
- | Production duplication | 11 lines (1 internal clone group) |
656
- | Test duplication | 42 clone groups, 661 lines |
657
- | Fallow refactoring targets | 0 |
646
+ | Metric | Value |
647
+ | -------------------------- | --------------------------------------- |
648
+ | Health score | 78/100 (B) |
649
+ | Total LOC | 8,356 (61 files, as of Phase 17 Step 5) |
650
+ | Dead code | 0 files, 0 exports |
651
+ | Maintainability index | 90.8 (good) |
652
+ | Avg cyclomatic complexity | 1.4 |
653
+ | P90 cyclomatic complexity | 2 |
654
+ | Production duplication | 11 lines (1 internal clone group) |
655
+ | Test duplication | 42 clone groups, 661 lines |
656
+ | Fallow refactoring targets | 0 |
658
657
 
659
658
  ### Dependency bag inventory
660
659
 
@@ -894,7 +893,7 @@ Updated health metrics (fallow, package-wide including tests):
894
893
  | Metric | Phase 16 baseline | Current |
895
894
  | -------------------------- | ------------------------------ | --------------------------------------------- |
896
895
  | Health score | 78/100 (B) | 78/100 (B) |
897
- | Source LOC | 7,778 (57 files) | ~7,400 (57 files) |
896
+ | Source LOC | 7,778 (57 files) | 8,356 (61 files, landed Phase 17 Step 5) |
898
897
  | Dead code | 0 files, 0 exports | 0 files, 0 exports |
899
898
  | Maintainability index | 90.8 (good) | 90.8 (good) |
900
899
  | Avg / P90 cyclomatic | 1.4 / 2 | 1.4 / 2 |
@@ -966,21 +965,29 @@ Priority = Impact × (6 − Risk).
966
965
  - 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
966
  - 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
967
  - 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.
968
+ - Landed: `Subagent.start()` (immediate path) and `Subagent.scheduleVia(schedule)` (queued path) own the promise and the shared `guardedRun()` status guard; `SubagentManager.spawn()` calls one or the other; `TestSubagentOptions.toolCallId` wires notification state via the constructor path.
969
+ - Correction (post-merge): the first cut used `void this.limiter.schedule(() => record.start())`, which left a queued agent's `promise` unset until its slot opened — silently regressing Step 1's "every spawned agent has a `promise` at spawn" invariant.
970
+ Fixed by inverting control: `scheduleVia` captures the limiter promise eagerly inside the agent (no external `.promise =` write), restoring the invariant.
971
+ Lesson: a step's acceptance criteria must include the cross-step invariants it could regress, not only its own grep-verifiable outcome.
970
972
 
971
- #### Step 4 — Extract run-listener and workspace-bracket collaborators from Subagent ([#375])
973
+ #### Step 4 — Extract run-listener and workspace-bracket collaborators from Subagent ([#375]) ✅ Complete
972
974
 
973
975
  - Targets: `src/lifecycle/subagent.ts` (455 LOC after Step 2 extracted SubagentState — still the largest source file).
974
976
  - 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`).
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.
977
+ - Change: extract a `RunListeners` object owning the observer-unsubscribe and signal-detach handles (`wireSignal`/`attachObserver`/`release`), and a `WorkspaceBracket` collaborator owning prepare/dispose-with-addendum, centralising the dispose logic.
976
978
  - Outcome: `subagent.ts` ≤ 450 LOC; workspace disposal logic in exactly one place; listener handles no longer raw nullable fields.
979
+ - Landed: `RunListeners` (`src/lifecycle/run-listeners.ts`) owns the signal-detach and observer-unsub handles with a single `release()` call; `WorkspaceBracket` (`src/lifecycle/workspace-bracket.ts`) owns prepare-at-run-start and dispose-with-addendum — `completeRun` and `failRun` call `workspaceBracket.dispose(outcome)` and receive the addendum string (or `""`) without reaching through to the workspace object directly.
980
+ `Subagent.wireSignal`, `attachObserver`, and `releaseListeners` are removed.
981
+ `subagent.ts`: 488 → 448 LOC.
982
+ Test count: 982 → 994 (+12: 7 RunListeners + 13 WorkspaceBracket − 8 redundant Subagent listener tests).
977
983
 
978
- #### Step 5 — Extract the manager observer from index.ts into a class ([#376])
984
+ #### Step 5 — Extract the manager observer from index.ts into a class ([#376]) ✅ Complete
979
985
 
980
986
  - Targets: `src/index.ts` (inline `SubagentManagerObserver` literal, ~70 lines), new module under `src/observation/`.
981
987
  - Smell: Category B/E — `index.ts` is the dominant churn hotspot (31.3, 91 commits); the literal mixes event emission, record persistence (`appendEntry`), and notification dispatch; principle 9 (state and behavior belong in classes, not closure-captured literals).
982
988
  - Change: extract a class (e.g. `SubagentEventsObserver`) constructed with narrow deps (`emit`, `appendEntry`, the `NotificationSystem`).
983
989
  - Outcome: `index.ts` < 170 lines; the observer's three concerns unit-tested directly without booting the extension.
990
+ - Landed: `src/observation/subagent-events-observer.ts` (new, 97 LOC); `index.ts` 226 → 177 lines; 60 → 61 source files; 994 → 1009 tests (+15 covering all four observer methods).
984
991
 
985
992
  #### Step 6 — Split widget delegation out of SubagentRuntime ([#377])
986
993
 
@@ -0,0 +1,76 @@
1
+ # Comparison with upstream
2
+
3
+ `@gotgenes/pi-subagents` began as a fork of [`tintinweb/pi-subagents`](https://github.com/tintinweb/pi-subagents) by [@tintinweb](https://github.com/tintinweb).
4
+ The original design — Claude Code-style subagent dispatch, the live widget, the conversation viewer, custom agent types — is the foundation everything here builds on.
5
+
6
+ It has since become an independently maintained hard fork.
7
+ It follows its own architecture, does not track upstream as a merge target, and cherry-picks upstream fixes only when they fit its scope.
8
+ This document compares the fork against the current upstream release so you can choose between them.
9
+
10
+ Versions compared: `@gotgenes/pi-subagents` 16.2.1 and `@tintinweb/pi-subagents` 0.10.3 (current at the time of writing).
11
+
12
+ ## At a glance
13
+
14
+ | Aspect | @gotgenes/pi-subagents | @tintinweb/pi-subagents |
15
+ | --------------- | ------------------------------- | --------------------------------------- |
16
+ | Philosophy | Minimal, composable core | Batteries-included, all-in-one |
17
+ | Pi peer scope | `@earendil-works/pi-*` (>=0.75) | `@earendil-works/pi-*` (>=0.74) |
18
+ | Spawn tool name | `subagent` | `Agent` |
19
+ | Runtime deps | `@sinclair/typebox` | `@sinclair/typebox`, `croner`, `nanoid` |
20
+ | License | MIT | MIT |
21
+
22
+ Both ship TypeScript source directly (Pi runs `./src/index.ts`) and target the same `@earendil-works/pi-*` Pi.
23
+ The peer-dep migration that prompted the original fork has since landed upstream, so the Pi scope is no longer a differentiator.
24
+
25
+ ## Common ground
26
+
27
+ Both extensions provide the same core experience:
28
+
29
+ - Claude Code-style foreground/background subagents with a live above-editor widget and a conversation viewer.
30
+ - Custom agent types defined in `.pi/agents/<name>.md` with YAML frontmatter (system prompt, model, thinking, tools).
31
+ - Persistent agent memory (project / local / user scopes) with read-only fallback.
32
+ - Skill preloading, fuzzy model selection, context inheritance, mid-run steering, session resume, and graceful turn limits.
33
+ - A `pi.events` lifecycle bus (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`).
34
+
35
+ ## What upstream has that this fork does not
36
+
37
+ Upstream is the batteries-included option.
38
+ It keeps several subsystems built in that this fork deliberately removed or delegated:
39
+
40
+ | Capability | @tintinweb/pi-subagents | @gotgenes/pi-subagents |
41
+ | ----------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
42
+ | Tool restrictions | `disallowed_tools` frontmatter (denylist) | Delegated — `permission:` via [`@gotgenes/pi-permission-system`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system) |
43
+ | Worktree isolation | Built-in | Delegated — [`@gotgenes/pi-subagents-worktrees`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-subagents-worktrees) |
44
+ | Scheduling | Cron / interval / one-shot subagents (`schedule`) | Removed |
45
+ | Cross-extension control | `subagents:rpc:*` event RPC | Replaced by a typed service (below) |
46
+ | Model-scope enforcement | `enabledModels` allowlist validation | Not included |
47
+ | Notifications | Smart group-join consolidation | Individual per-agent notifications |
48
+
49
+ ## What this fork adds
50
+
51
+ This fork is a minimal core that other extensions build on, plus a small companion ecosystem:
52
+
53
+ - **Typed service API** — `SubagentsService` exposed via `Symbol.for()` accessors, so another extension can spawn and manage subagents without importing this package or relying on ad-hoc event RPC.
54
+ - **Child-session lifecycle events** — `subagents:child:spawning` / `session-created` / `completed` / `disposed`, with `session-created` firing synchronously before `bindExtensions()` so consumers can register the child session deterministically.
55
+ - **`<active_agent>` system-prompt tag** — lets [`@gotgenes/pi-permission-system`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system) resolve per-agent `permission:` frontmatter (allow / ask / deny — richer than a binary denylist) inside the child session.
56
+ - **Companion packages** — permission policy and worktree isolation live in dedicated packages rather than the core.
57
+ - **Re-architected codebase** — decomposed into seven domains behind a typed public API boundary, backed by ~994 tests.
58
+
59
+ ## Which should I use?
60
+
61
+ **Use `@tintinweb/pi-subagents`** if you want a single, batteries-included extension with nothing else to install: built-in tool denylist, scheduled / cron subagents, cross-extension RPC, and model-scope enforcement in one package.
62
+ It is the canonical upstream and the original.
63
+
64
+ **Use `@gotgenes/pi-subagents`** if you want a minimal, composable core: richer allow / ask / deny permissions and worktree isolation through companion packages, a typed service plus lifecycle events to build your own extensions on, and an actively refactored codebase — and you do not need built-in scheduling, RPC, or model-scope enforcement.
65
+
66
+ The spawn tool is named `subagent` here versus `Agent` upstream, so prompts and docs that hard-code the tool name are not drop-in portable between the two.
67
+
68
+ ## Patches contributed upstream
69
+
70
+ Three of the fork's early changes were opened as PRs against upstream and remain a record of the shared lineage:
71
+
72
+ 1. Peer-dep migration to `@earendil-works/pi-*` — [tintinweb/pi-subagents#71](https://github.com/tintinweb/pi-subagents/pull/71) (upstream has since migrated).
73
+ 2. Post-`bindExtensions` active-tool re-filter — [tintinweb/pi-subagents#72](https://github.com/tintinweb/pi-subagents/pull/72).
74
+ 3. `<active_agent>` system-prompt tag — [tintinweb/pi-subagents#73](https://github.com/tintinweb/pi-subagents/pull/73).
75
+
76
+ The fork has since diverged well beyond these.
@@ -0,0 +1,300 @@
1
+ ---
2
+ issue: 375
3
+ issue_title: "Extract run-listener and workspace-bracket collaborators from Subagent"
4
+ ---
5
+
6
+ # Extract run-listener and workspace-bracket collaborators from Subagent
7
+
8
+ ## Problem Statement
9
+
10
+ `subagent.ts` is the largest source file in `pi-subagents` and an accelerating churn hotspot.
11
+ Two concerns inflate the `Subagent` class beyond its core responsibility (own a child's execution lifecycle):
12
+
13
+ 1. The per-run listener handles (`_unsub`, `_detachFn`) are raw nullable fields declared mid-class, with `wireSignal`/`attachObserver`/`releaseListeners` scattered around them.
14
+ 2. Workspace teardown logic appears at two run-completion call sites — `completeRun` (success/abort/steer) and `failRun` (error) — each re-deriving the `if (this._workspace) … dispose() … resultAddendum` shape.
15
+
16
+ This is Phase 17 Step 4 (core consolidation).
17
+ The goal is to lift both concerns into small owned collaborators so the class shrinks and the dispose logic lives in one place.
18
+
19
+ ## Goals
20
+
21
+ - Extract a `RunListeners` collaborator that owns the observer-unsubscribe and signal-detach handles, so the two listener handles stop being raw nullable fields on `Subagent`.
22
+ - Extract a `WorkspaceBracket` collaborator that owns prepare-at-run-start and dispose-with-result-addendum, so the workspace-disposal *logic* lives in exactly one place.
23
+ - Bring `subagent.ts` to ≤ 450 LOC (currently 488).
24
+ - Preserve all observable run/resume behavior exactly: observer-callback order, listener release on complete/fail/resume, workspace dispose status mapping, addendum folding, and the distinct error-handling semantics of the two dispose call sites.
25
+
26
+ This change is **not breaking**: it is a pure internal restructuring with no change to observable behavior, output shape, public service surface, or defaults.
27
+ `RunListeners` and `WorkspaceBracket` are internal lifecycle collaborators, not part of the published `dist/public.d.ts` surface.
28
+
29
+ ## Non-Goals
30
+
31
+ - No change to `SubagentState`, `SubagentExecution`, the `WorkspaceProvider`/`Workspace` seam interfaces (`src/lifecycle/workspace.ts`), or `subagent-manager.ts` wiring.
32
+ - No change to the run/resume control flow, the abort path, the steer buffer, or the notification field.
33
+ - No unification of the two dispose call sites' *error-handling* semantics (see Design Overview — they are intentionally different lifecycle contexts).
34
+ - Phase 17 Steps 5–9 (manager observer extraction, widget delegation split, test-fixture consolidation, cross-package settings-loader duplication) are separate issues.
35
+
36
+ ## Background
37
+
38
+ Relevant modules:
39
+
40
+ - `src/lifecycle/subagent.ts` — the `Subagent` class.
41
+ After Phase 17 Step 2 ([#373]) extracted `SubagentState` and Step 3 ([#374]) encapsulated `start`/`promise`/`notification`, the remaining structural debt is the listener fields and the workspace dispose duplication.
42
+ - `src/lifecycle/workspace.ts` — the generative workspace seam (ADR 0002): `WorkspaceProvider.prepare(ctx)` returns a `Workspace` (a `cwd` plus a `dispose(outcome)` hook returning an optional `resultAddendum`).
43
+ - `src/lifecycle/subagent-manager.ts` — the only production constructor of `Subagent` (`spawn`); it resolves the registered provider lazily via `getWorkspaceProvider: () => this._workspaceProvider`.
44
+ It calls `record.disposeSession()` but none of the listener/workspace methods being moved.
45
+ - `test/lifecycle/subagent.test.ts` — directly tests `wireSignal`, `attachObserver`, `releaseListeners` (3 `describe` blocks) and the workspace dispose paths via `run()`.
46
+
47
+ Current listener fields and methods on `Subagent`:
48
+
49
+ ```typescript
50
+ private _unsub?: () => void;
51
+ private _detachFn?: () => void;
52
+ wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void { … } // sets _detachFn
53
+ attachObserver(unsub: () => void): void { … } // sets _unsub
54
+ releaseListeners(): void { … } // clears both
55
+ ```
56
+
57
+ The two handles are attached at different moments: `wireSignal` at run start, `attachObserver` after the session is created; `resume()` only attaches the observer.
58
+ So a single combined `attach(unsub, detach)` (the issue's first-cut sketch) does not match the real call pattern — the collaborator must expose the two attach points separately.
59
+
60
+ Current workspace state and dispose call sites:
61
+
62
+ ```typescript
63
+ private _workspace?: Workspace;
64
+ // run(): const provider = this.execution.getWorkspaceProvider?.(); if (provider) { this._workspace = await provider.prepare({…}); cwd = this._workspace?.cwd; }
65
+ // completeRun(): if (this._workspace) { finalStatus = …; const r = this._workspace.dispose({status, description}); if (r?.resultAddendum) finalResult += r.resultAddendum; }
66
+ // failRun(): try { if (this._workspace) this._workspace.dispose({status:"error", description}); } catch (e) { debugLog(…); }
67
+ ```
68
+
69
+ Note: the issue's "three places" wording counts `run()`'s prepare-failure catch as a teardown path, but that catch has no prepared workspace to dispose — it only releases listeners and notifies.
70
+ The actual `dispose()` call appears at **two** sites (`completeRun`, `failRun`).
71
+
72
+ Constraint from AGENTS.md / code-design: business-logic modules stay SDK-free.
73
+ Both new collaborators use only globals (`AbortSignal`) and the local `workspace.ts` types — no Pi SDK imports.
74
+ Import siblings via `#src/...` aliases.
75
+
76
+ ### Invariants from prior Phase 17 steps (must not regress)
77
+
78
+ Per the [#374] retro lesson — a later phase step must not regress an earlier step's documented `Outcome:` with a green suite.
79
+ This step touches the `Subagent` run/resume surface, so the at-risk invariants are:
80
+
81
+ - **Step 1 ([#381]) — "every spawned agent has a `promise` at spawn."**
82
+ Pinned by the regression test in `test/lifecycle/subagent-manager.test.ts` (queued agent has a `promise` at spawn) and `scheduleVia` unit tests in `subagent.test.ts`.
83
+ This step does **not** touch `start`/`scheduleVia`/`guardedRun`/`_promise` — low risk, but the suite pins it.
84
+ - **Step 2 ([#373]) — "`Subagent` is construct-complete; no 'not configured for execution' throws."**
85
+ Pinned by the grep check (no "not configured for execution" in `subagent.ts`) and the constructor tests.
86
+ Both new collaborators are constructed **inside** the `Subagent` constructor (no new optional init fields), so construct-completeness is preserved.
87
+ - **Step 3 ([#374]) — "zero external writes to `Subagent` fields outside its own methods."**
88
+ Pinned by the grep check (`\.promise =` / `\.notification =` only in `subagent.ts`).
89
+ The removed `_unsub`/`_detachFn`/`_workspace` fields had no external writers; removing the public `wireSignal`/`attachObserver`/`releaseListeners` methods reduces the surface further.
90
+
91
+ ## Design Overview
92
+
93
+ Two small owned value objects, each owning state plus the behavior that reads/writes it (principle 9 — state and behavior in a class, not raw fields scattered through a host).
94
+ Neither is procedure-splitting: each owns mutable state and returns a value (`prepare` → cwd, `dispose` → addendum) or encapsulates lifecycle handles.
95
+
96
+ ### `RunListeners` — `src/lifecycle/run-listeners.ts`
97
+
98
+ Owns the two per-run teardown handles and the wire/release behavior.
99
+
100
+ ```typescript
101
+ export class RunListeners {
102
+ private unsub?: () => void;
103
+ private detach?: () => void;
104
+
105
+ /** Wire a parent AbortSignal so it stops the run when fired. No-op when no signal. */
106
+ wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
107
+ if (!signal) return;
108
+ const listener = () => onAbort();
109
+ signal.addEventListener("abort", listener, { once: true });
110
+ this.detach = () => signal.removeEventListener("abort", listener);
111
+ }
112
+
113
+ /** Store the record-observer unsubscribe handle. */
114
+ attachObserver(unsub: () => void): void {
115
+ this.unsub = unsub;
116
+ }
117
+
118
+ /** Release the observer + signal handles. Idempotent. */
119
+ release(): void {
120
+ this.unsub?.();
121
+ this.unsub = undefined;
122
+ this.detach?.();
123
+ this.detach = undefined;
124
+ }
125
+ }
126
+ ```
127
+
128
+ ### `WorkspaceBracket` — `src/lifecycle/workspace-bracket.ts`
129
+
130
+ Owns the prepared `Workspace` and the prepare/dispose logic.
131
+ It captures the provider *resolver* (not the provider) so resolution stays at run-start, matching today's `getWorkspaceProvider?.()` timing.
132
+
133
+ ```typescript
134
+ import type {
135
+ Workspace,
136
+ WorkspaceDisposeOutcome,
137
+ WorkspacePrepareContext,
138
+ WorkspaceProvider,
139
+ } from "#src/lifecycle/workspace";
140
+
141
+ export class WorkspaceBracket {
142
+ private prepared?: Workspace;
143
+
144
+ constructor(private readonly resolveProvider: () => WorkspaceProvider | undefined) {}
145
+
146
+ /** Resolve the registered provider and prepare the child workspace; returns its cwd (undefined when none). */
147
+ async prepare(ctx: WorkspacePrepareContext): Promise<string | undefined> {
148
+ const provider = this.resolveProvider();
149
+ if (!provider) return undefined;
150
+ this.prepared = await provider.prepare(ctx);
151
+ return this.prepared?.cwd;
152
+ }
153
+
154
+ /** Dispose the prepared workspace (if any); returns the result addendum verbatim ("" when none). */
155
+ dispose(outcome: WorkspaceDisposeOutcome): string {
156
+ if (!this.prepared) return "";
157
+ return this.prepared.dispose(outcome)?.resultAddendum ?? "";
158
+ }
159
+ }
160
+ ```
161
+
162
+ ### `Subagent` interaction with the collaborators
163
+
164
+ Constructed in the `Subagent` constructor (construct-complete):
165
+
166
+ ```typescript
167
+ private readonly listeners = new RunListeners();
168
+ private readonly workspaceBracket: WorkspaceBracket;
169
+ // in constructor:
170
+ this.workspaceBracket = new WorkspaceBracket(this.execution.getWorkspaceProvider ?? (() => undefined));
171
+ ```
172
+
173
+ Call sites in `run()` / `resume()` / `completeRun()` / `failRun()`:
174
+
175
+ ```typescript
176
+ // run() start:
177
+ this.listeners.wireSignal(this.execution.signal, () => this.abort());
178
+ const cwd = await this.workspaceBracket.prepare({ agentId: this.id, agentType: this.type, baseCwd: this.execution.baseCwd, invocation: this.invocation });
179
+ // run() after session created:
180
+ this.listeners.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, { onCompact: … }));
181
+
182
+ // completeRun():
183
+ const finalStatus: SubagentStatus = result.aborted ? "aborted" : result.steered ? "steered" : "completed";
184
+ let finalResult = result.responseText + this.workspaceBracket.dispose({ status: finalStatus, description: this.description });
185
+ // failRun():
186
+ try { this.workspaceBracket.dispose({ status: "error", description: this.description }); }
187
+ catch (cleanupErr) { debugLog("workspace dispose on agent error", cleanupErr); }
188
+ ```
189
+
190
+ This follows Tell-Don't-Ask: callers tell the bracket to `dispose(outcome)` and receive the addendum string; they do not reach through to `workspace.dispose(…).resultAddendum` (the prior Law-of-Demeter reach-through is absorbed into the bracket).
191
+
192
+ ### Why the two dispose call sites stay separate (structural-duplication check)
193
+
194
+ The issue asks to "collapse the three dispose paths into one."
195
+ Tracing why the two `dispose()` calls differ (per the code-design "structural reasons before extracting duplication" heuristic) shows they are **different lifecycle contexts**, not incidental duplication:
196
+
197
+ | Call site | Status source | Addendum | Error handling |
198
+ | ------------- | ------------------------------------------------- | --------------------------- | ----------------------------------------------------------------- |
199
+ | `completeRun` | derived from `result` (completed/aborted/steered) | folded into the result text | propagates (a dispose throw flows to `run()`'s catch → `failRun`) |
200
+ | `failRun` | hardcoded `"error"` | discarded | best-effort `try/catch` + `debugLog` |
201
+
202
+ So the resolution is: the dispose **logic** (the `if (prepared)` guard, the `.dispose()` call, the addendum unwrap) centralizes into `WorkspaceBracket.dispose()` — exactly one place, satisfying the issue's "disposal logic in exactly one place" outcome.
203
+ The two callers retain their distinct status derivation and error handling, because forcing them into one call would require a discriminator parameter that papers over a real lifecycle difference (Sandi Metz: "duplication is cheaper than the wrong abstraction").
204
+ `WorkspaceBracket.dispose()` deliberately does **not** wrap in `try/catch` — the best-effort behavior stays at `failRun`'s call site, preserving the existing semantics line-for-line (including the pre-existing success-path-throw → `failRun` re-dispose behavior).
205
+
206
+ ## Module-Level Changes
207
+
208
+ - **Added** `src/lifecycle/run-listeners.ts` — `RunListeners` class (`wireSignal`, `attachObserver`, `release`).
209
+ - **Added** `src/lifecycle/workspace-bracket.ts` — `WorkspaceBracket` class (`prepare`, `dispose`).
210
+ - **Added** `test/lifecycle/run-listeners.test.ts` — direct unit tests for `RunListeners`.
211
+ - **Added** `test/lifecycle/workspace-bracket.test.ts` — direct unit tests for `WorkspaceBracket`.
212
+ - **Changed** `src/lifecycle/subagent.ts`:
213
+ - Remove fields `_unsub`, `_detachFn`, `_workspace` and the public methods `wireSignal`, `attachObserver`, `releaseListeners`.
214
+ - Add `private readonly listeners = new RunListeners()` and `private readonly workspaceBracket: WorkspaceBracket` (constructed from `execution.getWorkspaceProvider`).
215
+ - Rewire `run()`, `resume()`, `resetForResume()`, `completeRun()`, `failRun()` to call `this.listeners.*` and `this.workspaceBracket.*`.
216
+ - Add imports for the two new modules; drop the now-unused `Workspace` type import if no longer referenced (keep `WorkspaceProvider` only if still referenced — verify with the type checker).
217
+ - **Changed** `test/lifecycle/subagent.test.ts`:
218
+ - Remove the `wireSignal`, `attachObserver / releaseListeners`, and `resetForResume releases listeners` `describe` blocks (their coverage moves to `run-listeners.test.ts`); the `run()`/`completeRun`/`failRun`/`resume` behavioral tests stay and continue to exercise the wired collaborators.
219
+ - Remove the `attachObserver(unsub)` calls inside the `completeRun`/`failRun` "releases listeners" tests — assert listener release via the run/resume path instead, or via a `RunListeners` unit test.
220
+
221
+ Doc updates (architecture references the moved symbols and module count):
222
+
223
+ - **Changed** `docs/architecture/architecture.md`:
224
+ - File-tree listing (`lifecycle/` block) — add `run-listeners.ts` and `workspace-bracket.ts` entries.
225
+ - `Subagent` class diagram (key domain types) — remove `+wireSignal`, `+attachObserver`, `+releaseListeners`; optionally add `RunListeners` / `WorkspaceBracket` classes with composition edges.
226
+ - Findings/health tables — bump `57 files` → `59 files` (lines ~650 and ~897).
227
+ - Phase 17 Step 4 entry — append a `Landed:` note recording the two collaborators and the final `subagent.ts` LOC.
228
+ - **Changed** `.pi/skills/package-pi-subagents/SKILL.md` — "seven domains (57 files)" → "(59 files)"; Lifecycle domain module count `11` → `13` and add the two modules to the directory list; update the test count if it is cited.
229
+
230
+ Grep confirmation done while planning: `wireSignal`/`attachObserver`/`releaseListeners`/`_workspace`/`_unsub`/`_detachFn` appear only in `subagent.ts` and `subagent.test.ts` (plus the architecture class diagram); the SKILL does not name them.
231
+ `disposeSession` (the one listener-adjacent method used by `subagent-manager.ts`) is **not** part of this change.
232
+
233
+ ## Test Impact Analysis
234
+
235
+ 1. **New unit tests the extraction enables.**
236
+ `RunListeners` and `WorkspaceBracket` become directly constructible and testable without booting a `Subagent` or a full `run()`:
237
+ - `run-listeners.test.ts` — `wireSignal` attaches and `release()` detaches the abort listener; `wireSignal(undefined, …)` is a no-op; `attachObserver` + `release()` calls and clears the unsub; `release()` is idempotent (double-call safe).
238
+ - `workspace-bracket.test.ts` — `prepare` with no provider returns `undefined`; with a provider returns `workspace.cwd`; with a provider resolving `undefined` returns `undefined`; `dispose` with no prepared workspace returns `""`; `dispose` returns the `resultAddendum` verbatim; `dispose` returns `""` when the workspace returns no addendum; a throwing `dispose` propagates (not swallowed).
239
+ 2. **Existing tests that become redundant.**
240
+ The `wireSignal`, `attachObserver / releaseListeners`, and `resetForResume releases listeners` `describe` blocks in `subagent.test.ts` (~7 tests) duplicate what `run-listeners.test.ts` now covers at a lower level — remove them.
241
+ 3. **Existing tests that must stay as-is.**
242
+ The `run()` workspace tests (prepare threads cwd into the factory, dispose status mapping for completed/error, addendum folding, no-provider path, prepare-failure path) genuinely exercise the *integration* of the bracket into the run lifecycle — they stay and verify the wiring preserved behavior.
243
+ The `completeRun`/`failRun`/`resume`/`abort` behavioral tests stay.
244
+
245
+ ## Invariants at risk
246
+
247
+ | Invariant (prior step) | Pinning test | Risk from this step |
248
+ | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
249
+ | Every spawned agent has a `promise` at spawn ([#381]) | `subagent-manager.test.ts` queued-agent regression test; `scheduleVia` unit tests | None — `start`/`scheduleVia`/`_promise` untouched |
250
+ | `Subagent` construct-complete; no "not configured for execution" throws ([#373]) | `subagent.test.ts` constructor tests + grep check | None — both collaborators constructed in the constructor, no new optional init fields |
251
+ | Zero external writes to `Subagent` fields ([#374]) | grep check (`\.promise =` / `\.notification =` only in `subagent.ts`) | None — removes fields/methods, adds no external writers |
252
+ | Listener release on complete/fail/resume; observer-callback order; dispose status mapping + addendum folding | `subagent.test.ts` `run()`/`completeRun`/`failRun`/`resume` blocks | Preserved by keeping the call sequence identical; run-suite pins it |
253
+
254
+ No invariant lives only in prose here — each is already pinned by a named test, and the run-lifecycle tests stay green through the wiring step.
255
+
256
+ ## TDD Order
257
+
258
+ 1. **Add `RunListeners` (red → green → commit).**
259
+ Write `test/lifecycle/run-listeners.test.ts` (fails — module absent), then implement `src/lifecycle/run-listeners.ts`.
260
+ Pure addition; `Subagent` not yet touched, suite otherwise unchanged.
261
+ Commit: `test: add RunListeners with wire/attach/release behavior (#375)` (or a single `refactor:` if test+impl land together — prefer the red/green split).
262
+ 2. **Add `WorkspaceBracket` (red → green → commit).**
263
+ Write `test/lifecycle/workspace-bracket.test.ts` (fails — module absent), then implement `src/lifecycle/workspace-bracket.ts`.
264
+ Pure addition.
265
+ Commit: `test: add WorkspaceBracket prepare/dispose behavior (#375)` then `refactor: add WorkspaceBracket collaborator (#375)` — or one `refactor:` commit for the red/green pair.
266
+ 3. **Wire both into `Subagent`; remove the raw fields/methods (atomic).**
267
+ Rewire `run`/`resume`/`resetForResume`/`completeRun`/`failRun` to the collaborators; remove `_unsub`/`_detachFn`/`_workspace` and the public `wireSignal`/`attachObserver`/`releaseListeners`; construct `listeners` and `workspaceBracket` in the constructor.
268
+ In the **same commit**, update `test/lifecycle/subagent.test.ts`: removing the three public methods breaks that file at the type level, so the deletion of the redundant `describe` blocks and the construction/wiring change must land together.
269
+ Run `pnpm run check` immediately after (interface-shape change).
270
+ Commit: `refactor: extract RunListeners and WorkspaceBracket from Subagent (#375)`.
271
+ 4. **Update docs (commit).**
272
+ `architecture.md` (file tree, class diagram, `57 → 59` file counts, Step 4 `Landed:` note with final LOC) and `package-pi-subagents` SKILL.md (file count, Lifecycle module list/count, test count if cited).
273
+ Commit: `docs: record run-listener and workspace-bracket extraction (#375)`.
274
+
275
+ Verification gates after step 3 and before review: `pnpm run check`, `pnpm run lint`, `pnpm -r run test`, `pnpm fallow dead-code` (confirm no orphaned imports in `subagent.test.ts` after the `describe` removals), and `wc -l src/lifecycle/subagent.ts` (assert ≤ 450).
276
+
277
+ ## Risks and Mitigations
278
+
279
+ - **Risk: the wiring step silently changes dispose error semantics.**
280
+ Mitigation: `WorkspaceBracket.dispose()` deliberately does not `try/catch`; the best-effort wrapper stays at `failRun`'s call site, preserving the success-path-throw → `failRun` behavior line-for-line.
281
+ A `workspace-bracket.test.ts` case asserts a throwing `dispose` propagates.
282
+ - **Risk: `subagent.ts` does not reach ≤ 450 LOC.**
283
+ Mitigation: removing 3 fields + 3 methods (≈ 25 lines incl. comments) and simplifying the two dispose blocks nets ≈ 40 lines below the current 488; `wc -l` is a gate.
284
+ If it lands at 451–455, that is an acceptable near-miss to note, not a blocker.
285
+ - **Risk: orphaned imports in `subagent.test.ts` after removing `describe` blocks.**
286
+ Mitigation: Biome `noUnusedImports` is warning-level (exit 0), so run `pnpm fallow dead-code` and re-check imports manually as part of step 3.
287
+ - **Risk: release cadence.**
288
+ This issue is internal-only; with `refactor:`/`docs:`/`test:` commits it will not trigger a release-please version bump — it ships bundled with the next feature release.
289
+ If a standalone release is desired (matching prior Phase 17 per-step cadence), that is a ship-time decision, not a planning one.
290
+
291
+ ## Open Questions
292
+
293
+ - Whether to add `RunListeners` / `WorkspaceBracket` as classes with composition edges to the architecture class diagram, or only update the file tree.
294
+ Defer to the doc step — add them if the diagram stays readable.
295
+ - The pre-existing untested success-path-`dispose`-throw → `failRun` re-dispose behavior is preserved but remains unpinned by a `Subagent`-level test.
296
+ Defer pinning it unless the implementer finds it cheap to add — the extraction does not change it.
297
+
298
+ [#373]: https://github.com/gotgenes/pi-packages/issues/373
299
+ [#374]: https://github.com/gotgenes/pi-packages/issues/374
300
+ [#381]: https://github.com/gotgenes/pi-packages/issues/381