@gotgenes/pi-subagents 6.7.0 → 6.8.1

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.
@@ -0,0 +1,179 @@
1
+ ---
2
+ issue: 123
3
+ issue_title: "refactor(pi-subagents): remove vi.fn() cast smell from test helpers"
4
+ ---
5
+
6
+ # Remove vi.fn() cast smell from test helpers
7
+
8
+ ## Problem Statement
9
+
10
+ Several test files construct mock objects typed to narrow interfaces (`AgentManagerLike`, `LifecycleRuntime`, `LifecycleManager`, `ToolStartRuntime`).
11
+ Because the returned objects are typed to the interface — not to Vitest's mock types — tests that need to configure individual method stubs are forced to cast:
12
+
13
+ ```typescript
14
+ (deps.manager.abort as ReturnType<typeof vi.fn>).mockReturnValue(false);
15
+ (deps.manager.getRecord as ReturnType<typeof vi.fn>).mockReturnValue(record);
16
+ ```
17
+
18
+ This silences TypeScript without constraining the call's return type — if `getRecord`'s return type changes, the cast won't catch it.
19
+ Nine occurrences exist across three test files.
20
+
21
+ ## Goals
22
+
23
+ - Eliminate all `as ReturnType<typeof vi.fn>` casts from the test suite.
24
+ - Preserve type safety: mock configuration calls should be checked against the real method signatures.
25
+ - Keep the change minimal — this is a test hygiene fix, not a structural redesign.
26
+
27
+ ## Non-Goals
28
+
29
+ - Changing `AgentManagerLike`, `LifecycleRuntime`, `LifecycleManager`, `ToolStartRuntime`, or any production code.
30
+ - Restructuring test layout or merging describe blocks.
31
+
32
+ ## Background
33
+
34
+ The cast pattern was noted during #111 implementation and preserved to keep scope tight.
35
+ Issue #111 (split `AgentRecord` lifecycle state) is now closed and implemented.
36
+
37
+ ### Affected files
38
+
39
+ | File | Occurrences | Interface |
40
+ | ---------------------------------- | ----------- | -------------------------------------- |
41
+ | `test/service-adapter.test.ts` | 5 | `AgentManagerLike` |
42
+ | `test/handlers/lifecycle.test.ts` | 2 | `LifecycleRuntime`, `LifecycleManager` |
43
+ | `test/handlers/tool-start.test.ts` | 2 | `ToolStartRuntime` |
44
+
45
+ ### Cast sites by file
46
+
47
+ `service-adapter.test.ts` — the "steer, abort, waitForAll, hasRunning" block's `createDeps` returns `AdapterDeps` directly.
48
+ Five casts reconfigure `getRecord`, `abort`, or `queueSteer` after construction:
49
+
50
+ 1. `(deps.manager.abort as ReturnType<typeof vi.fn>).mockReturnValue(false)`
51
+ 2. `(deps.manager.getRecord as ReturnType<typeof vi.fn>).mockReturnValue({...})` (×4)
52
+
53
+ `lifecycle.test.ts` — mock objects are assigned to `let` variables in `beforeEach`, typed to `LifecycleRuntime` and `LifecycleManager`.
54
+ Two casts reconfigure methods to track call order:
55
+
56
+ 1. `(runtime.setSessionContext as ReturnType<typeof vi.fn>).mockImplementation(...)`
57
+ 2. `(manager.clearCompleted as ReturnType<typeof vi.fn>).mockImplementation(...)`
58
+
59
+ Note: the same file already uses `vi.mocked()` in the shutdown-order test — both patterns coexist, which is itself a consistency smell.
60
+
61
+ `tool-start.test.ts` — mock object assigned to a `let` variable typed to `ToolStartRuntime`.
62
+ Two casts reconfigure methods to track call order:
63
+
64
+ 1. `(runtime.setUICtx as ReturnType<typeof vi.fn>).mockImplementation(...)`
65
+ 2. `(runtime.onTurnStart as ReturnType<typeof vi.fn>).mockImplementation(...)`
66
+
67
+ ### Approach: named-variable extraction
68
+
69
+ Extract individual `vi.fn()` stubs into named variables.
70
+ This is the approach the issue recommends and it aligns with the testing skill's guidance on extractable stubs.
71
+
72
+ The alternative — `vi.mocked()` — is already used in `lifecycle.test.ts` for the shutdown-order test and works for hand-built mocks, but is semantically less clean: `vi.mocked()` asserts that a value is already a mock, which is true here but opaque to readers.
73
+ Named variables make the mock-ness explicit at the construction site.
74
+
75
+ For `lifecycle.test.ts`, the named-variable approach also eliminates the inconsistency between the two ordering tests — one currently uses `vi.mocked()` and the other uses casts.
76
+ After this change both will use named stubs.
77
+
78
+ ## Design Overview
79
+
80
+ ### service-adapter.test.ts
81
+
82
+ Refactor the "steer, abort, waitForAll, hasRunning" block's `createDeps` to return named stubs:
83
+
84
+ ```typescript
85
+ function createDeps(overrides: Partial<AdapterDeps> = {}) {
86
+ const mockGetRecord = vi.fn<AgentManagerLike["getRecord"]>();
87
+ const mockAbort = vi.fn<AgentManagerLike["abort"]>(() => true);
88
+ const mockQueueSteer = vi.fn<AgentManagerLike["queueSteer"]>(() => true);
89
+
90
+ const deps: AdapterDeps = {
91
+ manager: {
92
+ spawn: vi.fn(() => "id"),
93
+ getRecord: mockGetRecord,
94
+ listAgents: vi.fn(() => []),
95
+ abort: mockAbort,
96
+ waitForAll: vi.fn(async () => {}),
97
+ hasRunning: vi.fn(() => true),
98
+ queueSteer: mockQueueSteer,
99
+ },
100
+ resolveModel: vi.fn(),
101
+ getCtx: () => ({ pi: {}, ctx: {} }),
102
+ getModelRegistry: () => ({ find: () => null, getAll: () => [] }),
103
+ ...overrides,
104
+ };
105
+
106
+ return { deps, mockGetRecord, mockAbort, mockQueueSteer };
107
+ }
108
+ ```
109
+
110
+ Callers destructure what they need:
111
+
112
+ ```typescript
113
+ const { deps, mockAbort } = createDeps();
114
+ mockAbort.mockReturnValue(false); // ← type-checked, no cast
115
+ ```
116
+
117
+ ### lifecycle.test.ts
118
+
119
+ Promote the `beforeEach`-scoped `runtime` and `manager` mock construction to use named stubs.
120
+ The stubs that need reconfiguration (`setSessionContext`, `clearCompleted`) become named `let` variables alongside the existing `runtime`/`manager` lets, reset in `beforeEach`:
121
+
122
+ ```typescript
123
+ let mockSetSessionContext: MockInstance<LifecycleRuntime["setSessionContext"]>;
124
+ let mockClearCompleted: MockInstance<LifecycleManager["clearCompleted"]>;
125
+ // ...assigned in beforeEach when building runtime/manager
126
+ ```
127
+
128
+ Also convert the shutdown-order test's `vi.mocked()` calls to the same pattern for consistency — `unpublishService`, `clearSessionContext`, `abortAll`, `disposeNotifications`, `dispose` all become named stubs.
129
+
130
+ ### tool-start.test.ts
131
+
132
+ Same pattern: promote `setUICtx` and `onTurnStart` to named `let` variables:
133
+
134
+ ```typescript
135
+ let mockSetUICtx: MockInstance<ToolStartRuntime["setUICtx"]>;
136
+ let mockOnTurnStart: MockInstance<ToolStartRuntime["onTurnStart"]>;
137
+ ```
138
+
139
+ ## Module-Level Changes
140
+
141
+ | File | Change |
142
+ | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
143
+ | `test/service-adapter.test.ts` | Refactor `createDeps` in the "steer, abort, waitForAll, hasRunning" block to return named mock stubs alongside `deps`. Update all 5 cast sites to use named stubs. |
144
+ | `test/handlers/lifecycle.test.ts` | Extract `mockSetSessionContext`, `mockClearCompleted`, `mockAbortAll`, `mockDispose`, `mockClearSessionContext` as named `let` variables. Replace 2 casts and 5 `vi.mocked()` calls with named stubs. |
145
+ | `test/handlers/tool-start.test.ts` | Extract `mockSetUICtx` and `mockOnTurnStart` as named `let` variables. Replace 2 casts with named stubs. |
146
+
147
+ No production files are changed.
148
+
149
+ ## Test Impact Analysis
150
+
151
+ 1. No new tests are added — this is a refactoring of existing test infrastructure.
152
+ 2. No tests become redundant — every existing assertion stays.
153
+ 3. All existing tests must pass unchanged; only the mock-wiring changes.
154
+
155
+ ## TDD Order
156
+
157
+ 1. **Commit:** Refactor `createDeps` in `service-adapter.test.ts` to return named stubs; update all 5 cast sites.
158
+ All tests pass before and after.
159
+ Commit: `test: remove vi.fn() cast smell from service-adapter tests (#123)`
160
+ 2. **Commit:** Extract named stubs in `lifecycle.test.ts`; replace 2 casts and 5 `vi.mocked()` calls.
161
+ All tests pass.
162
+ Commit: `test: remove vi.fn() cast smell from lifecycle tests (#123)`
163
+ 3. **Commit:** Extract named stubs in `tool-start.test.ts`; replace 2 casts.
164
+ All tests pass.
165
+ Commit: `test: remove vi.fn() cast smell from tool-start tests (#123)`
166
+
167
+ Each step is an independent file — order doesn't matter, but one-file-per-commit keeps diffs reviewable.
168
+
169
+ ## Risks and Mitigations
170
+
171
+ | Risk | Mitigation |
172
+ | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
173
+ | Overrides via `...overrides` in `service-adapter.test.ts` could replace a manager method, leaving the named stub disconnected | Only `manager`-level overrides are spread; individual method overrides aren't used in this block. |
174
+ | Named stubs add return-surface to helpers | Each helper is test-local and the extra names are self-documenting. The alternative (casting) is worse. |
175
+ | Converting `vi.mocked()` in `lifecycle.test.ts` shutdown test expands scope slightly beyond the cast pattern | Worth it for consistency — mixing `vi.mocked()` and named stubs in the same file is a different smell. |
176
+
177
+ ## Open Questions
178
+
179
+ None — the issue is fully scoped and the approach is established in the codebase.
@@ -0,0 +1,44 @@
1
+ ---
2
+ issue: 110
3
+ issue_title: "refactor(pi-subagents): wrap AgentActivity in AgentActivityTracker class"
4
+ ---
5
+
6
+ # Retro: #110 — wrap AgentActivity in AgentActivityTracker class
7
+
8
+ ## Final Retrospective (2026-05-21T23:30:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned and implemented `AgentActivityTracker` class across 6 TDD cycles plus doc updates, released as `pi-subagents-v6.7.0`.
13
+ The 7-field mutable `AgentActivity` interface was replaced with a class exposing explicit transition methods (`onToolStart`, `onToolEnd`, `onMessageStart`, `onMessageUpdate`, `onTurnEnd`, `onUsageUpdate`, `setSession`) and read-only accessors.
14
+ All 7 source files and 3 test files were migrated incrementally without any big-bang commit.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - **TDD Red phase caught all three implementation bugs.**
21
+ 1. `onToolEnd` initially incremented `toolUses` unconditionally (ported from original code), but the plan specified no-op defensive behavior.
22
+ The Red phase test `"onToolEnd with no matching tool is a no-op"` caught it instantly.
23
+ 2. `Date.now()` key collision in `activeTools` Map — two `onToolStart("Read")` calls in the same millisecond produced identical keys, so the second overwrote the first.
24
+ The Red phase test `"multiple concurrent tools with same name tracked independently"` caught it.
25
+ 3. `describeActivity` signature needed `ReadonlyMap<string, string>` after the accessor change — caught by `pnpm run check` in step 3.
26
+ All three were fixed immediately with no cascading rework.
27
+ - **Incremental migration avoided type breakage.**
28
+ The plan kept `AgentActivity` alive in `agent-widget.ts` until step 3, so steps 1–2 compiled without touching downstream files.
29
+ Each step only broke the files it was about to migrate, keeping intermediate states valid.
30
+ - **Monotonic counter is strictly better than `Date.now()` for tool keys.**
31
+ The extraction enabled replacing the `toolName + "_" + Date.now()` key strategy with `toolName + "_" + (++this._toolKeySeq)`, which never collides regardless of timing.
32
+ This is a concrete improvement the original inline code couldn't easily adopt.
33
+
34
+ #### What caused friction (agent side)
35
+
36
+ - `missing-context` — The plan specified the `Date.now()` key strategy from the original code, but didn't account for same-millisecond collisions in test execution.
37
+ Impact: ~1 minute debugging in step 1; trivial fix to monotonic counter.
38
+ - `premature-convergence` — Initial `onToolEnd` implementation copied the original's unconditional `toolUses++` before checking the plan's specified no-op behavior.
39
+ Impact: caught immediately by the Red phase test, single-line fix.
40
+
41
+ #### What caused friction (user side)
42
+
43
+ - No material friction observed.
44
+ The session ran end-to-end (plan → implement → ship → release) without user intervention.
@@ -0,0 +1,61 @@
1
+ ---
2
+ issue: 111
3
+ issue_title: "refactor(pi-subagents): split AgentRecord lifecycle state into phase-specific objects"
4
+ ---
5
+
6
+ # Retro: #111 — split AgentRecord lifecycle state into phase-specific objects
7
+
8
+ ## Final Retrospective (2026-05-22T01:50:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned and implemented the `AgentRecord` lifecycle split across 12 TDD cycles plus doc updates, released as `pi-subagents-v6.8.0`.
13
+ Three new phase-specific collaborators (`ExecutionState`, `WorktreeState`, `NotificationState`) replace 9 post-construction mutable fields.
14
+ `pendingSteers` moved to a `Map` on `AgentManager`; stats (`toolUses`, `lifetimeUsage`, `compactionCount`) encapsulated behind mutation methods with read-only getters.
15
+ `AgentRecordInit` trimmed from 19 optional fields to 4.
16
+
17
+ ### Observations
18
+
19
+ #### What went well
20
+
21
+ - **Lift-and-shift scaled from 7 files (#110) to 18 files (#111) without any intermediate test breakage.**
22
+ Every commit left all 41 test files passing.
23
+ The pattern — add new alongside old, migrate consumers with fallbacks (`record.execution?.session ?? record.session`), strip fallbacks in a final commit — is reliable for multi-step encapsulation refactors.
24
+ - **Stats encapsulation was simpler than expected.**
25
+ Converting `toolUses`, `lifetimeUsage`, `compactionCount` to private fields with getters and mutation methods required zero changes to read-only consumers because the getter names match the old field names.
26
+ Only `record-observer.ts` (the sole writer) needed updating.
27
+ - **The `createTestRecord` factory intersection type trick preserved backward compatibility.**
28
+ The factory accepts `toolUses?: number` via `Partial<AgentRecordInit> & { toolUses?: number; ... }` and internally calls `record.incrementToolUses()` in a loop.
29
+ This let 10+ test files continue passing `toolUses: 5` without rewriting each to call mutation methods directly.
30
+ - **`Promise.withResolvers` timing analysis in the plan was unnecessary.**
31
+ The plan spent ~40 lines analyzing whether `promise` should live inside `ExecutionState` and concluded it should stay separate.
32
+ Implementation confirmed: `record.execution` is set in `onSessionCreated` (async callback), `record.promise` is set after `runner.run()` (synchronous return) — different moments, straightforward.
33
+
34
+ #### What caused friction (agent side)
35
+
36
+ - `missing-context` — In the step 7 test for `record.execution`, the initial mock runner used `mockResolvedValue(...)` which doesn't call `onSessionCreated`, so `record.execution` stayed `undefined`.
37
+ Had to switch to `mockImplementation(async (..., opts) => { opts.onSessionCreated?.(session); ... })`.
38
+ The existing tests in the same file already use this pattern for record-observer tests, but I didn't check them first.
39
+ Impact: one test rewrite (~2 minutes), no rework to production code.
40
+ - `scope-drift` — Step 4 absorbed step 5 (adding collaborator fields) without noting the merge in the commit or session log.
41
+ Step 5 became a no-op.
42
+ Impact: no rework, but the session narrative skipped a plan step without explanation.
43
+ - `wrong-abstraction` — Step 12 was planned as a simple cleanup ("remove old fields and trim `AgentRecordInit`") but required coordinated changes across 18 files: removing 9 fields from `AgentRecordInit`, updating the `createTestRecord` factory, fixing 5 test files that passed removed fields, and stripping all fallback patterns.
44
+ This was 2-3 steps' worth of work compressed into one.
45
+ Impact: step 12 took significantly longer than other steps, though it landed cleanly.
46
+ - `missing-context` — Did not proactively flag the `as ReturnType<typeof vi.fn>` cast smell in `service-adapter.test.ts` while migrating that file.
47
+ The user noticed it and asked about it.
48
+ Filed as #123.
49
+ Impact: added friction but no rework; follow-up issue created.
50
+ User-caught.
51
+
52
+ #### What caused friction (user side)
53
+
54
+ - No material friction observed.
55
+ The user's `ask_user` decisions during planning (NotificationState collaborator, Map on AgentManager) gave clear direction.
56
+ Quick "follow-up" response on the cast smell kept scope tight.
57
+
58
+ ### Changes made
59
+
60
+ 1. `packages/pi-subagents/docs/retro/0111-split-agent-record-lifecycle.md` — this retro file.
61
+ 2. `.pi/skills/testing/SKILL.md` — added field-removal rule symmetric to the existing field-addition rule (esbuild silent pass-through on unknown init properties).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "6.7.0",
3
+ "version": "6.8.1",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -13,11 +13,13 @@ import { AgentRecord } from "./agent-record.js";
13
13
  import type { AgentRunner } from "./agent-runner.js";
14
14
  import { AgentTypeRegistry } from "./agent-types.js";
15
15
  import { debugLog } from "./debug.js";
16
+ import type { ExecutionState } from "./execution-state.js";
16
17
  import { buildParentSnapshot } from "./parent-snapshot.js";
17
18
  import { subscribeRecordObserver } from "./record-observer.js";
18
19
  import type { RunConfig } from "./runtime.js";
19
20
  import type { AgentInvocation, IsolationMode, ParentSnapshot, ShellExec, SubagentType, ThinkingLevel } from "./types.js";
20
21
  import type { WorktreeManager } from "./worktree.js";
22
+ import { WorktreeState } from "./worktree-state.js";
21
23
 
22
24
  export type OnAgentComplete = (record: AgentRecord) => void;
23
25
  export type OnAgentStart = (record: AgentRecord) => void;
@@ -92,6 +94,8 @@ export class AgentManager {
92
94
  private queue: { id: string; args: SpawnArgs }[] = [];
93
95
  /** Number of currently running background agents. */
94
96
  private runningBackground = 0;
97
+ /** Steers buffered for agents whose session hasn’t been created yet. */
98
+ private pendingSteers = new Map<string, string[]>();
95
99
 
96
100
  constructor(options: AgentManagerOptions) {
97
101
  this.runner = options.runner;
@@ -116,6 +120,19 @@ export class AgentManager {
116
120
  this.drainQueue();
117
121
  }
118
122
 
123
+ /**
124
+ * Buffer a steer message for an agent whose session isn’t ready yet.
125
+ * Returns false if the agent id is not tracked (already cleaned up or unknown).
126
+ * Called by steer-tool and service-adapter when record.execution is undefined.
127
+ */
128
+ queueSteer(id: string, message: string): boolean {
129
+ if (!this.agents.has(id)) return false;
130
+ const steers = this.pendingSteers.get(id) ?? [];
131
+ steers.push(message);
132
+ this.pendingSteers.set(id, steers);
133
+ return true;
134
+ }
135
+
119
136
  /**
120
137
  * Spawn an agent and return its ID immediately (for background use).
121
138
  * If the concurrency limit is reached, the agent is queued.
@@ -173,7 +190,7 @@ export class AgentManager {
173
190
  'Initialize git and commit at least once, or omit `isolation`.',
174
191
  );
175
192
  }
176
- record.worktree = wt;
193
+ record.worktreeState = new WorktreeState(wt);
177
194
  worktreeCwd = wt.path;
178
195
  }
179
196
 
@@ -207,17 +224,18 @@ export class AgentManager {
207
224
  signal: record.abortController!.signal,
208
225
  registry: this.registry,
209
226
  onSessionCreated: (session) => {
210
- record.session = session;
211
227
  // Capture the session file path early so it's available for display
212
228
  // before the run completes (e.g. in background agent status messages).
213
- const file = session.sessionManager?.getSessionFile?.();
214
- if (file) record.outputFile = file;
229
+ const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
230
+ // Set the execution-state collaborator — born complete at session creation.
231
+ record.execution = { session, outputFile };
215
232
  // Flush any steers that arrived before the session was ready
216
- if (record.pendingSteers?.length) {
217
- for (const msg of record.pendingSteers) {
233
+ const buffered = this.pendingSteers.get(id);
234
+ if (buffered?.length) {
235
+ for (const msg of buffered) {
218
236
  session.steer(msg).catch(() => {});
219
237
  }
220
- record.pendingSteers = undefined;
238
+ this.pendingSteers.delete(id);
221
239
  }
222
240
  // Subscribe record observer for stats accumulation
223
241
  unsubRecordObserver = subscribeRecordObserver(session, record, {
@@ -232,9 +250,9 @@ export class AgentManager {
232
250
 
233
251
  // Clean up worktree before transition so the final result includes branch text
234
252
  let finalResult = responseText;
235
- if (record.worktree) {
236
- const wtResult = this.worktrees.cleanup(record.worktree, options.description);
237
- record.worktreeResult = wtResult;
253
+ if (record.worktreeState) {
254
+ const wtResult = this.worktrees.cleanup(record.worktreeState, options.description);
255
+ record.worktreeState.recordCleanup(wtResult);
238
256
  if (wtResult.hasChanges && wtResult.branch) {
239
257
  finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
240
258
  }
@@ -245,8 +263,8 @@ export class AgentManager {
245
263
  else if (steered) record.markSteered(finalResult);
246
264
  else record.markCompleted(finalResult);
247
265
 
248
- record.session = session;
249
- if (sessionFile) record.outputFile = sessionFile;
266
+ // Update execution collaborator with final session/outputFile from runner
267
+ record.execution = { session, outputFile: sessionFile ?? record.execution?.outputFile };
250
268
 
251
269
  if (options.isBackground) {
252
270
  this.runningBackground--;
@@ -262,10 +280,11 @@ export class AgentManager {
262
280
  detach();
263
281
 
264
282
  // Best-effort worktree cleanup on error
265
- if (record.worktree) {
283
+ if (record.worktreeState) {
266
284
  try {
267
- const wtResult = this.worktrees.cleanup(record.worktree, options.description);
268
- record.worktreeResult = wtResult;
285
+ const wtResult = this.worktrees.cleanup(record.worktreeState, options.description);
286
+ record.worktreeState.recordCleanup(wtResult);
287
+
269
288
  } catch (err) { debugLog("cleanupWorktree on agent error", err); }
270
289
  }
271
290
 
@@ -322,16 +341,17 @@ export class AgentManager {
322
341
  signal?: AbortSignal,
323
342
  ): Promise<AgentRecord | undefined> {
324
343
  const record = this.agents.get(id);
325
- if (!record?.session) return undefined;
344
+ const session = record?.execution?.session;
345
+ if (!session) return undefined;
326
346
 
327
347
  record.resetForResume(Date.now());
328
348
 
329
- const unsubResume = subscribeRecordObserver(record.session, record, {
349
+ const unsubResume = subscribeRecordObserver(session, record, {
330
350
  onCompact: (r, info) => this.onCompact?.(r, info),
331
351
  });
332
352
 
333
353
  try {
334
- const responseText = await this.runner.resume(record.session, prompt, {
354
+ const responseText = await this.runner.resume(session, prompt, {
335
355
  signal,
336
356
  });
337
357
  record.markCompleted(responseText);
@@ -373,9 +393,9 @@ export class AgentManager {
373
393
 
374
394
  /** Dispose a record's session and remove it from the map. */
375
395
  private removeRecord(id: string, record: AgentRecord): void {
376
- record.session?.dispose?.();
377
- record.session = undefined;
396
+ record.execution?.session?.dispose?.();
378
397
  this.agents.delete(id);
398
+ this.pendingSteers.delete(id);
379
399
  }
380
400
 
381
401
  private cleanup() {
@@ -448,7 +468,7 @@ export class AgentManager {
448
468
  // Clear queue
449
469
  this.queue = [];
450
470
  for (const record of this.agents.values()) {
451
- record.session?.dispose();
471
+ record.execution?.session?.dispose();
452
472
  }
453
473
  this.agents.clear();
454
474
  // Prune any orphaned git worktrees (crash recovery)
@@ -5,12 +5,19 @@
5
5
  * by the class and exposed via transition methods. External code reads these
6
6
  * fields through public properties but cannot write them directly.
7
7
  *
8
- * Non-transition state (session, toolUses, lifetimeUsage, etc.) remains public.
8
+ * Stats (toolUses, lifetimeUsage, compactionCount) are owned by the class and
9
+ * accumulated via mutation methods (incrementToolUses, addUsage, incrementCompactions).
10
+ *
11
+ * Phase-specific collaborators (execution, worktreeState, notification) are attached
12
+ * after construction as lifecycle information becomes available.
9
13
  */
10
14
 
11
- import type { AgentSession } from "@earendil-works/pi-coding-agent";
15
+ import type { ExecutionState } from "./execution-state.js";
16
+ import type { NotificationState } from "./notification-state.js";
12
17
  import type { AgentInvocation, SubagentType } from "./types.js";
13
18
  import type { LifetimeUsage } from "./usage.js";
19
+ import { addUsage } from "./usage.js";
20
+ import type { WorktreeState } from "./worktree-state.js";
14
21
 
15
22
  export type AgentRecordStatus =
16
23
  | "queued"
@@ -30,19 +37,9 @@ export interface AgentRecordInit {
30
37
  completedAt?: number;
31
38
  result?: string;
32
39
  error?: string;
33
- toolUses?: number;
34
- lifetimeUsage?: LifetimeUsage;
35
- compactionCount?: number;
36
40
  abortController?: AbortController;
37
41
  invocation?: AgentInvocation;
38
- session?: AgentSession;
39
42
  promise?: Promise<string>;
40
- resultConsumed?: boolean;
41
- pendingSteers?: string[];
42
- worktree?: { path: string; branch: string };
43
- worktreeResult?: { hasChanges: boolean; branch?: string };
44
- toolCallId?: string;
45
- outputFile?: string;
46
43
  }
47
44
 
48
45
  export class AgentRecord {
@@ -68,19 +65,25 @@ export class AgentRecord {
68
65
  private _completedAt?: number;
69
66
  get completedAt(): number | undefined { return this._completedAt; }
70
67
 
71
- // Non-transition mutable state
72
- toolUses: number;
73
- lifetimeUsage: LifetimeUsage;
74
- compactionCount: number;
75
- session?: AgentSession;
76
- abortController?: AbortController;
68
+ // Stats accumulated via mutation methods, readable via getters
69
+ private _toolUses: number;
70
+ get toolUses(): number { return this._toolUses; }
71
+
72
+ private _lifetimeUsage: LifetimeUsage;
73
+ get lifetimeUsage(): Readonly<LifetimeUsage> { return this._lifetimeUsage; }
74
+
75
+ private _compactionCount: number;
76
+ get compactionCount(): number { return this._compactionCount; }
77
+
78
+ /** AbortController for cancelling this agent. Set at construction; used only by AgentManager. */
79
+ readonly abortController?: AbortController;
80
+ /** Promise for the full agent run (including post-processing). Set once by AgentManager. */
77
81
  promise?: Promise<string>;
78
- resultConsumed?: boolean;
79
- pendingSteers?: string[];
80
- worktree?: { path: string; branch: string };
81
- worktreeResult?: { hasChanges: boolean; branch?: string };
82
- toolCallId?: string;
83
- outputFile?: string;
82
+
83
+ // Phase-specific collaborators — each born complete when their info becomes available
84
+ execution?: ExecutionState;
85
+ worktreeState?: WorktreeState;
86
+ notification?: NotificationState;
84
87
 
85
88
  constructor(init: AgentRecordInit) {
86
89
  this.id = init.id;
@@ -94,18 +97,26 @@ export class AgentRecord {
94
97
  this._startedAt = init.startedAt ?? Date.now();
95
98
  this._completedAt = init.completedAt;
96
99
 
97
- this.toolUses = init.toolUses ?? 0;
98
- this.lifetimeUsage = init.lifetimeUsage ?? { input: 0, output: 0, cacheWrite: 0 };
99
- this.compactionCount = init.compactionCount ?? 0;
100
+ this._toolUses = 0;
101
+ this._lifetimeUsage = { input: 0, output: 0, cacheWrite: 0 };
102
+ this._compactionCount = 0;
100
103
  this.abortController = init.abortController;
101
- this.session = init.session;
102
104
  this.promise = init.promise;
103
- this.resultConsumed = init.resultConsumed;
104
- this.pendingSteers = init.pendingSteers;
105
- this.worktree = init.worktree;
106
- this.worktreeResult = init.worktreeResult;
107
- this.toolCallId = init.toolCallId;
108
- this.outputFile = init.outputFile;
105
+ }
106
+
107
+ /** Increment tool use count. Called by record-observer on tool_execution_end. */
108
+ incrementToolUses(): void {
109
+ this._toolUses++;
110
+ }
111
+
112
+ /** Accumulate a usage delta into lifetimeUsage. Called by record-observer on message_end. */
113
+ addUsage(delta: { input: number; output: number; cacheWrite: number }): void {
114
+ addUsage(this._lifetimeUsage, delta);
115
+ }
116
+
117
+ /** Increment compaction count. Called by record-observer on compaction_end. */
118
+ incrementCompactions(): void {
119
+ this._compactionCount++;
109
120
  }
110
121
 
111
122
  /** Transition to running state. Sets status and startedAt. */
@@ -0,0 +1,17 @@
1
+ /**
2
+ * execution-state.ts — ExecutionState: execution-phase state for a running agent.
3
+ *
4
+ * Constructed and attached to AgentRecord when onSessionCreated fires inside startAgent().
5
+ * Contains the session and output file — the two fields that become known once the
6
+ * runner creates the session. promise stays as a separate AgentRecord field because
7
+ * it is set at a different moment (after runner.run() returns).
8
+ */
9
+
10
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
11
+
12
+ export interface ExecutionState {
13
+ /** The active agent session — available from the moment the session is created. */
14
+ readonly session: AgentSession;
15
+ /** Path to the agent's session JSONL file, or undefined if not yet available. */
16
+ readonly outputFile: string | undefined;
17
+ }
package/src/index.ts CHANGED
@@ -88,7 +88,7 @@ export default function (pi: ExtensionAPI) {
88
88
  });
89
89
 
90
90
  // Skip notification if result was already consumed via get_subagent_result
91
- if (record.resultConsumed) {
91
+ if (record.notification?.resultConsumed) {
92
92
  notifications.cleanupCompleted(record.id);
93
93
  return;
94
94
  }
@@ -215,6 +215,7 @@ export default function (pi: ExtensionAPI) {
215
215
  getRecord: (id) => manager.getRecord(id),
216
216
  emitEvent: (name, data) => pi.events.emit(name, data),
217
217
  steerAgent: (session, message) => steerAgent(session, message),
218
+ queueSteer: (id, message) => manager.queueSteer(id, message),
218
219
  })));
219
220
 
220
221
  // ---- /agents interactive menu ----
@@ -0,0 +1,27 @@
1
+ /**
2
+ * notification-state.ts — NotificationState: notification-scoped tracking per background agent.
3
+ *
4
+ * Constructed once when agent-tool assigns the tool call ID (background agents only).
5
+ * Foreground agents never get a NotificationState — record.notification stays undefined.
6
+ */
7
+
8
+ export class NotificationState {
9
+ /** The tool call ID that spawned this background agent. Used in task-notification XML. */
10
+ readonly toolCallId: string;
11
+
12
+ private _resultConsumed = false;
13
+
14
+ constructor(toolCallId: string) {
15
+ this.toolCallId = toolCallId;
16
+ }
17
+
18
+ /** Whether the parent agent has already consumed this result (suppresses duplicate notifications). */
19
+ get resultConsumed(): boolean {
20
+ return this._resultConsumed;
21
+ }
22
+
23
+ /** Mark the result as consumed — suppresses the completion notification. */
24
+ markConsumed(): void {
25
+ this._resultConsumed = true;
26
+ }
27
+ }