@gotgenes/pi-subagents 5.6.0 → 5.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/docs/architecture/architecture.md +46 -38
- package/docs/plans/0070-extract-event-handlers.md +306 -0
- package/docs/plans/0087-evolve-subagent-runtime-methods.md +240 -0
- package/docs/retro/0072-inject-agent-manager-collaborators.md +46 -0
- package/package.json +1 -1
- package/src/index.ts +10 -10
- package/src/runtime.ts +48 -16
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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
|
+
## [5.7.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v5.6.0...pi-subagents-v5.7.0) (2026-05-20)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add session-context methods to SubagentRuntime ([3bbc6af](https://github.com/gotgenes/pi-packages/commit/3bbc6af5c3d9e6faef2112f6c92f991c4b27e38d))
|
|
14
|
+
* add widget delegation methods to SubagentRuntime ([36350f4](https://github.com/gotgenes/pi-packages/commit/36350f413f44f432b14b0a42e27df2c8f5444a08))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* add [#87](https://github.com/gotgenes/pi-packages/issues/87) to architecture roadmap and fix package scope hallucinations ([ddee1a0](https://github.com/gotgenes/pi-packages/commit/ddee1a011acc3e20d17d2bdf46cc4603604c092d))
|
|
20
|
+
* plan evolving SubagentRuntime from data bag to object with methods ([#87](https://github.com/gotgenes/pi-packages/issues/87)) ([4f2f16a](https://github.com/gotgenes/pi-packages/commit/4f2f16a8eea67e4959a754d172a9238b4b8662b1))
|
|
21
|
+
* plan extract event handlers from index.ts ([#70](https://github.com/gotgenes/pi-packages/issues/70)) ([5fc115f](https://github.com/gotgenes/pi-packages/commit/5fc115f61bc52c5c88630ea7c869e1e34bde0130))
|
|
22
|
+
* **retro:** add retro notes for issue [#72](https://github.com/gotgenes/pi-packages/issues/72) ([5b53189](https://github.com/gotgenes/pi-packages/commit/5b53189d6242b6f2262b9e7cd2d9dbdbe2a28c60))
|
|
23
|
+
* update architecture roadmap with [#72](https://github.com/gotgenes/pi-packages/issues/72), [#76](https://github.com/gotgenes/pi-packages/issues/76), [#80](https://github.com/gotgenes/pi-packages/issues/80), [#84](https://github.com/gotgenes/pi-packages/issues/84) status ([176cb68](https://github.com/gotgenes/pi-packages/commit/176cb682001b21b7c223656b353b8cf2af412b0c))
|
|
24
|
+
|
|
8
25
|
## [5.6.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v5.5.0...pi-subagents-v5.6.0) (2026-05-20)
|
|
9
26
|
|
|
10
27
|
|
|
@@ -59,7 +59,7 @@ There is also a `Symbol.for("pi-subagents:manager")` export on `globalThis` that
|
|
|
59
59
|
|
|
60
60
|
```text
|
|
61
61
|
┌────────────────────────────────────────────────────────┐
|
|
62
|
-
│ @
|
|
62
|
+
│ @gotgenes/pi-subagents (this package) │
|
|
63
63
|
│ │
|
|
64
64
|
│ Exports: │
|
|
65
65
|
│ SubagentsAPI interface │
|
|
@@ -131,7 +131,7 @@ After removal and `index.ts` decomposition, the core shrinks from ~6,300 to ~5,4
|
|
|
131
131
|
|
|
132
132
|
## SubagentsAPI
|
|
133
133
|
|
|
134
|
-
The `SubagentsAPI` interface, accessor functions, and serializable types are exported directly from this package (`@
|
|
134
|
+
The `SubagentsAPI` interface, accessor functions, and serializable types are exported directly from this package (`@gotgenes/pi-subagents`).
|
|
135
135
|
No separate API package is needed.
|
|
136
136
|
|
|
137
137
|
Consumers declare this package as an optional peer dependency:
|
|
@@ -139,10 +139,10 @@ Consumers declare this package as an optional peer dependency:
|
|
|
139
139
|
```json
|
|
140
140
|
{
|
|
141
141
|
"peerDependencies": {
|
|
142
|
-
"@
|
|
142
|
+
"@gotgenes/pi-subagents": ">=2.0.0"
|
|
143
143
|
},
|
|
144
144
|
"peerDependenciesMeta": {
|
|
145
|
-
"@
|
|
145
|
+
"@gotgenes/pi-subagents": { "optional": true }
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
```
|
|
@@ -150,7 +150,7 @@ Consumers declare this package as an optional peer dependency:
|
|
|
150
150
|
At runtime, consumers use dynamic import for type-safe access to the accessor functions:
|
|
151
151
|
|
|
152
152
|
```typescript
|
|
153
|
-
const { getSubagentsAPI } = await import("@
|
|
153
|
+
const { getSubagentsAPI } = await import("@gotgenes/pi-subagents");
|
|
154
154
|
const api = getSubagentsAPI();
|
|
155
155
|
if (api) {
|
|
156
156
|
api.spawn("Explore", "Check for stale TODOs");
|
|
@@ -238,14 +238,14 @@ They are fire-and-forget broadcast events — no request IDs, no reply channels.
|
|
|
238
238
|
|
|
239
239
|
```typescript
|
|
240
240
|
// package.json:
|
|
241
|
-
// "peerDependencies": { "@
|
|
242
|
-
// "peerDependenciesMeta": { "@
|
|
241
|
+
// "peerDependencies": { "@gotgenes/pi-subagents": ">=2.0.0" }
|
|
242
|
+
// "peerDependenciesMeta": { "@gotgenes/pi-subagents": { "optional": true } }
|
|
243
243
|
|
|
244
244
|
export default function (pi) {
|
|
245
245
|
pi.on("session_start", async (event, ctx) => {
|
|
246
246
|
let getSubagentsAPI;
|
|
247
247
|
try {
|
|
248
|
-
({ getSubagentsAPI } = await import("@
|
|
248
|
+
({ getSubagentsAPI } = await import("@gotgenes/pi-subagents"));
|
|
249
249
|
} catch {
|
|
250
250
|
return; // pi-subagents not installed
|
|
251
251
|
}
|
|
@@ -269,7 +269,7 @@ export default function (pi) {
|
|
|
269
269
|
const { id } = data as { id: string };
|
|
270
270
|
let getSubagentsAPI;
|
|
271
271
|
try {
|
|
272
|
-
({ getSubagentsAPI } = await import("@
|
|
272
|
+
({ getSubagentsAPI } = await import("@gotgenes/pi-subagents"));
|
|
273
273
|
} catch {
|
|
274
274
|
return;
|
|
275
275
|
}
|
|
@@ -330,7 +330,7 @@ Resolve model strings inside the adapter (fixing upstream [tintinweb/pi-subagent
|
|
|
330
330
|
Extracted tools, notifications, activity tracking, and the `/agents` command into separate modules.
|
|
331
331
|
`src/index.ts` shrank from ~1,619 lines to ~265 lines.
|
|
332
332
|
|
|
333
|
-
### Phase 6 (future): Extract UI to `@
|
|
333
|
+
### Phase 6 (future): Extract UI to `@gotgenes/pi-subagents-ui`
|
|
334
334
|
|
|
335
335
|
Move `ui/agent-widget.ts`, `ui/conversation-viewer.ts`, the `/agents` command, notifications, and activity tracking to a separate extension that consumes `SubagentsAPI` + lifecycle events.
|
|
336
336
|
This phase is deferred until the API boundary is proven stable in production.
|
|
@@ -353,44 +353,53 @@ Together they eliminate module-scope mutable state, create a testable functional
|
|
|
353
353
|
- Split `runAgent()` into a pure configuration assembler (~200 lines) and an IO shell (~200 lines).
|
|
354
354
|
- The assembler becomes independently testable without mocking the Pi SDK.
|
|
355
355
|
|
|
356
|
-
3. **gotgenes/pi-packages#76** — Inject `cwd` into `AgentManager`
|
|
357
|
-
-
|
|
358
|
-
- A small, mechanical prerequisite for Issue #72.
|
|
356
|
+
3. **gotgenes/pi-packages#76** ✓ — Inject `cwd` into `AgentManager`
|
|
357
|
+
- Replaced the `process.cwd()` call in `dispose()` with a constructor parameter.
|
|
359
358
|
|
|
360
|
-
4. **gotgenes/pi-packages#80** — Consolidate `getConfig` / `getAgentConfig` into a single resolution path
|
|
361
|
-
-
|
|
362
|
-
-
|
|
359
|
+
4. **gotgenes/pi-packages#80** ✓ — Consolidate `getConfig` / `getAgentConfig` into a single resolution path
|
|
360
|
+
- Replaced the two overlapping lookup functions with a single `resolveAgentConfig(type): AgentConfig` that handles the unknown-type fallback internally.
|
|
361
|
+
- Eliminated the duplicated fallback chain exposed by #71 and simplified test mock setup.
|
|
363
362
|
|
|
364
363
|
### Phase 2: Core decomposition
|
|
365
364
|
|
|
366
365
|
These build on Phase 1 and should land after it.
|
|
367
366
|
|
|
368
|
-
|
|
369
|
-
-
|
|
370
|
-
-
|
|
367
|
+
1. **gotgenes/pi-packages#84** ✓ — Extract `GitWorktreeManager` class from `worktree.ts`
|
|
368
|
+
- Added `WorktreeManager` interface and `GitWorktreeManager` class that captures `cwd` at construction.
|
|
369
|
+
- Prerequisite for #72 — separated the real-object extraction from the DI refactor.
|
|
371
370
|
|
|
372
|
-
|
|
371
|
+
2. **gotgenes/pi-packages#72** ✓ — Dependency-inject `AgentManager`'s collaborators
|
|
372
|
+
- Defined `AgentRunner` interface (execution boundary) and `ResumeOptions` type in `agent-runner.ts`.
|
|
373
|
+
- Converted `AgentManager` constructor from 6 positional parameters to an `AgentManagerOptions` bag with injected `AgentRunner` and `WorktreeManager`.
|
|
374
|
+
- Removed all runtime imports of `agent-runner.ts` and `worktree.ts` from `agent-manager.ts` (only `import type` remains).
|
|
375
|
+
- Migrated all tests from `vi.mock()` module stubs to `vi.fn()` interface stubs.
|
|
376
|
+
|
|
377
|
+
3. **gotgenes/pi-packages#87** — Evolve `SubagentRuntime` from data bag to object with methods
|
|
378
|
+
- Add session-context methods (`setSessionContext`, `clearSessionContext`) and widget delegation methods (`setUICtx`, `onTurnStart`, `markFinished`, `updateWidget`, `ensureTimer`).
|
|
379
|
+
- Prerequisite for #70 — without runtime methods, extracted handlers would move LoD violations and output-argument smells into handler classes.
|
|
380
|
+
|
|
381
|
+
4. **gotgenes/pi-packages#70** — Extract event handlers into `src/handlers/`
|
|
373
382
|
- Move the four inline lambdas (`session_start`, `session_before_switch`, `session_shutdown`, `tool_execution_start`) into named handler modules.
|
|
374
|
-
- Requires
|
|
383
|
+
- Requires Issues #69 and #87 because handlers need the `SubagentRuntime` with methods as their deps.
|
|
375
384
|
- Target: `src/index.ts` ≤150 lines.
|
|
376
385
|
|
|
377
386
|
### Phase 3: Interface polish
|
|
378
387
|
|
|
379
388
|
Small cleanups that are safest after the structural changes settle.
|
|
380
389
|
|
|
381
|
-
|
|
390
|
+
1. **gotgenes/pi-packages#66** — Replace `as any` casts with proper SDK types
|
|
382
391
|
- Type-only change in the tool/menu factory dep interfaces.
|
|
383
392
|
- Best done after Issues #69 and #70 when the interfaces are stable.
|
|
384
393
|
|
|
385
|
-
|
|
394
|
+
2. **gotgenes/pi-packages#77** — Add `projectAgentsDir` to `AgentMenuDeps`
|
|
386
395
|
- Remove the inline `process.cwd()` lambda from the menu handler.
|
|
387
396
|
|
|
388
397
|
### Phase 4: Features and cross-cutting concerns
|
|
389
398
|
|
|
390
|
-
|
|
399
|
+
1. **gotgenes/pi-packages#61** — Port transcript logging to Pi's official JSONL session format
|
|
391
400
|
- Feature work that should happen after structural refactoring is complete so the output-file subsystem has a stable home.
|
|
392
401
|
|
|
393
|
-
|
|
402
|
+
2. **gotgenes/pi-packages#22** — Parent-session resolution for `nicobailon/pi-subagents` children
|
|
394
403
|
- Cross-extension issue that spans `pi-permission-system` and `pi-subagents`.
|
|
395
404
|
- Requires coordination on env-var conventions.
|
|
396
405
|
- Not blocked by the structural refactor but logically separate from it.
|
|
@@ -398,15 +407,13 @@ Small cleanups that are safest after the structural changes settle.
|
|
|
398
407
|
### Dependency graph
|
|
399
408
|
|
|
400
409
|
```text
|
|
401
|
-
#69 (SubagentRuntime) ✓ ─┬─► #70 (handler extraction)
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
#
|
|
406
|
-
|
|
407
|
-
#
|
|
408
|
-
|
|
409
|
-
#80 (config lookup) ────(independent, simplifies #72 and test mocks)
|
|
410
|
+
#69 (SubagentRuntime) ✓ ──► #87 (runtime methods) ─┬─► #70 (handler extraction)
|
|
411
|
+
│
|
|
412
|
+
#71 (pure assembler) ✓ │
|
|
413
|
+
#80 (config lookup) ✓ │
|
|
414
|
+
#76 (cwd injection) ✓ │
|
|
415
|
+
#84 (WorktreeManager) ✓ │
|
|
416
|
+
#72 (AgentManager DI) ✓ ────────────────────────────┘──(optional)──► #70
|
|
410
417
|
|
|
411
418
|
#66 (type casts) ◄─────(after structural changes settle)
|
|
412
419
|
#77 (projectAgentsDir) ◄─(after #66 or parallel)
|
|
@@ -420,15 +427,16 @@ Small cleanups that are safest after the structural changes settle.
|
|
|
420
427
|
The recommended sequence is:
|
|
421
428
|
|
|
422
429
|
```text
|
|
423
|
-
#69 ✓ → #71 ✓ → #80 → #76 → #72 → #70 → #66 → #77 → #61
|
|
430
|
+
#69 ✓ → #71 ✓ → #80 ✓ → #76 ✓ → #84 ✓ → #72 ✓ → #87 → #70 → #66 → #77 → #61
|
|
424
431
|
```
|
|
425
432
|
|
|
426
|
-
|
|
433
|
+
Phase 1 is complete; Phase 2 is in progress.
|
|
434
|
+
The next issue is #87 (runtime methods), which unblocks #70 (handler extraction).
|
|
427
435
|
Issue #22 is a parallel cross-extension track and does not gate the structural work.
|
|
428
436
|
|
|
429
437
|
## Relationship with upstream
|
|
430
438
|
|
|
431
|
-
This fork ([
|
|
439
|
+
This fork (`@gotgenes/pi-subagents` in the [gotgenes/pi-packages] monorepo) is now a hard fork of [tintinweb/pi-subagents].
|
|
432
440
|
The decomposition diverges materially from upstream's direction.
|
|
433
441
|
|
|
434
442
|
The three upstream PRs (#71, #72, #73) remain open.
|
|
@@ -439,5 +447,5 @@ Upstream fixes and ideas are cherry-picked when they align with this fork's scop
|
|
|
439
447
|
The upstream test suite is run periodically as a regression canary for the agent-runner core.
|
|
440
448
|
|
|
441
449
|
[earendil-works/pi#4207]: https://github.com/earendil-works/pi/issues/4207
|
|
442
|
-
[
|
|
450
|
+
[gotgenes/pi-packages]: https://github.com/gotgenes/pi-packages
|
|
443
451
|
[tintinweb/pi-subagents]: https://github.com/tintinweb/pi-subagents
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 70
|
|
3
|
+
issue_title: "refactor: extract event handlers from pi-subagents index.ts into src/handlers/"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extract event handlers from index.ts
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
After #54 and #69, `src/index.ts` is still ~281 lines.
|
|
11
|
+
Four event handlers (`session_start`, `session_before_switch`, `session_shutdown`, `tool_execution_start`) are inline lambdas inside the factory closure.
|
|
12
|
+
They cannot be tested in isolation because they reach into closure-scoped objects (`runtime`, `manager`, `notifications`, `pi`).
|
|
13
|
+
`pi-permission-system` solved the same problem in #42 by extracting all handlers into `src/handlers/` as classes receiving narrow constructor-injected dependencies.
|
|
14
|
+
|
|
15
|
+
## Goals
|
|
16
|
+
|
|
17
|
+
- Create `src/handlers/` with dedicated modules for each handler group.
|
|
18
|
+
- Define handler classes with constructor-injected narrow interfaces that replace closure-captured state with explicit, testable contracts.
|
|
19
|
+
- Reduce `src/index.ts` by moving handler bodies out — target ≤ 200 lines (the remaining bulk is tool/menu wiring, which is out of scope).
|
|
20
|
+
- Enable unit testing of each handler module via mocked deps.
|
|
21
|
+
- No behavior change — pure structural refactor.
|
|
22
|
+
|
|
23
|
+
## Non-Goals
|
|
24
|
+
|
|
25
|
+
- Extracting tool registration or menu wiring from `index.ts` (separate concern; not in scope).
|
|
26
|
+
- Changing the `SubagentsService` interface.
|
|
27
|
+
- Refactoring `AgentManager` constructor signature.
|
|
28
|
+
- Consolidating notification callbacks — the notification system's shape is unchanged.
|
|
29
|
+
|
|
30
|
+
## Background
|
|
31
|
+
|
|
32
|
+
### Current handler bodies in index.ts
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// session_start (~3 lines)
|
|
36
|
+
runtime.currentCtx = { pi, ctx };
|
|
37
|
+
manager.clearCompleted();
|
|
38
|
+
|
|
39
|
+
// session_before_switch (~1 line)
|
|
40
|
+
manager.clearCompleted();
|
|
41
|
+
|
|
42
|
+
// session_shutdown (~5 lines)
|
|
43
|
+
unpublishSubagentsService();
|
|
44
|
+
runtime.currentCtx = undefined;
|
|
45
|
+
manager.abortAll();
|
|
46
|
+
notifications.dispose();
|
|
47
|
+
manager.dispose();
|
|
48
|
+
|
|
49
|
+
// tool_execution_start (~2 lines)
|
|
50
|
+
runtime.widget!.setUICtx(ctx.ui as UICtx);
|
|
51
|
+
runtime.widget!.onTurnStart();
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Prior art: pi-permission-system src/handlers/
|
|
55
|
+
|
|
56
|
+
Issue #42 extracted lifecycle, agent-prep, and permission-gate handlers into classes that receive narrow constructor deps.
|
|
57
|
+
Each class exposes handler methods matching the Pi SDK event signatures.
|
|
58
|
+
`index.ts` constructs the handler objects and wires `pi.on(event, handler.method)`.
|
|
59
|
+
|
|
60
|
+
This plan follows the same class-based pattern.
|
|
61
|
+
Although the individual handler bodies are small (1–5 lines), the lifecycle handlers share `runtime` and `manager` as collaborators — shared state that a class captures naturally via constructor injection rather than threading through each call.
|
|
62
|
+
|
|
63
|
+
### Prerequisite status
|
|
64
|
+
|
|
65
|
+
- Issue #69 (SubagentRuntime) — **closed / implemented**.
|
|
66
|
+
The runtime object exists in `src/runtime.ts` and is already wired into `index.ts`.
|
|
67
|
+
- Issue #87 (evolve SubagentRuntime from data bag to object with methods) — **open / must land first**.
|
|
68
|
+
Adds `setSessionContext()` / `clearSessionContext()` and widget delegation methods (`setUICtx()`, `onTurnStart()`, `markFinished()`, `updateWidget()`, `ensureTimer()`).
|
|
69
|
+
Without #87, extracted handlers would just move the output-argument and LoD smells from `index.ts` into handler classes.
|
|
70
|
+
With #87, handlers call methods on narrow runtime interfaces — no raw field writes, no `widget!` reach-throughs.
|
|
71
|
+
|
|
72
|
+
### Relevant constraints from AGENTS.md / code-style skill
|
|
73
|
+
|
|
74
|
+
- Keep modules focused and composable (one concern per file).
|
|
75
|
+
- Do not pass a shared dependency bag to functions that only use a subset — define narrow interfaces per consumer.
|
|
76
|
+
- Keep Pi SDK imports out of business-logic modules — handler modules should accept lean local payload interfaces, not full SDK event types.
|
|
77
|
+
- Prefer explicit configuration over hidden behavior.
|
|
78
|
+
|
|
79
|
+
## Design Overview
|
|
80
|
+
|
|
81
|
+
### Module layout
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
src/handlers/
|
|
85
|
+
lifecycle.ts # session_start, session_before_switch, session_shutdown
|
|
86
|
+
tool-start.ts # tool_execution_start
|
|
87
|
+
index.ts # barrel re-export
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
No shared `types.ts` file — each module defines its own narrow constructor interfaces.
|
|
91
|
+
This follows the code-style guidance ("do not pass a shared dependency bag to functions that only use a subset") and matches the permission-system's prior art where each handler class takes its own narrow constructor deps.
|
|
92
|
+
|
|
93
|
+
### Narrow constructor interfaces
|
|
94
|
+
|
|
95
|
+
Each class defines local interfaces for its collaborators, exposing only the methods the class actually calls.
|
|
96
|
+
This keeps tests simple (mock only what's used) and decouples handlers from concrete types.
|
|
97
|
+
|
|
98
|
+
#### SessionLifecycleHandler (in lifecycle.ts)
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
/** Narrow manager interface — only the methods lifecycle handlers call. */
|
|
102
|
+
export interface LifecycleManager {
|
|
103
|
+
clearCompleted(): void;
|
|
104
|
+
abortAll(): void;
|
|
105
|
+
dispose(): void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Narrow runtime interface — only the methods lifecycle handlers call. */
|
|
109
|
+
export interface LifecycleRuntime {
|
|
110
|
+
setSessionContext(pi: unknown, ctx: unknown): void;
|
|
111
|
+
clearSessionContext(): void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class SessionLifecycleHandler {
|
|
115
|
+
constructor(
|
|
116
|
+
private readonly pi: unknown,
|
|
117
|
+
private readonly runtime: LifecycleRuntime,
|
|
118
|
+
private readonly manager: LifecycleManager,
|
|
119
|
+
private readonly disposeNotifications: () => void,
|
|
120
|
+
private readonly unpublishService: () => void,
|
|
121
|
+
) {}
|
|
122
|
+
|
|
123
|
+
handleSessionStart(_event: unknown, ctx: unknown): void { ... }
|
|
124
|
+
handleSessionBeforeSwitch(): void { ... }
|
|
125
|
+
async handleSessionShutdown(): Promise<void> { ... }
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Five constructor params — `runtime` and `manager` are shared across all three methods (the key insight the plain-function design missed), while `disposeNotifications` and `unpublishService` are shutdown-only callbacks.
|
|
130
|
+
|
|
131
|
+
`LifecycleRuntime` exposes methods, not mutable fields — the handler *tells* the runtime to set/clear session context instead of writing raw fields (the output-argument smell that #87 eliminates).
|
|
132
|
+
|
|
133
|
+
#### ToolStartHandler (in tool-start.ts)
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
/** Narrow runtime interface — only the widget-delegation methods the handler calls. */
|
|
137
|
+
export interface ToolStartRuntime {
|
|
138
|
+
setUICtx(ctx: UICtx): void;
|
|
139
|
+
onTurnStart(): void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class ToolStartHandler {
|
|
143
|
+
constructor(
|
|
144
|
+
private readonly runtime: ToolStartRuntime,
|
|
145
|
+
) {}
|
|
146
|
+
|
|
147
|
+
handleToolExecutionStart(_event: unknown, ctx: ToolStartCtx): void { ... }
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
After #87, the runtime owns widget delegation — `runtime.setUICtx()` delegates to `this.widget?.setUICtx()` internally, handling the null check.
|
|
152
|
+
The handler takes a narrow `ToolStartRuntime` interface (just the two methods it calls) and does not know about `widget` or `SubagentRuntime` at all.
|
|
153
|
+
|
|
154
|
+
### Event payload interfaces
|
|
155
|
+
|
|
156
|
+
Following the code-style skill ("prefer lean local payload interfaces over full SDK event types"), each handler defines minimal payload types for the events it consumes.
|
|
157
|
+
|
|
158
|
+
`session_start` receives `(event, ctx)` — but the current handler ignores the event payload entirely and only reads `ctx`.
|
|
159
|
+
`tool_execution_start` receives `(event, ctx)` — the handler reads `ctx.ui`.
|
|
160
|
+
|
|
161
|
+
Since handlers ignore event payloads, the method signatures use `_event: unknown`.
|
|
162
|
+
|
|
163
|
+
### index.ts wire-up
|
|
164
|
+
|
|
165
|
+
After extraction, `index.ts` constructs handler instances and binds:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import { SessionLifecycleHandler } from "./handlers/index.js";
|
|
169
|
+
import { ToolStartHandler } from "./handlers/index.js";
|
|
170
|
+
|
|
171
|
+
const lifecycle = new SessionLifecycleHandler(
|
|
172
|
+
pi,
|
|
173
|
+
runtime,
|
|
174
|
+
manager,
|
|
175
|
+
() => notifications.dispose(),
|
|
176
|
+
unpublishSubagentsService,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
pi.on("session_start", (event, ctx) => lifecycle.handleSessionStart(event, ctx));
|
|
180
|
+
pi.on("session_before_switch", () => lifecycle.handleSessionBeforeSwitch());
|
|
181
|
+
pi.on("session_shutdown", () => lifecycle.handleSessionShutdown());
|
|
182
|
+
|
|
183
|
+
const toolStart = new ToolStartHandler(runtime);
|
|
184
|
+
|
|
185
|
+
pi.on("tool_execution_start", (event, ctx) => toolStart.handleToolExecutionStart(event, ctx));
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Handler instances are constructed once and hold their collaborators for the extension's lifetime — the same pattern as pi-permission-system's `SessionLifecycleHandler`.
|
|
189
|
+
`runtime` satisfies both `LifecycleRuntime` and `ToolStartRuntime` structurally — TypeScript matches the narrow interface without an explicit cast.
|
|
190
|
+
|
|
191
|
+
### Edge cases
|
|
192
|
+
|
|
193
|
+
- `session_shutdown` calls `unpublishSubagentsService()` which is a module-level import — the handler receives it as a constructor callback, keeping the handler SDK-free.
|
|
194
|
+
- Constructor params are narrow interfaces (`LifecycleManager`, `LifecycleRuntime`, `ToolStartRuntime`) rather than concrete types (`AgentManager`, `SubagentRuntime`), so tests construct mocks without importing production classes.
|
|
195
|
+
- Widget null safety: after #87, the runtime's delegation methods handle null internally (`this.widget?.setUICtx(ctx)`), so handlers never see the null case.
|
|
196
|
+
`ToolStartHandler` tests mock the narrow `ToolStartRuntime` interface directly — no widget null logic to test in the handler.
|
|
197
|
+
|
|
198
|
+
## Module-Level Changes
|
|
199
|
+
|
|
200
|
+
### `src/handlers/lifecycle.ts` (new)
|
|
201
|
+
|
|
202
|
+
- `LifecycleManager` interface (narrow: `clearCompleted`, `abortAll`, `dispose`).
|
|
203
|
+
- `LifecycleRuntime` interface (narrow: `setSessionContext`, `clearSessionContext`).
|
|
204
|
+
- `SessionLifecycleHandler` class — constructor takes `(pi, runtime, manager, disposeNotifications, unpublishService)`.
|
|
205
|
+
- `handleSessionStart(_event, ctx)` — calls `this.runtime.setSessionContext(this.pi, ctx)`, calls `this.manager.clearCompleted()`.
|
|
206
|
+
- `handleSessionBeforeSwitch()` — calls `this.manager.clearCompleted()`.
|
|
207
|
+
- `handleSessionShutdown()` — calls `this.unpublishService()`, `this.runtime.clearSessionContext()`, `this.manager.abortAll()`, `this.disposeNotifications()`, `this.manager.dispose()`.
|
|
208
|
+
|
|
209
|
+
### `src/handlers/tool-start.ts` (new)
|
|
210
|
+
|
|
211
|
+
- `ToolStartRuntime` interface (narrow: `setUICtx`, `onTurnStart`).
|
|
212
|
+
- `ToolStartCtx` local interface (`{ ui: UICtx }`).
|
|
213
|
+
- `ToolStartHandler` class — constructor takes `runtime: ToolStartRuntime`.
|
|
214
|
+
- `handleToolExecutionStart(_event, ctx)` — calls `this.runtime.setUICtx(ctx.ui)`, `this.runtime.onTurnStart()`.
|
|
215
|
+
|
|
216
|
+
### `src/handlers/index.ts` (new)
|
|
217
|
+
|
|
218
|
+
- Barrel re-export of handler classes and their narrow interfaces.
|
|
219
|
+
|
|
220
|
+
### `src/index.ts` (modified)
|
|
221
|
+
|
|
222
|
+
- Add imports from `./handlers/index.js`.
|
|
223
|
+
- Construct `SessionLifecycleHandler` with `(pi, runtime, manager, () => notifications.dispose(), unpublishSubagentsService)`.
|
|
224
|
+
- Construct `ToolStartHandler` with `runtime`.
|
|
225
|
+
- Replace inline `pi.on("session_start", ...)` lambda with `lifecycle.handleSessionStart` delegation.
|
|
226
|
+
- Replace inline `pi.on("session_before_switch", ...)` lambda with `lifecycle.handleSessionBeforeSwitch` delegation.
|
|
227
|
+
- Replace inline `pi.on("session_shutdown", ...)` lambda with `lifecycle.handleSessionShutdown` delegation.
|
|
228
|
+
- Replace inline `pi.on("tool_execution_start", ...)` lambda with `toolStart.handleToolExecutionStart` delegation.
|
|
229
|
+
- `unpublishSubagentsService` import stays — passed to the handler constructor.
|
|
230
|
+
|
|
231
|
+
### `test/handlers/lifecycle.test.ts` (new)
|
|
232
|
+
|
|
233
|
+
- Construct `SessionLifecycleHandler` with mocked `LifecycleManager`, `LifecycleRuntime`, and stub callbacks.
|
|
234
|
+
- `handleSessionStart`: verify `runtime.setSessionContext` called with `(pi, ctx)`, `manager.clearCompleted` called.
|
|
235
|
+
- `handleSessionBeforeSwitch`: verify `manager.clearCompleted` called.
|
|
236
|
+
- `handleSessionShutdown`: verify all five cleanup calls in correct order (unpublish → clearSessionContext → abortAll → disposeNotifications → dispose manager).
|
|
237
|
+
|
|
238
|
+
### `test/handlers/tool-start.test.ts` (new)
|
|
239
|
+
|
|
240
|
+
- Construct `ToolStartHandler` with a mock `ToolStartRuntime`.
|
|
241
|
+
- Verify `setUICtx` and `onTurnStart` called with correct arguments.
|
|
242
|
+
|
|
243
|
+
### `test/print-mode.test.ts` (unchanged)
|
|
244
|
+
|
|
245
|
+
- This integration test calls `handlers.get("session_shutdown")` on the extension's registered handlers.
|
|
246
|
+
The extraction is transparent — the Pi event registration is still in `index.ts`, just delegating to extracted functions.
|
|
247
|
+
No changes needed.
|
|
248
|
+
|
|
249
|
+
## Test Impact Analysis
|
|
250
|
+
|
|
251
|
+
### New unit tests enabled by the extraction
|
|
252
|
+
|
|
253
|
+
1. `test/handlers/lifecycle.test.ts` — Each lifecycle handler tested in isolation with mocked deps.
|
|
254
|
+
Previously impossible because handlers were inline lambdas with closure-captured `runtime`, `manager`, `notifications`, and `pi`.
|
|
255
|
+
2. `test/handlers/tool-start.test.ts` — `tool_execution_start` handler tested with mocked widget.
|
|
256
|
+
Previously untestable because the handler was an inline lambda closing over `runtime.widget`.
|
|
257
|
+
3. Widget null-safety is not the handler's concern — the runtime handles it internally after #87.
|
|
258
|
+
|
|
259
|
+
### Existing tests that become redundant
|
|
260
|
+
|
|
261
|
+
None.
|
|
262
|
+
No existing tests directly test the event handler bodies — they were untestable inline lambdas.
|
|
263
|
+
The extraction creates *new* coverage, not duplicate coverage.
|
|
264
|
+
|
|
265
|
+
### Existing tests that stay as-is
|
|
266
|
+
|
|
267
|
+
- `test/print-mode.test.ts` — calls `session_shutdown` via the extension's registered handler map; still works because `index.ts` still registers the event, just delegates to extracted functions.
|
|
268
|
+
- All other test files — no dependency on handler internals.
|
|
269
|
+
|
|
270
|
+
## TDD Order
|
|
271
|
+
|
|
272
|
+
1. **Create `src/handlers/lifecycle.ts` with `SessionLifecycleHandler` class.**
|
|
273
|
+
Write `test/handlers/lifecycle.test.ts` constructing the handler with mocked narrow interfaces.
|
|
274
|
+
Verify: `handleSessionStart` sets `currentCtx` and calls `clearCompleted`; `handleSessionBeforeSwitch` calls `clearCompleted`; `handleSessionShutdown` calls all five cleanup steps in order.
|
|
275
|
+
Commit: `feat: add SessionLifecycleHandler`
|
|
276
|
+
|
|
277
|
+
2. **Create `src/handlers/tool-start.ts` with `ToolStartHandler` class.**
|
|
278
|
+
Write `test/handlers/tool-start.test.ts` constructing the handler with a mock getter.
|
|
279
|
+
Verify: calls `setUICtx` and `onTurnStart` on widget; no-op when widget is null.
|
|
280
|
+
Commit: `feat: add ToolStartHandler`
|
|
281
|
+
|
|
282
|
+
3. **Create `src/handlers/index.ts` barrel and wire handlers into `src/index.ts`.**
|
|
283
|
+
Add barrel re-export in `src/handlers/index.ts`.
|
|
284
|
+
Replace inline lambdas in `src/index.ts` with handler instance method delegation.
|
|
285
|
+
Construct `SessionLifecycleHandler` and `ToolStartHandler` in the factory.
|
|
286
|
+
Run full test suite to verify no regressions.
|
|
287
|
+
Run `pnpm run check` to verify types.
|
|
288
|
+
Commit: `refactor: wire extracted handlers into extension factory (#70)`
|
|
289
|
+
|
|
290
|
+
## Risks and Mitigations
|
|
291
|
+
|
|
292
|
+
| Risk | Mitigation |
|
|
293
|
+
| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
294
|
+
| Handler extraction changes call order in `session_shutdown` | Test explicitly asserts call order; current order is preserved exactly. |
|
|
295
|
+
| `SessionLifecycleHandler` has 5 constructor params | Reviewed against design-review: `runtime` and `manager` are shared across all three methods (the core shared-state argument for a class). The two callbacks (`disposeNotifications`, `unpublishService`) are shutdown-only but small enough to not warrant a second class. Acceptable. |
|
|
296
|
+
| `print-mode.test.ts` integration test breaks | Test calls `session_shutdown` via the extension's handler map, not the extracted function directly. The delegation is transparent. Verified no changes needed. |
|
|
297
|
+
| `UICtx` type import in `tool-start.ts` couples handler to widget module | `UICtx` is a lean interface already exported from `ui/agent-widget.ts`. The handler only references it in the `ToolStartCtx` local type. Acceptable coupling. |
|
|
298
|
+
|
|
299
|
+
## Open Questions
|
|
300
|
+
|
|
301
|
+
- Should the `session_shutdown` handler call cleanup in a specific guaranteed order (e.g., unpublish → abort → dispose)?
|
|
302
|
+
The current inline code uses a specific order; the extraction preserves it.
|
|
303
|
+
If order matters for correctness, add a code comment documenting it.
|
|
304
|
+
Decide during implementation.
|
|
305
|
+
- Should the `ToolStartHandler` also handle `UICtx` type re-export, or should `tool-start.ts` import `UICtx` from `ui/agent-widget.ts` directly?
|
|
306
|
+
Decide during implementation — if the handler defines its own `ToolStartCtx` with `{ ui: unknown }`, it avoids the import entirely.
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 87
|
|
3
|
+
issue_title: "refactor: evolve SubagentRuntime from data bag to object with methods"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Evolve SubagentRuntime from data bag to object with methods
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`SubagentRuntime` (introduced in #69) consolidates all mutable extension state into one object, but it remains a plain data bag — an interface with public mutable fields and no methods.
|
|
11
|
+
This causes two structural smells in `index.ts`:
|
|
12
|
+
|
|
13
|
+
1. **Output arguments** — handlers write raw fields on the runtime instead of calling methods:
|
|
14
|
+
`runtime.currentCtx = { pi, ctx }` and `runtime.currentCtx = undefined`.
|
|
15
|
+
2. **Law of Demeter violations** — 8 occurrences of `runtime.widget!.method()` across 4 call sites in `index.ts`, where callers reach through the runtime to talk to the widget with unsafe `!` non-null assertions.
|
|
16
|
+
|
|
17
|
+
Issue #70 (extract event handlers) explicitly lists this issue as a prerequisite.
|
|
18
|
+
Without these methods, extracted handlers would just move the output-argument and LoD smells from `index.ts` into handler classes.
|
|
19
|
+
|
|
20
|
+
## Goals
|
|
21
|
+
|
|
22
|
+
- Convert `SubagentRuntime` from an interface + factory to a class with methods.
|
|
23
|
+
- Add session-context methods: `setSessionContext(pi, ctx)` and `clearSessionContext()`.
|
|
24
|
+
- Add widget delegation methods that absorb the `runtime.widget!` reach-throughs: `setUICtx()`, `onTurnStart()`, `markFinished()`, `updateWidget()`, `ensureTimer()`.
|
|
25
|
+
- Update all 10 call sites in `index.ts` to use the new methods — eliminate raw `currentCtx` field writes and `widget!` assertions.
|
|
26
|
+
- No behavior change; pure structural refactor.
|
|
27
|
+
|
|
28
|
+
## Non-Goals
|
|
29
|
+
|
|
30
|
+
- Extracting event handlers into separate files (that is #70).
|
|
31
|
+
- Adding methods for `defaultMaxTurns` / `graceTurns` writes — those field writes remain as-is; the issue scope covers `currentCtx` and `widget!` only.
|
|
32
|
+
- Changing the `SubagentsService` interface.
|
|
33
|
+
- Making `widget` private — `index.ts` still assigns `runtime.widget = new AgentWidget(...)` after construction.
|
|
34
|
+
- Adding new features.
|
|
35
|
+
|
|
36
|
+
## Background
|
|
37
|
+
|
|
38
|
+
### Prior art
|
|
39
|
+
|
|
40
|
+
`pi-permission-system`'s `PermissionSession` is a class with lifecycle methods (`refreshConfig`, `resetForNewSession`, `shutdown`).
|
|
41
|
+
Lifecycle handlers in `src/handlers/lifecycle.ts` call those methods instead of writing fields.
|
|
42
|
+
This issue brings `SubagentRuntime` to the same level.
|
|
43
|
+
|
|
44
|
+
### Current runtime shape
|
|
45
|
+
|
|
46
|
+
`src/runtime.ts` exports a `SubagentRuntime` interface and a `createSubagentRuntime()` factory:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
export interface SubagentRuntime {
|
|
50
|
+
defaultMaxTurns: number | undefined;
|
|
51
|
+
graceTurns: number;
|
|
52
|
+
currentCtx: { pi: unknown; ctx: unknown } | undefined;
|
|
53
|
+
readonly agentActivity: Map<string, AgentActivity>;
|
|
54
|
+
widget: AgentWidget | null;
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Call sites to migrate
|
|
59
|
+
|
|
60
|
+
Two `currentCtx` writes (output arguments):
|
|
61
|
+
|
|
62
|
+
| Line | Current | After |
|
|
63
|
+
| ---- | ---------------------------------- | ------------------------------------ |
|
|
64
|
+
| 126 | `runtime.currentCtx = { pi, ctx }` | `runtime.setSessionContext(pi, ctx)` |
|
|
65
|
+
| 138 | `runtime.currentCtx = undefined` | `runtime.clearSessionContext()` |
|
|
66
|
+
|
|
67
|
+
Eight `widget!` reach-throughs (LoD violations):
|
|
68
|
+
|
|
69
|
+
| Line | Current | After |
|
|
70
|
+
| ---- | ------------------------------------------- | ----------------------------------- |
|
|
71
|
+
| 60 | `runtime.widget!.markFinished(id)` | `runtime.markFinished(id)` |
|
|
72
|
+
| 61 | `runtime.widget!.update()` | `runtime.updateWidget()` |
|
|
73
|
+
| 149 | `runtime.widget!.setUICtx(ctx.ui as UICtx)` | `runtime.setUICtx(ctx.ui as UICtx)` |
|
|
74
|
+
| 150 | `runtime.widget!.onTurnStart()` | `runtime.onTurnStart()` |
|
|
75
|
+
| 204 | `runtime.widget!.setUICtx(ctx as UICtx)` | `runtime.setUICtx(ctx as UICtx)` |
|
|
76
|
+
| 205 | `runtime.widget!.ensureTimer()` | `runtime.ensureTimer()` |
|
|
77
|
+
| 206 | `runtime.widget!.update()` | `runtime.updateWidget()` |
|
|
78
|
+
| 207 | `runtime.widget!.markFinished(id)` | `runtime.markFinished(id)` |
|
|
79
|
+
|
|
80
|
+
### Dependency chain
|
|
81
|
+
|
|
82
|
+
Issue #69 (SubagentRuntime) is closed/implemented.
|
|
83
|
+
This issue (#87) is a prerequisite for #70 (extract event handlers).
|
|
84
|
+
The #70 plan defines narrow handler interfaces (`LifecycleRuntime`, `ToolStartRuntime`) that the runtime class satisfies structurally.
|
|
85
|
+
|
|
86
|
+
### Relevant constraints from AGENTS.md / code-style skill
|
|
87
|
+
|
|
88
|
+
- Keep modules focused and composable (one concern per file).
|
|
89
|
+
- Do not pass a shared dependency bag to functions that only use a subset — define narrow interfaces per consumer.
|
|
90
|
+
- Do not write back into a received dependency bag (output arguments).
|
|
91
|
+
- Do not reach through an injected collaborator to talk to a stranger (Law of Demeter).
|
|
92
|
+
- When multiple callers perform the same reach-through, the missing abstraction is a method on the intermediate object that delegates internally.
|
|
93
|
+
|
|
94
|
+
## Design Overview
|
|
95
|
+
|
|
96
|
+
### Class conversion
|
|
97
|
+
|
|
98
|
+
Convert `SubagentRuntime` from an interface to a class.
|
|
99
|
+
Public fields stay as-is — callers that read `runtime.defaultMaxTurns`, `runtime.currentCtx`, `runtime.agentActivity`, etc. continue to work.
|
|
100
|
+
The `createSubagentRuntime()` factory becomes a thin alias returning `new SubagentRuntime()`, preserving backward compatibility for `index.ts` and existing tests during the transition.
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
export class SubagentRuntime {
|
|
104
|
+
defaultMaxTurns: number | undefined = undefined;
|
|
105
|
+
graceTurns: number = 5;
|
|
106
|
+
currentCtx: { pi: unknown; ctx: unknown } | undefined = undefined;
|
|
107
|
+
readonly agentActivity: Map<string, AgentActivity> = new Map();
|
|
108
|
+
widget: AgentWidget | null = null;
|
|
109
|
+
|
|
110
|
+
setSessionContext(pi: unknown, ctx: unknown): void {
|
|
111
|
+
this.currentCtx = { pi, ctx };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
clearSessionContext(): void {
|
|
115
|
+
this.currentCtx = undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setUICtx(ctx: UICtx): void {
|
|
119
|
+
this.widget?.setUICtx(ctx);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
onTurnStart(): void {
|
|
123
|
+
this.widget?.onTurnStart();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
markFinished(id: string): void {
|
|
127
|
+
this.widget?.markFinished(id);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
updateWidget(): void {
|
|
131
|
+
this.widget?.update();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
ensureTimer(): void {
|
|
135
|
+
this.widget?.ensureTimer();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Widget delegation null safety
|
|
141
|
+
|
|
142
|
+
Current code uses `runtime.widget!.method()` — an unsafe non-null assertion that would throw if widget were null.
|
|
143
|
+
The delegation methods use optional chaining (`this.widget?.method()`), which silently no-ops when widget is null.
|
|
144
|
+
This is safe and intentional: widget is always assigned before any agent can complete, so the null path is unreachable in practice, but the delegation removes the assertion smell.
|
|
145
|
+
The #70 plan explicitly expects this behavior: "Widget null safety: after #87, the runtime's delegation methods handle null internally."
|
|
146
|
+
|
|
147
|
+
### UICtx type import
|
|
148
|
+
|
|
149
|
+
`runtime.ts` already imports `AgentActivity` and `AgentWidget` from `ui/agent-widget.ts`.
|
|
150
|
+
Adding `UICtx` to the same type import is consistent with the existing dependency.
|
|
151
|
+
`UICtx` is a lean local interface (two method signatures), not a Pi SDK type.
|
|
152
|
+
|
|
153
|
+
### What stays unchanged
|
|
154
|
+
|
|
155
|
+
- `RunConfig` interface — remains as-is.
|
|
156
|
+
- `defaultMaxTurns` / `graceTurns` field writes from settings appliers — out of scope per non-goals.
|
|
157
|
+
- `runtime.currentCtx` reads via getter callbacks (`getCtx: () => runtime.currentCtx`) — reads are not output arguments.
|
|
158
|
+
- `runtime.widget = new AgentWidget(...)` assignment — `widget` stays public.
|
|
159
|
+
- `agentActivity` map usage across notification, tool, and menu deps — unchanged.
|
|
160
|
+
|
|
161
|
+
## Module-Level Changes
|
|
162
|
+
|
|
163
|
+
### `src/runtime.ts` (modified)
|
|
164
|
+
|
|
165
|
+
- Convert `SubagentRuntime` from `export interface` to `export class` with field initializers.
|
|
166
|
+
- Add `setSessionContext(pi, ctx)` and `clearSessionContext()` methods.
|
|
167
|
+
- Add `setUICtx(ctx)`, `onTurnStart()`, `markFinished(id)`, `updateWidget()`, `ensureTimer()` delegation methods.
|
|
168
|
+
- Add `UICtx` to the existing type import from `./ui/agent-widget.js`.
|
|
169
|
+
- Keep `createSubagentRuntime()` as `() => new SubagentRuntime()` for backward compat.
|
|
170
|
+
- Keep `RunConfig` interface unchanged.
|
|
171
|
+
|
|
172
|
+
### `src/index.ts` (modified)
|
|
173
|
+
|
|
174
|
+
- Replace `runtime.currentCtx = { pi, ctx }` with `runtime.setSessionContext(pi, ctx)` (line 126).
|
|
175
|
+
- Replace `runtime.currentCtx = undefined` with `runtime.clearSessionContext()` (line 138).
|
|
176
|
+
- Replace all 8 `runtime.widget!.method()` reach-throughs with `runtime.method()` delegation calls (lines 60, 61, 149, 150, 204–207).
|
|
177
|
+
- No import changes needed — `runtime` is already imported via the factory.
|
|
178
|
+
|
|
179
|
+
### `test/runtime.test.ts` (modified)
|
|
180
|
+
|
|
181
|
+
- Add tests for `setSessionContext` / `clearSessionContext` methods.
|
|
182
|
+
- Add tests for each widget delegation method using duck-typed widget stubs.
|
|
183
|
+
- Add test verifying delegation methods no-op when widget is null.
|
|
184
|
+
- Existing tests remain — factory defaults, field mutability, instance isolation, and widget assignment all still apply.
|
|
185
|
+
|
|
186
|
+
## Test Impact Analysis
|
|
187
|
+
|
|
188
|
+
### New unit tests enabled
|
|
189
|
+
|
|
190
|
+
1. `test/runtime.test.ts` additions — `setSessionContext` sets `currentCtx` correctly; `clearSessionContext` resets it to `undefined`.
|
|
191
|
+
2. `test/runtime.test.ts` additions — Each widget delegation method forwards to the widget's corresponding method; all delegation methods silently no-op when widget is null.
|
|
192
|
+
|
|
193
|
+
### Existing tests that become redundant
|
|
194
|
+
|
|
195
|
+
None.
|
|
196
|
+
The existing `runtime.test.ts` tests cover factory defaults, field mutability, and instance isolation — all still valid with the class conversion.
|
|
197
|
+
The "fields are independently mutable" test exercises direct field writes, which remain supported.
|
|
198
|
+
|
|
199
|
+
### Existing tests that stay as-is
|
|
200
|
+
|
|
201
|
+
- `test/runtime.test.ts` — All 5 existing tests pass unchanged (the class satisfies the same structural contract as the previous interface-based object).
|
|
202
|
+
- `test/print-mode.test.ts` — Calls `session_shutdown` via the extension's handler map; transparent to runtime internals.
|
|
203
|
+
- All other test files — No dependency on `SubagentRuntime` fields or methods.
|
|
204
|
+
|
|
205
|
+
## TDD Order
|
|
206
|
+
|
|
207
|
+
1. **Convert `SubagentRuntime` to a class; add session-context methods.**
|
|
208
|
+
Convert the interface to a class with field initializers matching current defaults.
|
|
209
|
+
Add `setSessionContext(pi, ctx)` and `clearSessionContext()` methods.
|
|
210
|
+
Update `createSubagentRuntime()` to return `new SubagentRuntime()`.
|
|
211
|
+
Add tests in `runtime.test.ts`: `setSessionContext` sets `currentCtx`; `clearSessionContext` resets to `undefined`; round-trip set→clear.
|
|
212
|
+
Run existing tests to verify no regressions.
|
|
213
|
+
Commit: `feat: add session-context methods to SubagentRuntime`
|
|
214
|
+
|
|
215
|
+
2. **Add widget delegation methods; add tests.**
|
|
216
|
+
Add `setUICtx(ctx)`, `onTurnStart()`, `markFinished(id)`, `updateWidget()`, `ensureTimer()` methods to the class.
|
|
217
|
+
Add `UICtx` to the type import from `./ui/agent-widget.js`.
|
|
218
|
+
Add tests in `runtime.test.ts`: each delegation method forwards to the widget stub; all methods no-op when widget is null.
|
|
219
|
+
Commit: `feat: add widget delegation methods to SubagentRuntime`
|
|
220
|
+
|
|
221
|
+
3. **Migrate all call sites in `index.ts` to use the new methods.**
|
|
222
|
+
Replace the 2 `currentCtx` writes with `setSessionContext` / `clearSessionContext`.
|
|
223
|
+
Replace the 8 `widget!` reach-throughs with delegation methods.
|
|
224
|
+
Run full test suite and `pnpm run check`.
|
|
225
|
+
Commit: `refactor: use SubagentRuntime methods in extension factory (#87)`
|
|
226
|
+
|
|
227
|
+
## Risks and Mitigations
|
|
228
|
+
|
|
229
|
+
| Risk | Mitigation |
|
|
230
|
+
| ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
231
|
+
| Class conversion breaks code that constructs `SubagentRuntime` as a plain object | Only `createSubagentRuntime()` constructs runtime instances (in `index.ts` and tests). The factory is updated to return `new SubagentRuntime()`. No code constructs a `{ ... } as SubagentRuntime` literal. |
|
|
232
|
+
| Widget delegation silently swallows errors when widget is null (changes behavior from throw to no-op) | The null path is unreachable in practice — widget is always assigned before any agent completes. The silent no-op is strictly safer than the `!` assertion. The #70 plan explicitly expects this behavior. |
|
|
233
|
+
| Adding `UICtx` import to `runtime.ts` increases coupling to the widget module | `runtime.ts` already imports `AgentActivity` and `AgentWidget` from the same module. `UICtx` is a lean 2-method interface, not a Pi SDK type. Coupling is minimal and consistent. |
|
|
234
|
+
| Remaining output-argument writes (`defaultMaxTurns`, `graceTurns`) are left unaddressed | Explicitly out of scope per the issue's acceptance criteria. Can be addressed in a follow-up if the pattern becomes painful. |
|
|
235
|
+
|
|
236
|
+
## Open Questions
|
|
237
|
+
|
|
238
|
+
- Should `createSubagentRuntime()` be removed in favor of `new SubagentRuntime()` directly?
|
|
239
|
+
The factory adds no value over a no-arg constructor, but removing it widens the blast radius without benefit.
|
|
240
|
+
Defer — remove it as part of #70 or a future cleanup if it feels redundant.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 72
|
|
3
|
+
issue_title: "refactor: dependency-inject AgentManager's collaborators"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #72 — dependency-inject AgentManager's collaborators
|
|
7
|
+
|
|
8
|
+
## Final Retrospective (2026-05-20T17:50:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Defined `AgentRunner` and `WorktreeManager` interfaces, converted `AgentManager`'s 6-positional-parameter constructor to an options bag with injected collaborators, migrated all 19 test sites from `vi.mock()` to `vi.fn()` stubs, and added 7 new DI-enabled tests.
|
|
13
|
+
The planning phase required significant user redirection to arrive at the right abstractions; the TDD execution phase was clean with zero rework.
|
|
14
|
+
Released as `pi-subagents-v5.6.0`.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
#### What went well
|
|
19
|
+
|
|
20
|
+
- The `ask_user` interactions during planning surfaced genuine design decisions (options bag vs positional constructor, lifecycle callback grouping) that the issue body left open.
|
|
21
|
+
The user's responses were substantive and redirecting.
|
|
22
|
+
- The user's "make the change that makes the change easy" framing identified #84 (`GitWorktreeManager` extraction) as a prerequisite, which made #72's implementation clean — zero type-shuffling needed.
|
|
23
|
+
- The lift-and-shift test migration (Phase B → Phase C) worked exactly as planned: introduce `createManager()` helper under the old constructor, then switch it to the options bag atomically.
|
|
24
|
+
All 19 test sites migrated with no logic changes.
|
|
25
|
+
- `Promise.withResolvers` (ES2024) in the new queueing test made controlled async coordination clean — no manual resolve/reject wiring.
|
|
26
|
+
|
|
27
|
+
#### What caused friction (agent side)
|
|
28
|
+
|
|
29
|
+
- `wrong-abstraction` — Spent ~4 analysis cycles on "how to move types between files" (`ToolActivity`, `RunOptions`, `WorktreeInfo` → `types.ts`) when the real question was "what objects want to exist?"
|
|
30
|
+
The user had to redirect three times: "are there real objects with state?", "what state IS in AgentRunner?", and "we haven't pulled all the threads."
|
|
31
|
+
Impact: added ~10 minutes of back-and-forth during planning, but ultimately produced a better design (stateful `WorktreeManager` vs stateless `AgentRunner` seam, plus #84 as prep).
|
|
32
|
+
The dependency-graph analysis itself was sound — it confirmed no circular deps — but it answered a question nobody was asking.
|
|
33
|
+
|
|
34
|
+
- `premature-convergence` — First draft of the plan included `WorktreeManager` extraction as "Phase A step 1" inside #72.
|
|
35
|
+
The user asked "did we create another issue that we need to tackle first?"
|
|
36
|
+
— pointing out that the prep work should be its own issue.
|
|
37
|
+
Impact: minor rework to update the plan and file #84; no code rework since it was caught during planning. (User-caught.)
|
|
38
|
+
|
|
39
|
+
#### What caused friction (user side)
|
|
40
|
+
|
|
41
|
+
- The user's early redirect ("take a step back — does the AgentManager really need six params?") could have been even more direct — e.g., "before we discuss constructor shape, what higher-level abstractions are missing?"
|
|
42
|
+
That said, the Socratic approach ultimately led to a better shared understanding of why `WorktreeManager` is a real object and `AgentRunner` is a seam.
|
|
43
|
+
|
|
44
|
+
### Changes made
|
|
45
|
+
|
|
46
|
+
1. Retro file created at `packages/pi-subagents/docs/retro/0072-inject-agent-manager-collaborators.md`.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -57,8 +57,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
57
57
|
const notifications = createNotificationSystem({
|
|
58
58
|
sendMessage: (msg, opts) => pi.sendMessage(msg as any, opts as any),
|
|
59
59
|
agentActivity: runtime.agentActivity,
|
|
60
|
-
markFinished: (id) => runtime.
|
|
61
|
-
updateWidget: () => runtime.
|
|
60
|
+
markFinished: (id) => runtime.markFinished(id),
|
|
61
|
+
updateWidget: () => runtime.updateWidget(),
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
// Background completion: emit lifecycle event and delegate to notification system
|
|
@@ -123,7 +123,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
123
123
|
publishSubagentsService(service);
|
|
124
124
|
|
|
125
125
|
pi.on("session_start", async (_event, ctx) => {
|
|
126
|
-
runtime.
|
|
126
|
+
runtime.setSessionContext(pi, ctx);
|
|
127
127
|
manager.clearCompleted();
|
|
128
128
|
});
|
|
129
129
|
|
|
@@ -135,7 +135,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
135
135
|
// If the session is going down, there's nothing left to consume agent results.
|
|
136
136
|
pi.on("session_shutdown", async () => {
|
|
137
137
|
unpublishSubagentsService();
|
|
138
|
-
runtime.
|
|
138
|
+
runtime.clearSessionContext();
|
|
139
139
|
manager.abortAll();
|
|
140
140
|
notifications.dispose();
|
|
141
141
|
manager.dispose();
|
|
@@ -146,8 +146,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
146
146
|
|
|
147
147
|
// Grab UI context from first tool execution + clear lingering widget on new turn
|
|
148
148
|
pi.on("tool_execution_start", async (_event, ctx) => {
|
|
149
|
-
runtime.
|
|
150
|
-
runtime.
|
|
149
|
+
runtime.setUICtx(ctx.ui as UICtx);
|
|
150
|
+
runtime.onTurnStart();
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
/** Build the full type list text dynamically from the unified registry. */
|
|
@@ -201,10 +201,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
201
201
|
listAgents: () => manager.listAgents(),
|
|
202
202
|
},
|
|
203
203
|
widget: {
|
|
204
|
-
setUICtx: (ctx) => runtime.
|
|
205
|
-
ensureTimer: () => runtime.
|
|
206
|
-
update: () => runtime.
|
|
207
|
-
markFinished: (id) => runtime.
|
|
204
|
+
setUICtx: (ctx) => runtime.setUICtx(ctx as UICtx),
|
|
205
|
+
ensureTimer: () => runtime.ensureTimer(),
|
|
206
|
+
update: () => runtime.updateWidget(),
|
|
207
|
+
markFinished: (id) => runtime.markFinished(id),
|
|
208
208
|
},
|
|
209
209
|
agentActivity: runtime.agentActivity,
|
|
210
210
|
emitEvent: (name, data) => pi.events.emit(name, data),
|
package/src/runtime.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Follows the same pattern as pi-permission-system's ExtensionRuntime.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { AgentActivity, AgentWidget } from "./ui/agent-widget.js";
|
|
9
|
+
import type { AgentActivity, AgentWidget, UICtx } from "./ui/agent-widget.js";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Narrow config subset read by AgentManager when constructing RunOptions.
|
|
@@ -23,27 +23,65 @@ export interface RunConfig {
|
|
|
23
23
|
* Created once inside `piSubagentsExtension()` via `createSubagentRuntime()`.
|
|
24
24
|
* Tests construct a fresh runtime per test for full isolation.
|
|
25
25
|
*/
|
|
26
|
-
export
|
|
26
|
+
export class SubagentRuntime {
|
|
27
27
|
// ── Execution config (was module-scope in agent-runner.ts) ──────────────
|
|
28
28
|
/** Default max turns for all agents. undefined = unlimited. */
|
|
29
|
-
defaultMaxTurns: number | undefined;
|
|
29
|
+
defaultMaxTurns: number | undefined = undefined;
|
|
30
30
|
/** Additional turns allowed after the soft-limit steer message. */
|
|
31
|
-
graceTurns: number;
|
|
31
|
+
graceTurns: number = 5;
|
|
32
32
|
|
|
33
33
|
// ── Session state (was closure-scoped in index.ts) ───────────────────────
|
|
34
34
|
/** Active Pi session context — set on session_start, cleared on session_shutdown. */
|
|
35
|
-
currentCtx: { pi: unknown; ctx: unknown } | undefined;
|
|
35
|
+
currentCtx: { pi: unknown; ctx: unknown } | undefined = undefined;
|
|
36
36
|
/**
|
|
37
37
|
* Per-agent live activity state shared across the notification system,
|
|
38
38
|
* widget, and tool handlers. The Map itself is never replaced.
|
|
39
39
|
*/
|
|
40
|
-
readonly agentActivity: Map<string, AgentActivity
|
|
40
|
+
readonly agentActivity: Map<string, AgentActivity> = new Map();
|
|
41
41
|
/**
|
|
42
42
|
* Persistent widget reference. Null until constructed after AgentManager.
|
|
43
|
-
*
|
|
44
|
-
* complete after widget construction.
|
|
43
|
+
* Delegation methods use optional chaining so callers never need `widget!`.
|
|
45
44
|
*/
|
|
46
|
-
widget: AgentWidget | null;
|
|
45
|
+
widget: AgentWidget | null = null;
|
|
46
|
+
|
|
47
|
+
// ── Session-context methods ──────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** Store the active Pi session context (called from session_start). */
|
|
50
|
+
setSessionContext(pi: unknown, ctx: unknown): void {
|
|
51
|
+
this.currentCtx = { pi, ctx };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Clear the session context (called from session_shutdown). */
|
|
55
|
+
clearSessionContext(): void {
|
|
56
|
+
this.currentCtx = undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Widget delegation methods ─────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/** Delegate to widget.setUICtx — no-op when widget is null. */
|
|
62
|
+
setUICtx(ctx: UICtx): void {
|
|
63
|
+
this.widget?.setUICtx(ctx);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Delegate to widget.onTurnStart — no-op when widget is null. */
|
|
67
|
+
onTurnStart(): void {
|
|
68
|
+
this.widget?.onTurnStart();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Delegate to widget.markFinished — no-op when widget is null. */
|
|
72
|
+
markFinished(id: string): void {
|
|
73
|
+
this.widget?.markFinished(id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Delegate to widget.update — no-op when widget is null. */
|
|
77
|
+
updateWidget(): void {
|
|
78
|
+
this.widget?.update();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Delegate to widget.ensureTimer — no-op when widget is null. */
|
|
82
|
+
ensureTimer(): void {
|
|
83
|
+
this.widget?.ensureTimer();
|
|
84
|
+
}
|
|
47
85
|
}
|
|
48
86
|
|
|
49
87
|
/**
|
|
@@ -52,11 +90,5 @@ export interface SubagentRuntime {
|
|
|
52
90
|
* Call once at extension startup; pass the result to factories and handlers.
|
|
53
91
|
*/
|
|
54
92
|
export function createSubagentRuntime(): SubagentRuntime {
|
|
55
|
-
return
|
|
56
|
-
defaultMaxTurns: undefined,
|
|
57
|
-
graceTurns: 5,
|
|
58
|
-
currentCtx: undefined,
|
|
59
|
-
agentActivity: new Map(),
|
|
60
|
-
widget: null,
|
|
61
|
-
};
|
|
93
|
+
return new SubagentRuntime();
|
|
62
94
|
}
|