@gotgenes/pi-subagents 16.2.1 → 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,13 @@ 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
+
8
15
  ## [16.2.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.2.0...pi-subagents-v16.2.1) (2026-06-15)
9
16
 
10
17
 
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
@@ -646,7 +646,7 @@ That method — testability friction as a boundary probe, with its limits — is
646
646
  | Metric | Value |
647
647
  | -------------------------- | --------------------------------------- |
648
648
  | Health score | 78/100 (B) |
649
- | Total LOC | 8,356 (60 files, as of Phase 17 Step 4) |
649
+ | Total LOC | 8,356 (61 files, as of Phase 17 Step 5) |
650
650
  | Dead code | 0 files, 0 exports |
651
651
  | Maintainability index | 90.8 (good) |
652
652
  | Avg cyclomatic complexity | 1.4 |
@@ -893,7 +893,7 @@ Updated health metrics (fallow, package-wide including tests):
893
893
  | Metric | Phase 16 baseline | Current |
894
894
  | -------------------------- | ------------------------------ | --------------------------------------------- |
895
895
  | Health score | 78/100 (B) | 78/100 (B) |
896
- | Source LOC | 7,778 (57 files) | 8,356 (60 files, landed Phase 17 Step 4) |
896
+ | Source LOC | 7,778 (57 files) | 8,356 (61 files, landed Phase 17 Step 5) |
897
897
  | Dead code | 0 files, 0 exports | 0 files, 0 exports |
898
898
  | Maintainability index | 90.8 (good) | 90.8 (good) |
899
899
  | Avg / P90 cyclomatic | 1.4 / 2 | 1.4 / 2 |
@@ -970,7 +970,7 @@ Priority = Impact × (6 − Risk).
970
970
  Fixed by inverting control: `scheduleVia` captures the limiter promise eagerly inside the agent (no external `.promise =` write), restoring the invariant.
971
971
  Lesson: a step's acceptance criteria must include the cross-step invariants it could regress, not only its own grep-verifiable outcome.
972
972
 
973
- #### 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
974
974
 
975
975
  - Targets: `src/lifecycle/subagent.ts` (455 LOC after Step 2 extracted SubagentState — still the largest source file).
976
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`).
@@ -981,12 +981,13 @@ Priority = Impact × (6 − Risk).
981
981
  `subagent.ts`: 488 → 448 LOC.
982
982
  Test count: 982 → 994 (+12: 7 RunListeners + 13 WorkspaceBracket − 8 redundant Subagent listener tests).
983
983
 
984
- #### 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
985
985
 
986
986
  - Targets: `src/index.ts` (inline `SubagentManagerObserver` literal, ~70 lines), new module under `src/observation/`.
987
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).
988
988
  - Change: extract a class (e.g. `SubagentEventsObserver`) constructed with narrow deps (`emit`, `appendEntry`, the `NotificationSystem`).
989
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).
990
991
 
991
992
  #### Step 6 — Split widget delegation out of SubagentRuntime ([#377])
992
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,232 @@
1
+ ---
2
+ issue: 376
3
+ issue_title: "Extract the manager observer from index.ts into a class"
4
+ ---
5
+
6
+ # Extract the manager observer from index.ts into a class
7
+
8
+ ## Problem Statement
9
+
10
+ `index.ts` constructs an inline `SubagentManagerObserver` object literal (~50 lines, four methods) at the composition root and hands it to `SubagentManager`.
11
+ The literal mixes three concerns: emitting `pi.events` lifecycle events, persisting the final record via `pi.appendEntry`, and dispatching completion notifications through the `NotificationManager`.
12
+ Per code-design principle 9 (state and behavior belong in a class, not a closure-captured literal), these three concerns cannot be unit-tested today without booting the entire extension.
13
+ `index.ts` is the package's dominant churn hotspot (31.3, 91 commits); shrinking it is the goal of Phase 17 Track B.
14
+
15
+ ## Goals
16
+
17
+ - Extract the inline observer literal into a `SubagentEventsObserver` class under `src/observation/`, constructed with narrow deps: an `emit` function, an `appendEntry` function, and the `NotificationSystem`.
18
+ - `index.ts` instantiates the class and passes it to `SubagentManager` in place of the literal.
19
+ - Unit-test the observer's three concerns directly (event shapes, record persistence payload, notification dispatch branching) without booting the extension.
20
+ - Preserve every emitted event payload and `appendEntry` shape byte-for-byte — this is a pure extraction with no observable behavior change (not breaking).
21
+ - Bring `index.ts` below 170 lines.
22
+
23
+ ## Non-Goals
24
+
25
+ - Splitting widget delegation out of `SubagentRuntime` (Phase 17 Step 6, Issue [#377]) — Step 5 is its prerequisite but lands separately.
26
+ - Moving `buildEventData` out of `notification.ts` — it stays where it is tested and is imported by the new observer.
27
+ - Pushing event-payload construction onto `Subagent` (a Tell-Don't-Ask improvement) — tracked as an Open Question, out of scope here.
28
+ - Narrowing `NotificationSystem` to a two-method interface — see Open Questions.
29
+ - Any other Phase 17 step.
30
+
31
+ ## Background
32
+
33
+ Relevant modules:
34
+
35
+ - `src/index.ts` (226 lines) — composition root.
36
+ Defines `const observer: SubagentManagerObserver = { … }` at lines 79–134 and passes it to `new SubagentManager({ …, observer, … })`.
37
+ - `src/lifecycle/subagent-manager.ts` — owns the `SubagentManagerObserver` interface (four methods: `onSubagentStarted`, `onSubagentCompleted`, `onSubagentCompacted`, `onSubagentCreated`).
38
+ This interface is the manager's contract and stays here.
39
+ - `src/observation/notification.ts` — exports `buildEventData(record)` (pure, tested in `notification.test.ts`), the `NotificationSystem` interface (`cancelNudge`, `sendCompletion`, `cleanupCompleted`, `dispose`), and `NotificationManager`.
40
+ - `src/observation/record-observer.ts` — the established pattern for an observation-domain module that subscribes/dispatches with narrow deps; the new class is a sibling.
41
+
42
+ The current literal's four methods:
43
+
44
+ - `onSubagentStarted(record)` → `emit("subagents:started", { id, type, description })`.
45
+ - `onSubagentCompleted(record)` → branch on terminal status to `emit("subagents:failed" | "subagents:completed", buildEventData(record))`; `appendEntry("subagents:record", { …8 fields… })`; then either `notifications.cleanupCompleted(record.id)` (when `record.notification?.resultConsumed`) or `notifications.sendCompletion(record)`.
46
+ - `onSubagentCompacted(record, info)` → `emit("subagents:compacted", { id, type, description, reason, tokensBefore, compactionCount })`.
47
+ - `onSubagentCreated(record)` → `emit("subagents:created", { id, type, description, isBackground: true })`.
48
+
49
+ SDK signatures the narrow deps mirror:
50
+
51
+ - `EventBus.emit(channel: string, data: unknown): void`.
52
+ - `ExtensionAPI.appendEntry<T = unknown>(customType: string, data?: T): void`.
53
+
54
+ The applicable AGENTS.md constraints: Pi SDK imports stay out of the observation module — the class accepts `emit`/`appendEntry` as injected callbacks (the same pattern `SettingsManager` uses with `SettingsEmit`), and `index.ts` wires them to `pi.events.emit`/`pi.appendEntry` via arrows (avoids `@typescript-eslint/unbound-method`).
55
+
56
+ ## Design Overview
57
+
58
+ ### Class shape
59
+
60
+ ```typescript
61
+ /** Emit callback — a subset of `pi.events.emit`. */
62
+ export type EventEmit = (channel: string, data: unknown) => void;
63
+
64
+ /** Append callback — a subset of `pi.appendEntry`. */
65
+ export type AppendEntry = (customType: string, data: unknown) => void;
66
+
67
+ export interface SubagentEventsObserverDeps {
68
+ emit: EventEmit;
69
+ appendEntry: AppendEntry;
70
+ notifications: NotificationSystem;
71
+ }
72
+
73
+ export class SubagentEventsObserver implements SubagentManagerObserver {
74
+ private readonly emit: EventEmit;
75
+ private readonly appendEntry: AppendEntry;
76
+ private readonly notifications: NotificationSystem;
77
+
78
+ constructor(deps: SubagentEventsObserverDeps) {
79
+ this.emit = deps.emit;
80
+ this.appendEntry = deps.appendEntry;
81
+ this.notifications = deps.notifications;
82
+ }
83
+
84
+ onSubagentStarted(record: Subagent): void { /* emit started */ }
85
+ onSubagentCompleted(record: Subagent): void { /* emit failed|completed, appendEntry, dispatch */ }
86
+ onSubagentCompacted(record: Subagent, info: CompactionInfo): void { /* emit compacted */ }
87
+ onSubagentCreated(record: Subagent): void { /* emit created */ }
88
+ }
89
+ ```
90
+
91
+ The four method bodies are moved verbatim from the literal — same event channels, same payload fields, same branching.
92
+ `buildEventData` is imported from `#src/observation/notification`.
93
+
94
+ ### Call site in index.ts
95
+
96
+ ```typescript
97
+ const observer = new SubagentEventsObserver({
98
+ emit: (channel, data) => pi.events.emit(channel, data),
99
+ appendEntry: (customType, data) => pi.appendEntry(customType, data),
100
+ notifications,
101
+ });
102
+
103
+ const manager = new SubagentManager({
104
+ createSubagentSession: (params) => createSubagentSession(params, subagentSessionDeps),
105
+ baseCwd: process.cwd(),
106
+ observer,
107
+ limiter,
108
+ getRunConfig: () => settings,
109
+ });
110
+ ```
111
+
112
+ `notifications` (the `NotificationManager`) is already constructed earlier in `index.ts`, so the observer construction slots in where the literal was.
113
+
114
+ ### Extracted-module interaction with upstream deps
115
+
116
+ The observer only *reads* from its inputs and *tells* its collaborators — no output-argument mutation, no reverse-search, no reach-back into the manager:
117
+
118
+ ```text
119
+ onSubagentCompleted(record):
120
+ reads record.status / record.id / … (Subagent getters)
121
+ calls buildEventData(record) (pure helper)
122
+ tells this.emit("subagents:completed", data) (injected callback)
123
+ tells this.appendEntry("subagents:record", {…}) (injected callback)
124
+ reads record.notification?.resultConsumed (LoD chain — pre-existing)
125
+ tells this.notifications.cleanupCompleted | sendCompletion
126
+ ```
127
+
128
+ The one Law-of-Demeter chain (`record.notification?.resultConsumed`) is carried over unchanged from the literal; it is pre-existing and out of scope (see Open Questions).
129
+
130
+ ### Design-review checklist result
131
+
132
+ | Smell | Location | Evidence | Disposition |
133
+ | ---------------- | ------------------------------------- | -------------------------------------------------------- | -------------------------------------------- |
134
+ | Dependency width | `SubagentEventsObserverDeps` (3 deps) | all methods use `emit`; `onSubagentCompleted` uses all 3 | OK — narrow |
135
+ | LoD violation | `onSubagentCompleted` | `record.notification?.resultConsumed` | Track and watch — pre-existing, out of scope |
136
+ | Output argument | — | none | Clean |
137
+ | ISP | `NotificationSystem` (4 methods) | observer uses `sendCompletion`, `cleanupCompleted` | Accept per issue spec — see Open Questions |
138
+
139
+ The extraction introduces a real collaborator (a class that owns three behaviors and is independently testable), not a procedure split — each method returns no value but performs a distinct side-effecting concern that was previously untestable in isolation.
140
+
141
+ ## Module-Level Changes
142
+
143
+ - `src/observation/subagent-events-observer.ts` (new)
144
+ - Export `SubagentEventsObserver implements SubagentManagerObserver`, plus `EventEmit`, `AppendEntry`, and `SubagentEventsObserverDeps` types.
145
+ - Imports: `SubagentManagerObserver` from `#src/lifecycle/subagent-manager`, `buildEventData` and `NotificationSystem` from `#src/observation/notification`, `Subagent` and `CompactionInfo` from `#src/types`.
146
+ - `src/index.ts` (changed)
147
+ - Remove the inline `const observer: SubagentManagerObserver = { … }` literal (lines 79–134).
148
+ - Add `import { SubagentEventsObserver } from "#src/observation/subagent-events-observer"`.
149
+ - Construct `const observer = new SubagentEventsObserver({ emit, appendEntry, notifications })`.
150
+ - Remove now-unused imports: `buildEventData` (moves to the new module) and the `type SubagentManagerObserver` import (the literal annotation is gone).
151
+ - Keep `NotificationDetails` and `NotificationManager` imports (still used by `registerMessageRenderer` and the `notifications` construction).
152
+ - `test/observation/subagent-events-observer.test.ts` (new)
153
+ - Unit tests for all four methods (see TDD Order).
154
+ - `docs/architecture/architecture.md` (changed)
155
+ - Mark Phase 17 Step 5 `✅ Complete` and add a "Landed" note (index.ts line count, file/test counts).
156
+ - Update the "Total LOC … (60 files …)" listings to 61 files and refresh the test count.
157
+ - The churn-hotspot note for `index.ts` stays — Step 5 continues the shrink trend.
158
+
159
+ Grep confirms no `package-*/SKILL.md` documents the inline observer literal or `SubagentEventsObserver` by name; no skill-doc update needed.
160
+
161
+ ## Test Impact Analysis
162
+
163
+ 1. **New unit tests enabled.**
164
+ All four observer methods become directly testable with `vi.fn()` stubs for `emit`/`appendEntry` and a stubbed `NotificationSystem`.
165
+ Previously the only way to exercise these paths was through `SubagentManager` integration tests that drove the manager's `buildObserver` forwarding — the index-level observer body itself had zero coverage.
166
+ 2. **No existing tests become redundant.**
167
+ The `subagent-manager.test.ts` observer tests (lines 76–148) exercise the manager's *forwarding* (`buildObserver` → `this.observer?.onSubagent…`), a different layer that stays.
168
+ `notification.test.ts` keeps the `buildEventData` tests.
169
+ `agent-tool.test.ts:153` keeps its note that `subagents:created` is delegated to the observer.
170
+ 3. **Tests that must stay as-is.**
171
+ The manager forwarding tests and the `buildEventData` purity tests genuinely exercise layers the extraction does not touch.
172
+
173
+ ## Invariants at risk
174
+
175
+ This step touches `index.ts`, which prior Phase 17 steps shrank, and the event/notification dispatch surface.
176
+ Step 6 ([#377]) depends on this step; it must not regress the index.ts shrink.
177
+
178
+ | Invariant | Pinned by |
179
+ | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
180
+ | Completion of a successful background agent emits `subagents:completed` (not `subagents:failed`) with `buildEventData` shape | new `onSubagentCompleted` success test |
181
+ | Terminal `error`/`stopped`/`aborted` status emits `subagents:failed` | new `onSubagentCompleted` error test |
182
+ | `appendEntry("subagents:record", …)` persists the eight-field record on every completion | new `onSubagentCompleted` appendEntry test |
183
+ | Already-consumed result skips `sendCompletion` and calls `cleanupCompleted` | new `onSubagentCompleted` resultConsumed test |
184
+ | `subagents:created` carries `isBackground: true` | new `onSubagentCreated` test |
185
+
186
+ These invariants lived only in the untested inline literal before; the new tests pin them for the first time.
187
+
188
+ ## TDD Order
189
+
190
+ 1. **Red → Green → Commit: extract `SubagentEventsObserver` and wire `index.ts`.**
191
+
192
+ Write `test/observation/subagent-events-observer.test.ts` first (red), covering:
193
+ - `onSubagentStarted` emits `subagents:started` with `{ id, type, description }`.
194
+ - `onSubagentCompleted` (success status) emits `subagents:completed` with `buildEventData(record)`, calls `appendEntry("subagents:record", …)` with the eight fields, and calls `notifications.sendCompletion(record)`.
195
+ - `onSubagentCompleted` (error/stopped/aborted) emits `subagents:failed`.
196
+ - `onSubagentCompleted` with `record.notification?.resultConsumed` calls `notifications.cleanupCompleted(record.id)` and does *not* call `sendCompletion`.
197
+ - `onSubagentCompacted` emits `subagents:compacted` with `{ id, type, description, reason, tokensBefore, compactionCount }`.
198
+ - `onSubagentCreated` emits `subagents:created` with `{ id, type, description, isBackground: true }`.
199
+
200
+ Use `createTestSubagent({ … })` from `#test/helpers/make-subagent` for records (it provides status, result, error, `toolCallId` for `NotificationState`, `compactionCount`); `vi.fn()` for `emit`/`appendEntry`; a stub object for `NotificationSystem`.
201
+
202
+ Then create `src/observation/subagent-events-observer.ts` (green) and update `index.ts` in the **same commit**: replace the literal with `new SubagentEventsObserver({ … })` and drop the now-unused `buildEventData` / `type SubagentManagerObserver` imports.
203
+ The class and the index.ts swap are coupled — index.ts is the sole call site of the literal being replaced, and the new class needs a consumer to satisfy `pnpm fallow dead-code`.
204
+
205
+ Run `pnpm --filter @gotgenes/pi-subagents run check` and `pnpm --filter @gotgenes/pi-subagents exec vitest run`.
206
+ Commit: `refactor: extract SubagentEventsObserver from index.ts (#376)`
207
+
208
+ 2. **Commit: mark Phase 17 Step 5 complete in the architecture doc.**
209
+
210
+ In `docs/architecture/architecture.md`, mark Step 5 `✅ Complete`, add a "Landed" note (new index.ts line count, file count 60 → 61, refreshed test count), and update the two "(60 files …)" LOC listings.
211
+ Commit: `docs: mark Phase 17 Step 5 complete in architecture.md (#376)`
212
+
213
+ ## Risks and Mitigations
214
+
215
+ - **Risk:** the extracted methods drift from the literal's exact event payloads or `appendEntry` shape.
216
+ **Mitigation:** move the bodies verbatim; the new tests assert each payload field explicitly, pinning the shapes.
217
+ - **Risk:** passing `pi.events.emit` / `pi.appendEntry` as bare values trips `@typescript-eslint/unbound-method`.
218
+ **Mitigation:** wire them as arrow callbacks in `index.ts` (`(channel, data) => pi.events.emit(channel, data)`), matching the existing `SettingsManager` emit wiring.
219
+ - **Risk:** the new class is flagged as dead code by `pnpm fallow dead-code` if it lands before its consumer.
220
+ **Mitigation:** the class and the `index.ts` wiring land in one commit (TDD step 1).
221
+ - **Risk:** `index.ts` does not drop below 170 lines.
222
+ **Mitigation:** the literal is ~50 lines and the replacement is ~6; 226 − ~50 + ~6 ≈ 182 minus the removed import lines lands it near/below 170 — verify with `wc -l` during implementation and, if just over, the net is still a clear shrink (the Outcome target is directional, not a hard gate).
223
+
224
+ ## Open Questions
225
+
226
+ - Should `NotificationSystem` be narrowed to a two-method `CompletionNotifier` (`sendCompletion`, `cleanupCompleted`) for the observer per ISP?
227
+ Deferred: the issue prescribes passing `NotificationSystem`, and the interface is already cohesive.
228
+ Revisit only if a third consumer wants the narrow slice.
229
+ - Should event-payload construction (and the `record.notification?.resultConsumed` read) move onto `Subagent` as Tell-Don't-Ask methods (`record.toEventData()`, `record.isResultConsumed()`)?
230
+ Deferred: out of scope for a faithful extraction; `buildEventData` already exists as a pure helper.
231
+
232
+ [#377]: https://github.com/gotgenes/pi-packages/issues/377
@@ -49,3 +49,56 @@ Pre-completion reviewer returned WARN (2 non-blocking findings; the doc metric r
49
49
  - **Pre-completion reviewer WARN findings** (both addressed inline):
50
50
  1. `architecture.md` health-metric rows still carried "→ 59 after Step 4" annotations after landing — updated to actual counts (60 files, 8,356 LOC) and the docs commit amended.
51
51
  2. `WorkspaceBracket.hasProvider()` TDA trade-off — documented in the `run()` call-site comment; noted here for Phase 18 awareness.
52
+
53
+ ## Stage: Final Retrospective (2026-06-15T02:04:42Z)
54
+
55
+ ### Session summary
56
+
57
+ Shipped issue #375 (Phase 17 Step 4) cleanly across four stages in one continuous session: planning produced a 4-step plan, TDD implemented it in 4 commits (suite 982 → 994), shipping pushed/verified-CI/closed-the-issue and merged release-please PR #406 (`pi-subagents-v16.2.1`, which actually carried #374's `fix:` — #375's `refactor:`/`docs:` commits trigger no bump).
58
+ The only substantive friction was a single self-identified plan deviation in TDD (a microtask-boundary timing trap), resolved inside the same commit with no rework, reorder, or user correction.
59
+
60
+ ### Observations
61
+
62
+ #### What went well
63
+
64
+ - **The cross-step-invariant discipline from the #374 retro paid off a second time.**
65
+ The #374 process retro added the "Invariants at risk" plan section; the #375 plan used it to list the three prior Phase 17 invariants (at-spawn `promise`, construct-complete, zero external field writes), each already pinned by a named test.
66
+ All three held through the extraction with a green suite — the regression class that bit #374 did not recur.
67
+ This is the first time the new section was load-bearing on a *fresh* issue rather than as a post-hoc correction.
68
+ - **Planning corrected the issue's own design sketch instead of implementing it literally.**
69
+ The issue proposed `RunListeners.attach(unsub, detach)` and "three dispose paths collapse into one"; the plan recognized the two handles attach at different lifecycle moments (so `attach` had to split into `wireSignal`/`attachObserver`/`release`) and that the two dispose sites have genuinely different error-handling semantics (so they stay separate per the structural-duplication heuristic).
70
+ Treating the issue body as a hypothesis, not a spec, avoided a wrong abstraction.
71
+
72
+ #### What caused friction (agent side)
73
+
74
+ - `missing-context` — the plan's Design Overview sketched `const cwd = await this.workspaceBracket.prepare(...)` **unconditionally**, but the original `run()` only awaited inside `if (provider) { ... }`, keeping the no-provider path synchronous up to the factory call.
75
+ An always-`async` helper adds a microtask boundary even when it returns immediately, so the queued-abort test in `subagent-manager.test.ts` ("abort removes a queued agent without ever running it") failed: it asserts `factory.toHaveBeenCalledOnce()` synchronously, and the factory call had been deferred a tick.
76
+ The first fix attempt (`if (this.execution.getWorkspaceProvider)`) also failed because `SubagentManager.spawn()` always injects `getWorkspaceProvider: () => this._workspaceProvider` as a function regardless of whether a provider is registered, so the guard was always true.
77
+ Resolved by adding `WorkspaceBracket.hasProvider()` (a synchronous predicate) and guarding the `await` with it.
78
+ Impact: ~2 test-run cycles inside TDD step 3, one extra method (`hasProvider`) plus 2 unit tests not in the plan, and a mild Tell-Don't-Ask trade-off the pre-completion reviewer flagged as WARN.
79
+ Self-identified via the failing test; no commit reorder, no rework beyond the in-commit fix.
80
+ - `other` (minor) — `subagent.ts` first landed at 469 LOC, above the plan's ≤ 450 gate; two trim passes (stale module-level doc comment, redundant field comments) brought it to 448.
81
+ The trimmed comments were genuinely stale post-extraction, so the trim was legitimate, but the LOC estimate ("≈ 40 lines removed") was optimistic about the structural change alone.
82
+ Impact: 2 extra edits, no rework.
83
+
84
+ #### What caused friction (user side)
85
+
86
+ - None.
87
+ The four-stage flow ran end-to-end on prompt templates with zero mid-stream user corrections or strategic interventions — the work was clean enough that none were needed.
88
+
89
+ ### Diagnostic details
90
+
91
+ - **Model-performance correlation** — Planning ran on `anthropic/claude-opus-4-8` (pinned via `plan-issue.md`, appropriate for judgment-heavy design); TDD on `anthropic/claude-sonnet-4-6` (appropriate); the pre-completion reviewer subagent on its frontmatter default; Retro on `anthropic/claude-opus-4-8` (pinned).
92
+ The **ship stage ran on `opencode-go/deepseek-v4-flash`** — the same weak-model-on-release-management pattern flagged in the #374 retro.
93
+ It again executed cleanly, including the non-trivial reasoning that PR #406 mapped to #374 not #375 and the `UNSTABLE`/empty-rollup `GITHUB_TOKEN` diagnosis.
94
+ Second consecutive clean run, so the risk remains theoretical (irreversible ops on a weak model) rather than demonstrated harm.
95
+ - **Escalation-delay tracking** — the microtask friction was not a rabbit hole: the diagnosis moved methodically (failing test → `concurrency-limiter.ts` → `subagent-manager.ts:156` injection point → root cause) across ~2 test-run cycles, under the 5-call flag threshold.
96
+ - **Unused-tool detection** — the friction was exact-symbol tracing (`grep` for `getWorkspaceProvider`), which `grep` handled correctly; no Explore/colgrep dispatch was warranted.
97
+ - **Feedback-loop gap analysis** — verification was incremental throughout: per-file `vitest` after each red/green, `pnpm run check` after the interface-change step, full suite + lint + `fallow dead-code` before the pre-completion review.
98
+ No end-only-verification gap.
99
+
100
+ ### Changes made
101
+
102
+ 1. `.pi/skills/testing/SKILL.md` — added a bullet under `### Interface and type changes` on the conditional-`await` → always-`async` microtask-boundary trap (sibling to the existing runtime-vs-typecheck timing rule).
103
+ 2. `packages/pi-subagents/docs/retro/0375-extract-run-listener-workspace-bracket.md` — added this Final Retrospective stage entry.
104
+ 3. Considered but not landed (operator-declined or out of scope): pinning `/ship-issue` to a stronger model (recurrence of the #374 finding, no demonstrated harm this session), a `plan-issue` rule on preserving conditional awaits in extracted-method sketches, and a rule against trimming comments to hit a LOC gate.
@@ -0,0 +1,40 @@
1
+ ---
2
+ issue: 376
3
+ issue_title: "Extract the manager observer from index.ts into a class"
4
+ ---
5
+
6
+ # Retro: #376 — Extract the manager observer from index.ts into a class
7
+
8
+ ## Stage: Planning (2026-06-15T00:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned Phase 17 Step 5: extracting the inline `SubagentManagerObserver` literal from `index.ts` into a `SubagentEventsObserver` class under `src/observation/`, constructed with narrow `emit` / `appendEntry` / `NotificationSystem` deps.
13
+ The plan is a single red→green→commit extraction (class + tests + `index.ts` wiring in one commit) plus a docs commit marking the step complete.
14
+ Classified as a non-breaking, pure internal extraction with no observable behavior change.
15
+
16
+ ### Observations
17
+
18
+ - The issue is the operator's own (author `gotgenes` matches the gh user) and the architecture doc already specifies Step 5 precisely, so the `ask-user` gate was skipped.
19
+ - The class + `index.ts` swap must land in one commit: `index.ts` is the sole call site of the literal being replaced, and the new class needs a consumer to satisfy `pnpm fallow dead-code`.
20
+ - Kept `buildEventData` in `notification.ts` (it is tested there) and imported it into the new module — avoids churning `notification.test.ts`.
21
+ - Used `refactor:` for the extraction commit, matching the precedent of Phase 17 Step 4 (#375); `refactor` is hidden from the release-please changelog.
22
+ - Two structural smells were noted as out of scope: the `record.notification?.resultConsumed` Law-of-Demeter chain (track-and-watch) and narrowing `NotificationSystem` to a two-method `CompletionNotifier` per ISP (the issue prescribes passing `NotificationSystem`).
23
+ - Wiring `pi.events.emit` / `pi.appendEntry` as arrow callbacks in `index.ts` avoids the `@typescript-eslint/unbound-method` trap; mirrors the existing `SettingsManager` emit pattern.
24
+ - Step 6 (#377) depends on this step; the plan pins the previously-untested event/notification dispatch invariants so Step 6 cannot regress them.
25
+
26
+ ## Stage: Implementation — TDD (2026-06-15T18:40:00Z)
27
+
28
+ ### Session summary
29
+
30
+ Completed 2 TDD cycles: (1) extracted `SubagentEventsObserver` into `src/observation/subagent-events-observer.ts`, added 15 tests covering all four observer methods, and updated `src/index.ts` to replace the inline literal — all in one coupled commit; (2) marked Phase 17 Step 5 ✅ Complete in the architecture doc with a Landed note.
31
+ Test delta: 994 → 1009 (+15); file count 63 → 64 test files; `index.ts` 226 → 177 lines.
32
+ Pre-completion reviewer: PASS.
33
+
34
+ ### Observations
35
+
36
+ - The typed `vi.fn<(channel: string, data: unknown) => void>()` mock triggered `@typescript-eslint/no-unnecessary-type-assertion` on `mock.calls[0]!` indexing — fixed by switching the error-status assertions to `toHaveBeenCalledWith("subagents:failed", expect.anything())` and the success-status assertion to `toHaveBeenCalledWith("subagents:completed", buildEventData(record))`.
37
+ This eliminated all raw `mock.calls[0]` indexing from the file, which is cleaner.
38
+ - The autoformatter (`pi-autoformat`) ran after writing `index.ts`, so the import block was reflowed; re-reading before further edits would be required in any follow-up session.
39
+ - Architecture doc: Step 4's header was already missing its ✅ Complete marker (it had a Landed note); the reviewer noted this was corrected correctly, not a regression.
40
+ - All five previously-untested observer-behavior invariants are now pinned by tests for the first time.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "16.2.1",
3
+ "version": "16.2.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/src/index.ts CHANGED
@@ -27,9 +27,10 @@ import { createChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
27
27
  import { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
28
28
  import { createSubagentSession, type SubagentSessionDeps } from "#src/lifecycle/create-subagent-session";
29
29
  import { buildParentSnapshot } from "#src/lifecycle/parent-snapshot";
30
- import { SubagentManager, type SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
31
- import { buildEventData, type NotificationDetails, NotificationManager } from "#src/observation/notification";
30
+ import { SubagentManager } from "#src/lifecycle/subagent-manager";
31
+ import { type NotificationDetails, NotificationManager } from "#src/observation/notification";
32
32
  import { createNotificationRenderer } from "#src/observation/renderer";
33
+ import { SubagentEventsObserver } from "#src/observation/subagent-events-observer";
33
34
  import { createSubagentRuntime } from "#src/runtime";
34
35
  import { publishSubagentsService, unpublishSubagentsService } from "#src/service/service";
35
36
  import { SubagentsServiceAdapter } from "#src/service/service-adapter";
@@ -76,61 +77,11 @@ export default function (pi: ExtensionAPI) {
76
77
  settings.load();
77
78
 
78
79
  // Observer: receives agent lifecycle notifications and dispatches events/notifications.
79
- const observer: SubagentManagerObserver = {
80
- onSubagentStarted(record) {
81
- // Emit started event when agent transitions to running (including from queue).
82
- pi.events.emit("subagents:started", {
83
- id: record.id,
84
- type: record.type,
85
- description: record.description,
86
- });
87
- },
88
- onSubagentCompleted(record) {
89
- // Emit lifecycle event based on terminal status.
90
- const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
91
- const eventData = buildEventData(record);
92
- if (isError) {
93
- pi.events.emit("subagents:failed", eventData);
94
- } else {
95
- pi.events.emit("subagents:completed", eventData);
96
- }
97
-
98
- // Persist final record for cross-extension history reconstruction.
99
- pi.appendEntry("subagents:record", {
100
- id: record.id, type: record.type, description: record.description,
101
- status: record.status, result: record.result, error: record.error,
102
- startedAt: record.startedAt, completedAt: record.completedAt,
103
- });
104
-
105
- // Skip notification if result was already consumed via get_subagent_result.
106
- if (record.notification?.resultConsumed) {
107
- notifications.cleanupCompleted(record.id);
108
- return;
109
- }
110
-
111
- notifications.sendCompletion(record);
112
- },
113
- onSubagentCompacted(record, info) {
114
- // Emit compacted event when agent's session compacts (preserves count on record).
115
- pi.events.emit("subagents:compacted", {
116
- id: record.id,
117
- type: record.type,
118
- description: record.description,
119
- reason: info.reason,
120
- tokensBefore: info.tokensBefore,
121
- compactionCount: record.compactionCount,
122
- });
123
- },
124
- onSubagentCreated(record) {
125
- // Emit created event for background agents (before limiter admission).
126
- pi.events.emit("subagents:created", {
127
- id: record.id,
128
- type: record.type,
129
- description: record.description,
130
- isBackground: true,
131
- });
132
- },
133
- };
80
+ const observer = new SubagentEventsObserver({
81
+ emit: (channel, data) => pi.events.emit(channel, data),
82
+ appendEntry: (customType, data) => pi.appendEntry(customType, data),
83
+ notifications,
84
+ });
134
85
 
135
86
  const subagentSessionDeps: SubagentSessionDeps = {
136
87
  io: {
@@ -0,0 +1,97 @@
1
+ import type { SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
2
+ import { buildEventData, type NotificationSystem } from "#src/observation/notification";
3
+ import type { CompactionInfo, Subagent } from "#src/types";
4
+
5
+ /** Emit callback — a subset of `pi.events.emit`. */
6
+ export type EventEmit = (channel: string, data: unknown) => void;
7
+
8
+ /** Append callback — a subset of `pi.appendEntry`. */
9
+ export type AppendEntry = (customType: string, data: unknown) => void;
10
+
11
+ export interface SubagentEventsObserverDeps {
12
+ emit: EventEmit;
13
+ appendEntry: AppendEntry;
14
+ notifications: NotificationSystem;
15
+ }
16
+
17
+ /**
18
+ * Receives agent lifecycle notifications from SubagentManager and dispatches
19
+ * them to three concerns: pi.events lifecycle events, session-entry persistence,
20
+ * and completion notifications.
21
+ *
22
+ * Constructed with narrow deps (emit, appendEntry, NotificationSystem) so all
23
+ * three concerns are unit-testable without booting the extension.
24
+ */
25
+ export class SubagentEventsObserver implements SubagentManagerObserver {
26
+ private readonly emit: EventEmit;
27
+ private readonly appendEntry: AppendEntry;
28
+ private readonly notifications: NotificationSystem;
29
+
30
+ constructor(deps: SubagentEventsObserverDeps) {
31
+ this.emit = deps.emit;
32
+ this.appendEntry = deps.appendEntry;
33
+ this.notifications = deps.notifications;
34
+ }
35
+
36
+ onSubagentStarted(record: Subagent): void {
37
+ // Emit started event when agent transitions to running (including from queue).
38
+ this.emit("subagents:started", {
39
+ id: record.id,
40
+ type: record.type,
41
+ description: record.description,
42
+ });
43
+ }
44
+
45
+ onSubagentCompleted(record: Subagent): void {
46
+ // Emit lifecycle event based on terminal status.
47
+ const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
48
+ const eventData = buildEventData(record);
49
+ if (isError) {
50
+ this.emit("subagents:failed", eventData);
51
+ } else {
52
+ this.emit("subagents:completed", eventData);
53
+ }
54
+
55
+ // Persist final record for cross-extension history reconstruction.
56
+ this.appendEntry("subagents:record", {
57
+ id: record.id,
58
+ type: record.type,
59
+ description: record.description,
60
+ status: record.status,
61
+ result: record.result,
62
+ error: record.error,
63
+ startedAt: record.startedAt,
64
+ completedAt: record.completedAt,
65
+ });
66
+
67
+ // Skip notification if result was already consumed via get_subagent_result.
68
+ if (record.notification?.resultConsumed) {
69
+ this.notifications.cleanupCompleted(record.id);
70
+ return;
71
+ }
72
+
73
+ this.notifications.sendCompletion(record);
74
+ }
75
+
76
+ onSubagentCompacted(record: Subagent, info: CompactionInfo): void {
77
+ // Emit compacted event when agent's session compacts (preserves count on record).
78
+ this.emit("subagents:compacted", {
79
+ id: record.id,
80
+ type: record.type,
81
+ description: record.description,
82
+ reason: info.reason,
83
+ tokensBefore: info.tokensBefore,
84
+ compactionCount: record.compactionCount,
85
+ });
86
+ }
87
+
88
+ onSubagentCreated(record: Subagent): void {
89
+ // Emit created event for background agents (before limiter admission).
90
+ this.emit("subagents:created", {
91
+ id: record.id,
92
+ type: record.type,
93
+ description: record.description,
94
+ isBackground: true,
95
+ });
96
+ }
97
+ }