@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 +14 -0
- package/README.md +9 -67
- package/docs/architecture/architecture.md +27 -20
- package/docs/comparison-with-upstream.md +76 -0
- package/docs/plans/0375-extract-run-listener-workspace-bracket.md +300 -0
- package/docs/plans/0376-extract-manager-observer.md +232 -0
- package/docs/retro/0374-encapsulate-subagent-start-notification.md +133 -0
- package/docs/retro/0375-extract-run-listener-workspace-bracket.md +104 -0
- package/docs/retro/0376-extract-manager-observer.md +40 -0
- package/package.json +1 -1
- package/src/index.ts +8 -57
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/subagent-manager.ts +5 -3
- package/src/lifecycle/subagent.ts +67 -94
- package/src/lifecycle/workspace-bracket.ts +59 -0
- package/src/observation/subagent-events-observer.ts +97 -0
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
|
-
>
|
|
10
|
-
>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
440
|
+
## Relationship to upstream
|
|
485
441
|
|
|
486
|
-
This
|
|
487
|
-
|
|
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
|
-
|
|
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
|
|
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 |
|
|
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) |
|
|
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()`
|
|
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 (`
|
|
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
|