@gotgenes/pi-subagents 7.5.1 → 7.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 CHANGED
@@ -5,6 +5,39 @@ 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
+ ## [7.7.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.6.0...pi-subagents-v7.7.0) (2026-05-26)
9
+
10
+
11
+ ### Features
12
+
13
+ * extract writeAgentFile overwrite-guard function ([#217](https://github.com/gotgenes/pi-packages/issues/217)) ([141df78](https://github.com/gotgenes/pi-packages/commit/141df784ea5cf6c5286a1a6e9861daa259fa4e1c))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * add Phase 14 roadmap — Agent domain model, scheduling extraction ([#227](https://github.com/gotgenes/pi-packages/issues/227)–[#232](https://github.com/gotgenes/pi-packages/issues/232)) ([089d9e0](https://github.com/gotgenes/pi-packages/commit/089d9e0becde693c2795ca590d987a4d2b169edc))
19
+ * plan extract overwrite guard from UI ([#217](https://github.com/gotgenes/pi-packages/issues/217)) ([89de32c](https://github.com/gotgenes/pi-packages/commit/89de32c6a1bbb84fb0e252fecaa6edf79dc9b5b3))
20
+ * **retro:** add planning stage notes for issue [#217](https://github.com/gotgenes/pi-packages/issues/217) ([b1a854f](https://github.com/gotgenes/pi-packages/commit/b1a854f18ad133542c5f3e3ab4400ed753ba7c8c))
21
+ * **retro:** add retro notes for issue [#216](https://github.com/gotgenes/pi-packages/issues/216) ([dcb86ea](https://github.com/gotgenes/pi-packages/commit/dcb86eace93d2f68acf39d6f0b8e7d64aaf982d1))
22
+ * **retro:** add TDD stage notes for issue [#217](https://github.com/gotgenes/pi-packages/issues/217) ([7305a28](https://github.com/gotgenes/pi-packages/commit/7305a281f89258d8898fb13f02ba051b58513a71))
23
+ * update architecture for writeAgentFile extraction ([#217](https://github.com/gotgenes/pi-packages/issues/217)) ([298a819](https://github.com/gotgenes/pi-packages/commit/298a8196de5b8dc507bb08ead57a6c712a50c3f0))
24
+
25
+ ## [7.6.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.5.1...pi-subagents-v7.6.0) (2026-05-26)
26
+
27
+
28
+ ### Features
29
+
30
+ * add WorktreeState.performCleanup for self-cleanup ([#216](https://github.com/gotgenes/pi-packages/issues/216)) ([ad0583a](https://github.com/gotgenes/pi-packages/commit/ad0583a9c26b6782af2a55ea86e72f3c3474ebe7))
31
+
32
+
33
+ ### Documentation
34
+
35
+ * plan decompose startAgent via RunHandle lifecycle object ([#216](https://github.com/gotgenes/pi-packages/issues/216)) ([2689571](https://github.com/gotgenes/pi-packages/commit/268957175c2aaa03da98c99778c6ff67e0bf45e3))
36
+ * **retro:** add planning stage notes for issue [#216](https://github.com/gotgenes/pi-packages/issues/216) ([06daa19](https://github.com/gotgenes/pi-packages/commit/06daa1923d75aae8aec1ddd492486c951e50a23f))
37
+ * **retro:** add retro notes for issue [#215](https://github.com/gotgenes/pi-packages/issues/215) ([57f7cf9](https://github.com/gotgenes/pi-packages/commit/57f7cf9139ce3d77f2ec91541bc67cd78c57bdb8))
38
+ * **retro:** add TDD stage notes for issue [#216](https://github.com/gotgenes/pi-packages/issues/216) ([4001da1](https://github.com/gotgenes/pi-packages/commit/4001da1faecc15d2c2c92e7fd69788d908ef5ad8))
39
+ * update architecture doc for [#216](https://github.com/gotgenes/pi-packages/issues/216) RunHandle decomposition ([8ad4a2a](https://github.com/gotgenes/pi-packages/commit/8ad4a2a2d25acdf7f2cd544f6b3cd3949edbc471))
40
+
8
41
  ## [7.5.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.5.0...pi-subagents-v7.5.1) (2026-05-26)
9
42
 
10
43
 
@@ -294,6 +294,7 @@ src/
294
294
  │ ├── message-formatters.ts pure per-message-type formatters (extracted from conversation-viewer)
295
295
  │ ├── agent-activity-tracker.ts live activity state tracker
296
296
  │ ├── agent-file-ops.ts filesystem abstraction
297
+ │ ├── agent-file-writer.ts overwrite-guard + write + reload + notify helper
297
298
  │ ├── ui-observer.ts session-event observer for streaming
298
299
  │ └── display.ts pure formatters and shared types
299
300
 
@@ -491,7 +492,7 @@ Once structural work stabilizes, these are expected to cool.
491
492
  ### Production duplication
492
493
 
493
494
  The prior clone group between `agent-runner.ts` and `message-formatters.ts` was resolved in #172.
494
- One 20-line clone group remains between `agent-config-editor.ts:138–151` and `agent-creation-wizard.ts:231–250` both implement the same overwrite-guard + write + reload + notify pattern.
495
+ The 20-line clone group between `agent-config-editor.ts` and `agent-creation-wizard.ts` was resolved in #217 extracted into `ui/agent-file-writer.ts` (`writeAgentFile`). 0 production clone groups remain.
495
496
 
496
497
  ### Proposed bag decompositions
497
498
 
@@ -652,20 +653,23 @@ Extract per-entry-type formatters: `formatMessageEntry(entry)` and `formatCompac
652
653
  - Smell: B (oversized function)
653
654
  - Outcome: cognitive complexity < 10, function < 15 LOC
654
655
 
655
- ### Step 3: Decompose `startAgent` in `agent-manager.ts` — [#216]
656
+ ### Step 3: Decompose `startAgent` in `agent-manager.ts` — [#216]
656
657
 
657
- `startAgent` is a ~130-line private method that chains worktree setup state transitions observer notification abort-signal wiring → runner invocation → `.then()` completion handler (~35 lines)`.catch()` error handler (~15 lines).
658
- Both the `.then()` and `.catch()` blocks share common finalization logic (background counter decrement, observer notification, queue drain, worktree cleanup, detach signal).
658
+ `startAgent` had two mutable closure variables (`unsubRecordObserver`, `detachParentSignal`) shared across three callbacks with duplicated finalization logic in `.then()`/`.catch()`.
659
+ The fix introduced a `RunHandle` lifecycle object (private to `agent-manager.ts`) that owns the per-run cleanup state and exposes `complete()`/`fail()` as Tell-Don't-Ask methods.
660
+ `WorktreeState` gained `performCleanup(worktrees, description)` to eliminate the ask-tell dance at cleanup sites.
659
661
 
660
- Extract:
662
+ Extracted:
661
663
 
662
- 1. `handleRunCompletion(record, options, result)` worktree cleanup, state transition, execution update, observer notification.
663
- 2. `handleRunError(record, options, err)` — error marking, worktree cleanup.
664
- 3. `finalizeBackgroundRun(record)` — shared `runningBackground--`, observer, `drainQueue()`.
664
+ 1. `RunHandle` classowns `unsub`/`detachFn`, `wireSignal()`, `attachObserver()`, `complete()`, `fail()`, idempotent `fireOnFinished()`.
665
+ 2. `finalizeBackgroundRun(record)` — shared `runningBackground--`, crash-safe observer notification, `drainQueue()`.
666
+ 3. `setupWorktree(id, record, isolation)` — worktree creation with strict failure.
667
+ 4. `flushPendingSteers(id, session)` — drain buffered steers on session creation.
668
+ 5. `WorktreeState.performCleanup(worktrees, description)` — self-cleanup eliminating ask-tell.
665
669
 
666
- - Target: `src/lifecycle/agent-manager.ts`
670
+ - Target: `src/lifecycle/agent-manager.ts`, `src/lifecycle/worktree-state.ts`
667
671
  - Smell: B (oversized method) + A (duplicated finalization logic in then/catch)
668
- - Outcome: no method > 40 LOC, `agent-manager.ts` < 480 LOC
672
+ - Outcome: `startAgent` reduced to ~40 LOC coordinator with zero mutable `let` bindings; `.then()`/`.catch()` are one-liners
669
673
 
670
674
  ### Step 4: Extract overwrite guard from UI — [#217]
671
675
 
@@ -724,6 +728,112 @@ flowchart LR
724
728
  2. **Track B — Complexity and coupling** (Steps 2, 5): independent, can proceed in parallel with Track A.
725
729
  3. **Track C — Duplication** (Steps 4, 6): Step 4 depends on Step 1 (overwrite guard lives in files being converted); Step 6 depends on Steps 1 and 3 (production code they test changes first).
726
730
 
731
+ ## Improvement roadmap (Phase 14)
732
+
733
+ Phase 14 addresses the anemic domain model in the lifecycle layer.
734
+ `AgentRecord` is a data bag — identity, status transitions, and stats — but no behavior.
735
+ `AgentManager` reaches into records 37 times, doing work that belongs on the agent.
736
+ Per-agent state (pending steers, abort logic, run lifecycle) is scattered across the manager, `RunHandle`, and a manager-level Map.
737
+
738
+ The scheduling concern (queue, concurrency counter, drain) is tangled into `AgentManager` alongside collection management and run orchestration.
739
+ `notifyConcurrencyChanged()` is a scheduling method exposed as a public API so settings can poke the queue — a cross-concern leak.
740
+
741
+ ### Findings summary
742
+
743
+ | Finding | Category | Impact | Risk | Priority |
744
+ | ------------------------------------------------------------- | ------------ | ------ | ---- | -------- |
745
+ | `AgentRecord` is anemic — no behavior, manager reaches in 37× | B: Oversized | 5 | 3 | 15 |
746
+ | Scheduling tangled into `AgentManager` (3 fields, 3 methods) | A: Coupling | 4 | 2 | 12 |
747
+ | `startAgent` uses `.then()`/`.catch()` instead of async/await | C: Callbacks | 3 | 2 | 10 |
748
+ | `onSessionCreated` callback flows through 3 layers | C: Callbacks | 3 | 2 | 10 |
749
+ | `resume()` duplicates observer subscribe/unsubscribe pattern | A: Redundant | 2 | 1 | 8 |
750
+ | `exec`/`registry` relay-only deps on `AgentManager` | C: Coupling | 2 | 1 | 6 |
751
+
752
+ ### Step 1: Evolve AgentRecord into Agent with behavior — [#227]
753
+
754
+ Rename `AgentRecord` → `Agent` (or wrap it).
755
+ Move per-agent behavior from `AgentManager` into the agent:
756
+
757
+ 1. `Agent.abort()` — absorbs status-check + controller.abort + markStopped.
758
+ 2. `Agent.queueSteer(message)` / `Agent.flushPendingSteers(session)` — moves pending steers from manager map to per-agent array.
759
+ 3. `Agent.setupWorktree(worktrees, isolation)` — moves worktree creation into the agent.
760
+
761
+ - Target: `src/lifecycle/agent-record.ts` → `src/lifecycle/agent.ts`, `src/lifecycle/agent-manager.ts`
762
+ - Smell: B (anemic domain model) + C (manager reaching into records)
763
+ - Outcome: `AgentManager` delegates via Tell-Don't-Ask; per-agent state lives on the agent
764
+
765
+ ### Step 2: Convert startAgent to async/await — [#228]
766
+
767
+ Convert `startAgent` from synchronous (returns void, assigns `record.promise` to a `.then()`/`.catch()` chain) to `async` (returns `Promise<void>`, uses try/catch).
768
+ `spawn()` assigns `record.promise = this.startAgent(...)` instead of calling `startAgent()` synchronously.
769
+
770
+ - Depends on: #227
771
+ - Target: `src/lifecycle/agent-manager.ts`
772
+ - Smell: C (raw promise callbacks)
773
+ - Outcome: zero `.then()`/`.catch()` in `agent-manager.ts`
774
+
775
+ ### Step 3: Replace onSessionCreated callback with observer method — [#229]
776
+
777
+ Add `onSessionCreated(agent, session)` to `AgentManagerObserver`.
778
+ Remove the `onSessionCreated` callback from `AgentSpawnConfig`.
779
+ Tool-layer code subscribes via the observer pattern instead of passing callbacks through the spawn config.
780
+
781
+ - Target: `src/lifecycle/agent-manager.ts`, `src/tools/background-spawner.ts`, `src/tools/foreground-runner.ts`
782
+ - Smell: C (callback flowing through 3 layers)
783
+ - Outcome: `AgentSpawnConfig` loses one callback field; session notification uses the observer pattern
784
+
785
+ ### Step 4: Extract ConcurrencyQueue from AgentManager — [#230]
786
+
787
+ Extract `queue[]`, `runningBackground`, `_getMaxConcurrent`, `drainQueue()`, `finalizeBackgroundRun()` into a `ConcurrencyQueue` class.
788
+ `SettingsManager` talks to the queue directly — `notifyConcurrencyChanged()` is eliminated from `AgentManager`.
789
+
790
+ - Target: new `src/lifecycle/concurrency-queue.ts`, `src/lifecycle/agent-manager.ts`, `src/index.ts`
791
+ - Smell: A (tangled concerns) + C (cross-concern leak via `notifyConcurrencyChanged`)
792
+ - Outcome: `AgentManager` loses 3 fields, 3 methods (~40 lines); scheduling is independently testable
793
+
794
+ ### Step 5: Push exec/registry relay deps to runner construction — [#231]
795
+
796
+ `AgentManager` receives `exec` and `registry` in its constructor but only relays them to `runner.run()` via `context`.
797
+ Move them to `ConcreteAgentRunner` construction.
798
+
799
+ - Target: `src/lifecycle/agent-manager.ts`, `src/lifecycle/agent-runner.ts`, `src/index.ts`
800
+ - Smell: C (relay-only dependencies)
801
+ - Outcome: `AgentManager` loses 2 fields; `AgentManagerOptions` shrinks from 7 to 5 fields
802
+
803
+ ### Step 6: Unify resume() with RunHandle pattern — [#232]
804
+
805
+ After #227 moves `RunHandle` ownership to the `Agent`, `resume()` on `AgentManager` becomes a 4-line delegation to `agent.resume(runner, prompt, signal)`.
806
+ The agent manages its own observer subscription lifecycle.
807
+
808
+ - Depends on: #227, #228
809
+ - Target: `src/lifecycle/agent-manager.ts`
810
+ - Smell: A (duplicated observer subscribe/unsubscribe pattern)
811
+ - Outcome: no manual `subscribeRecordObserver` / try-finally in the manager
812
+
813
+ ### Step dependency diagram
814
+
815
+ ```mermaid
816
+ flowchart LR
817
+ S1["Step 1\nAgent with behavior"]
818
+ S2["Step 2\nasync startAgent"]
819
+ S3["Step 3\nonSessionCreated observer"]
820
+ S4["Step 4\nConcurrencyQueue"]
821
+ S5["Step 5\nrelay deps"]
822
+ S6["Step 6\nresume unification"]
823
+
824
+ S1 --> S2
825
+ S1 --> S6
826
+ S2 --> S6
827
+ S3 ~~~ S4
828
+ S4 ~~~ S5
829
+ ```
830
+
831
+ ### Tracks
832
+
833
+ 1. **Track A — Domain model** (Steps 1, 2, 6): Agent with behavior, async runs, resume unification.
834
+ Sequential — each depends on the previous.
835
+ 2. **Track B — Decoupling** (Steps 3, 4, 5): independent, can proceed in parallel with Track A.
836
+
727
837
  ## Refactoring history
728
838
 
729
839
  Phases 1–5 and 7–12 are complete.
@@ -761,6 +871,7 @@ Detailed records are preserved in per-phase history files:
761
871
  | Phase 11 | #192, #193, #194, #195, #196 | SessionContext, runtime queries, interface alignment, tool classes, runner/menu classes, index.ts simplification |
762
872
  | Phase 12 | #205, #206, #207, #208 | renderWidgetLines, showAgentDetail, widget update, shared test fixtures |
763
873
  | Phase 13 | #214, #215, #216, #217, #218, #219 | Closure-to-class, buildParentContext, startAgent decomp, overwrite guard, settings SDK, test duplication |
874
+ | Phase 14 | #227, #228, #229, #230, #231, #232 | Agent domain model, async startAgent, onSessionCreated observer, ConcurrencyQueue, relay deps, resume unification |
764
875
 
765
876
  The remaining open issue is #22 (parent-session resolution), a cross-extension track that does not gate the structural work.
766
877
 
@@ -0,0 +1,255 @@
1
+ ---
2
+ issue: 216
3
+ issue_title: "Decompose startAgent in agent-manager.ts (Phase 13, Step 3)"
4
+ ---
5
+
6
+ # Decompose `startAgent` via `RunHandle` lifecycle object
7
+
8
+ ## Problem Statement
9
+
10
+ `startAgent` in `agent-manager.ts` is a ~125-line method whose complexity comes not from length alone but from **mutable closure state shared across callbacks**.
11
+ Two `let` variables (`unsubRecordObserver`, `detachParentSignal`) are written in one closure (`onSessionCreated` / setup block) and read in two others (`.then()` / `.catch()`).
12
+ The `.then()` and `.catch()` handlers duplicate finalization logic (observer unsubscription, signal detach, worktree cleanup, background counter management).
13
+
14
+ The original issue proposed extracting three methods (`handleRunCompletion`, `handleRunError`, `finalizeBackgroundRun`).
15
+ This plan replaces that mechanical extraction with a structural fix: introduce a **`RunHandle` lifecycle object** that owns the per-run cleanup state, eliminating the mutable closures and the duplicated finalization.
16
+
17
+ ## Goals
18
+
19
+ - Eliminate mutable closure state from `startAgent` — all per-run state lives on `RunHandle`.
20
+ - Eliminate duplicated cleanup/finalization logic in `.then()`/`.catch()` via Tell-Don't-Ask on `RunHandle`.
21
+ - Teach `WorktreeState` to self-clean via `performCleanup()`, removing the ask-tell dance from callers.
22
+ - Reduce `startAgent` to a coordinator (~35–40 lines) with zero mutable `let` bindings.
23
+ - Keep all 929 lines of existing `agent-manager.test.ts` passing unchanged.
24
+
25
+ ## Non-Goals
26
+
27
+ - Extracting `RunHandle` to a separate file — it stays private in `agent-manager.ts` for now.
28
+ - Changing the `runner.run()` options shape or the `RunResult` type.
29
+ - Reducing `agent-manager.test.ts` duplication (tracked in #219).
30
+ - Moving `pendingSteers` state to a different owner (the timing gap between `spawn()` and `startAgent()` makes this non-trivial).
31
+
32
+ ## Background
33
+
34
+ ### Closure tangle in `startAgent`
35
+
36
+ ```text
37
+ unsubRecordObserver ──written in──▶ onSessionCreated callback
38
+ ──read in────▶ .then() handler
39
+ ──read in────▶ .catch() handler
40
+
41
+ detachParentSignal ──written in──▶ setup block
42
+ ──read via───▶ detach closure
43
+ ──read in────▶ .then() handler (via detach)
44
+ ──read in────▶ .catch() handler (via detach)
45
+ ```
46
+
47
+ Both variables are resource-release handles — acquired at different times, released in the same place.
48
+ They have no owner; they float as mutable `let` bindings shared across closures.
49
+
50
+ ### Duplicated finalization
51
+
52
+ Both `.then()` and `.catch()` perform:
53
+
54
+ 1. `unsubRecordObserver?.(); detach();` — release listeners
55
+ 2. Worktree cleanup via `this.worktrees.cleanup()` + `record.worktreeState.recordCleanup()` — ask-tell dance
56
+ 3. Background finalization: `this.runningBackground--`, `this.observer?.onAgentCompleted(record)`, `this.drainQueue()`
57
+
58
+ ### Existing types
59
+
60
+ - `RunResult` is already exported from `agent-runner.ts` — `RunHandle.complete()` can accept it directly.
61
+ - `WorktreeManager.cleanup()` accepts `WorktreeInfo`, which `WorktreeState` satisfies structurally (has `path` and `branch`).
62
+ - `record.description` is available on `AgentRecord` at cleanup time, so `RunHandle` doesn't need a separate `description` parameter.
63
+
64
+ ## Design Overview
65
+
66
+ ### `WorktreeState.performCleanup(worktrees, description)`
67
+
68
+ Teach `WorktreeState` to orchestrate its own cleanup instead of requiring callers to do the ask-tell dance:
69
+
70
+ ```typescript
71
+ performCleanup(worktrees: WorktreeManager, description: string): WorktreeCleanupResult {
72
+ const result = worktrees.cleanup(this, description);
73
+ this._cleanupResult = result;
74
+ return result;
75
+ }
76
+ ```
77
+
78
+ This replaces the two-step pattern at both call sites:
79
+
80
+ ```typescript
81
+ // Before (caller orchestrates):
82
+ const wtResult = this.worktrees.cleanup(record.worktreeState, options.description);
83
+ record.worktreeState.recordCleanup(wtResult);
84
+
85
+ // After (Tell-Don't-Ask):
86
+ const wtResult = record.worktreeState.performCleanup(this.worktrees, record.description);
87
+ ```
88
+
89
+ ### `RunHandle` lifecycle object
90
+
91
+ A short-lived object born when a run starts, consumed when it ends.
92
+ Owns the two resource-release handles and exposes `complete()`/`fail()` as the only way to finish a run.
93
+
94
+ ```typescript
95
+ class RunHandle {
96
+ private unsub?: () => void;
97
+ private detach?: () => void;
98
+ private onFinished?: () => void;
99
+
100
+ constructor(
101
+ private readonly record: AgentRecord,
102
+ private readonly worktrees: WorktreeManager,
103
+ onFinished?: () => void,
104
+ ) { this.onFinished = onFinished; }
105
+
106
+ wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void;
107
+ attachObserver(unsub: () => void): void;
108
+ complete(result: RunResult): string;
109
+ fail(err: unknown): void;
110
+
111
+ private detachListeners(): void;
112
+ private fireOnFinished(): void; // idempotent — nulls callback after first call
113
+ }
114
+ ```
115
+
116
+ Key design decisions:
117
+
118
+ 1. **`onFinished` callback** — set once at construction, fires at most once (idempotent guard).
119
+ For background agents this is `() => this.finalizeBackgroundRun(record)`.
120
+ For foreground agents it is `undefined`.
121
+ This eliminates the `if (options.isBackground)` check from both `.then()` and `.catch()`.
122
+
123
+ 2. **`fireOnFinished` is idempotent** — if `complete()` throws (e.g., worktree cleanup fails on the success path) and the promise chain falls through to `.catch()` → `fail()`, the callback fires exactly once.
124
+ `AgentRecord`'s transition guards (`if (this._status !== "stopped")`) protect against double state transitions.
125
+
126
+ 3. **`complete()` returns `result.responseText`** — the branch-suffix text is stored on the record via `markCompleted(finalResult)` but the promise resolves with the original response text, matching current behavior.
127
+
128
+ 4. **No `worktrees` or `description` parameters on `complete()`/`fail()`** — `RunHandle` gets `worktrees` at construction; `description` comes from `record.description`.
129
+
130
+ ### `finalizeBackgroundRun(record)` on `AgentManager`
131
+
132
+ Extracts the shared background finalization:
133
+
134
+ ```typescript
135
+ private finalizeBackgroundRun(record: AgentRecord): void {
136
+ this.runningBackground--;
137
+ try { this.observer?.onAgentCompleted(record); }
138
+ catch (err) { debugLog("onAgentCompleted observer", err); }
139
+ this.drainQueue();
140
+ }
141
+ ```
142
+
143
+ Note: the current `.catch()` handler does not wrap `onAgentCompleted` in try/catch, but `.then()` does.
144
+ The extracted method always wraps it — an observer error must never prevent `drainQueue()` from running.
145
+
146
+ ### Small helpers on `AgentManager`
147
+
148
+ Two additional extractions to keep `startAgent` focused:
149
+
150
+ ```typescript
151
+ private setupWorktree(
152
+ id: string, record: AgentRecord, isolation: IsolationMode | undefined,
153
+ ): string | undefined;
154
+
155
+ private flushPendingSteers(id: string, session: AgentSession): void;
156
+ ```
157
+
158
+ ### Resulting `startAgent` shape
159
+
160
+ After all extractions, `startAgent` becomes a coordinator with **zero mutable `let` bindings**:
161
+
162
+ ```typescript
163
+ private startAgent(id: string, record: AgentRecord, { snapshot, type, prompt, options }: SpawnArgs) {
164
+ const worktreeCwd = this.setupWorktree(id, record, options.isolation);
165
+
166
+ record.markRunning(Date.now());
167
+ if (options.isBackground) this.runningBackground++;
168
+ this.observer?.onAgentStarted(record);
169
+
170
+ const handle = new RunHandle(
171
+ record, this.worktrees,
172
+ options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
173
+ );
174
+ handle.wireSignal(options.signal, () => this.abort(id));
175
+
176
+ const runConfig = this.getRunConfig?.();
177
+ record.promise = this.runner.run(snapshot, type, prompt, {
178
+ context: { exec: this.exec, registry: this.registry, cwd: worktreeCwd, parentSession: options.parentSession },
179
+ model: options.model, maxTurns: options.maxTurns,
180
+ defaultMaxTurns: runConfig?.defaultMaxTurns, graceTurns: runConfig?.graceTurns,
181
+ isolated: options.isolated, thinkingLevel: options.thinkingLevel,
182
+ signal: record.abortController!.signal,
183
+ onSessionCreated: (session) => {
184
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
185
+ const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
186
+ record.execution = { session, outputFile };
187
+ this.flushPendingSteers(id, session);
188
+ handle.attachObserver(subscribeRecordObserver(session, record, {
189
+ onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
190
+ }));
191
+ options.onSessionCreated?.(session, record);
192
+ },
193
+ })
194
+ .then((result) => handle.complete(result))
195
+ .catch((err: unknown) => { handle.fail(err); return ""; });
196
+ }
197
+ ```
198
+
199
+ The `.then()` and `.catch()` are one-liners.
200
+ The `onSessionCreated` callback captures only `const` references (no mutable closure state).
201
+ The `record.promise` assignment moves inline (no intermediate `const promise`).
202
+
203
+ ## Module-Level Changes
204
+
205
+ | File | Change |
206
+ | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
207
+ | `src/lifecycle/worktree-state.ts` | Add `performCleanup(worktrees, description)` method |
208
+ | `src/lifecycle/agent-manager.ts` | Add `RunHandle` class (private); add `finalizeBackgroundRun()`, `setupWorktree()`, `flushPendingSteers()` methods; rewrite `startAgent` to use them |
209
+ | `test/lifecycle/worktree-state.test.ts` | Add tests for `performCleanup` |
210
+
211
+ ## Test Impact Analysis
212
+
213
+ 1. **New unit tests**: `WorktreeState.performCleanup` — directly testable with a mock `WorktreeManager`.
214
+ `RunHandle` is tested indirectly through the existing `agent-manager.test.ts` suite (929 lines, comprehensive coverage of success/error/worktree/signal/background paths).
215
+ 2. **Redundant tests**: None — all existing tests exercise the same public API (`spawn`, `spawnAndWait`, `abort`, `resume`).
216
+ 3. **Tests that must stay as-is**: All of `agent-manager.test.ts` — the refactoring is behavior-preserving and these tests verify every path through `RunHandle.complete()` and `RunHandle.fail()`.
217
+
218
+ ## TDD Order
219
+
220
+ 1. **`WorktreeState.performCleanup`** — red: test that `performCleanup` calls the manager, records the result, and returns it.
221
+ Green: implement `performCleanup` on `WorktreeState`.
222
+ Commit: `feat: add WorktreeState.performCleanup for self-cleanup (#216)`
223
+
224
+ 2. **Use `performCleanup` in `startAgent`** — refactor both cleanup sites in `.then()` and `.catch()` to use `record.worktreeState.performCleanup()`.
225
+ Verify: all existing agent-manager tests pass.
226
+ Commit: `refactor: use WorktreeState.performCleanup in startAgent (#216)`
227
+
228
+ 3. **Extract `finalizeBackgroundRun`** — extract the shared background finalization block.
229
+ Add try/catch around `onAgentCompleted` (unifying the asymmetry between `.then()` and `.catch()`).
230
+ Verify: all existing agent-manager tests pass.
231
+ Commit: `refactor: extract finalizeBackgroundRun from startAgent (#216)`
232
+
233
+ 4. **Introduce `RunHandle` and rewire `startAgent`** — add `RunHandle` class with `wireSignal`, `attachObserver`, `complete`, `fail`, `detachListeners`, `fireOnFinished`.
234
+ Extract `setupWorktree` and `flushPendingSteers`.
235
+ Rewrite `startAgent` to use `RunHandle`, eliminating all mutable `let` bindings.
236
+ Verify: all existing agent-manager tests pass.
237
+ Run `pnpm run check` to verify types.
238
+ Commit: `refactor: introduce RunHandle lifecycle object in startAgent (#216)`
239
+
240
+ ## Risks and Mitigations
241
+
242
+ 1. **`complete()` throws after `fireOnFinished`** — if worktree cleanup succeeds, state transition succeeds, but `fireOnFinished` itself throws (observer error), the `.catch()` handler calls `fail()` which calls `fireOnFinished` again.
243
+ Mitigation: `fireOnFinished` is idempotent (nulls callback after first call), and `finalizeBackgroundRun` wraps `onAgentCompleted` in try/catch.
244
+ `AgentRecord` transition guards prevent double state transitions.
245
+
246
+ 2. **`complete()` throws before state transition** — e.g., `worktrees.cleanup()` throws on the success path.
247
+ The `.catch()` handler calls `fail()`, which marks the record as error and does best-effort worktree cleanup.
248
+ This matches current behavior (the success-path worktree cleanup is not wrapped in try/catch today).
249
+
250
+ 3. **Subtle behavior change in error-path observer notification** — current `.catch()` does not wrap `onAgentCompleted` in try/catch; `finalizeBackgroundRun` does.
251
+ This is a minor hardening, not a behavior change — an observer throwing during error finalization would previously have prevented `drainQueue()` from running.
252
+
253
+ ## Open Questions
254
+
255
+ - None — the design is straightforward and all decisions are driven by eliminating the identified smells.
@@ -0,0 +1,176 @@
1
+ ---
2
+ issue: 217
3
+ issue_title: "Extract overwrite guard from UI (Phase 13, Step 4)"
4
+ ---
5
+
6
+ # Extract overwrite guard from UI
7
+
8
+ ## Problem Statement
9
+
10
+ The overwrite-guard + write + reload + notify pattern is duplicated between `AgentConfigEditor.ejectAgent` and `AgentCreationWizard.showManualWizard`.
11
+ Both sites check file existence, prompt for overwrite confirmation, write the file, reload the agent registry, and notify the user — identical logic with only the content and notification label differing.
12
+ This is the last remaining production clone group in the package.
13
+
14
+ ## Goals
15
+
16
+ - Extract a shared `writeAgentFile` function into a new `src/ui/agent-file-writer.ts` module.
17
+ - Replace both call sites (`ejectAgent`, `showManualWizard`) with calls to the shared function.
18
+ - Achieve 0 production clone groups.
19
+ - Unit-test the extracted function in isolation.
20
+
21
+ ## Non-Goals
22
+
23
+ - Extracting the partial overwrite guard in `showGenerateWizard` — that flow has different lifecycle semantics (the spawned agent does the write, and the post-write check is conditional on file existence).
24
+ The guard-only overlap is 5 lines, not worth a separate abstraction.
25
+ - Reducing test duplication in `agent-config-editor.test.ts` or `agent-creation-wizard.test.ts` — tracked in #219 (Phase 13, Step 6).
26
+ - Changing the `disableAgent` write path — it has no overwrite guard and different notification semantics.
27
+
28
+ ## Background
29
+
30
+ ### Existing modules
31
+
32
+ | Module | Role |
33
+ | --------------------------------- | ------------------------------------------------------------------- |
34
+ | `src/ui/agent-config-editor.ts` | Agent detail view with edit/delete/eject/disable/enable transitions |
35
+ | `src/ui/agent-creation-wizard.ts` | AI-generation and manual-form agent creation flows |
36
+ | `src/ui/agent-file-ops.ts` | Filesystem abstraction (`AgentFileOps` interface + production impl) |
37
+ | `src/ui/agent-menu.ts` | `/agents` slash command menu; defines `MenuUI` interface |
38
+
39
+ ### Duplicated pattern
40
+
41
+ Both sites execute this sequence:
42
+
43
+ ```typescript
44
+ if (this.fileOps.exists(targetPath)) {
45
+ const overwrite = await ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
46
+ if (!overwrite) return;
47
+ }
48
+ this.fileOps.write(targetPath, content);
49
+ this.registry.reload();
50
+ ui.notify(`${label} ${targetPath}`, "info");
51
+ ```
52
+
53
+ The only differences are the `content` argument and the notification `label`.
54
+
55
+ ### Dependency
56
+
57
+ Issue #214 (closure-to-class conversion) is closed — both consumer files are already class-based.
58
+
59
+ ## Design Overview
60
+
61
+ ### Extracted function
62
+
63
+ `writeAgentFile` is a free async function — not a class method — because both consumers are classes with different constructor signatures and no shared base.
64
+ The function takes narrow interface parameters following ISP: each parameter type declares only the methods the function calls.
65
+
66
+ ```typescript
67
+ /** Minimal file operations for the overwrite-guard-and-write pattern. */
68
+ interface FileWriter {
69
+ exists(filePath: string): boolean;
70
+ write(filePath: string, content: string): void;
71
+ }
72
+
73
+ /** Minimal UI for the overwrite-guard-and-write pattern. */
74
+ interface WriterUI {
75
+ confirm(title: string, message: string): Promise<boolean>;
76
+ notify(message: string, level: "info" | "warning" | "error"): void;
77
+ }
78
+
79
+ /** Registry that can be reloaded after file changes. */
80
+ interface Reloadable {
81
+ reload(): void;
82
+ }
83
+
84
+ /**
85
+ * Write an agent file with an overwrite guard.
86
+ *
87
+ * Returns true if the file was written, false if the user declined to overwrite.
88
+ */
89
+ export async function writeAgentFile(
90
+ fileOps: FileWriter,
91
+ ui: WriterUI,
92
+ registry: Reloadable,
93
+ targetPath: string,
94
+ content: string,
95
+ label: string,
96
+ ): Promise<boolean>;
97
+ ```
98
+
99
+ ### Consumer call sites
100
+
101
+ In `AgentConfigEditor.ejectAgent`:
102
+
103
+ ```typescript
104
+ await writeAgentFile(this.fileOps, ui, this.registry, targetPath, buildEjectContent(cfg), `Ejected ${name} to`);
105
+ ```
106
+
107
+ In `AgentCreationWizard.showManualWizard`:
108
+
109
+ ```typescript
110
+ await writeAgentFile(this.fileOps, ui, this.registry, targetPath, content, "Created");
111
+ ```
112
+
113
+ Both callers already hold `this.fileOps` and `this.registry` as private fields, and receive `ui` as a method parameter — no wiring changes needed.
114
+
115
+ ### ISP verification
116
+
117
+ The `FileWriter` interface uses 2 of `AgentFileOps`'s 6 methods (`exists`, `write`).
118
+ The `WriterUI` interface uses 2 of `MenuUI`'s 6 methods (`confirm`, `notify`).
119
+ The `Reloadable` interface uses 1 method (`reload`).
120
+ All three are structurally satisfied by the existing types without adapter code.
121
+
122
+ ## Module-Level Changes
123
+
124
+ 1. **New `src/ui/agent-file-writer.ts`** — exports `writeAgentFile` function and the three narrow interfaces (`FileWriter`, `WriterUI`, `Reloadable`).
125
+ 2. **`src/ui/agent-config-editor.ts`** — `ejectAgent` method: replace the inline overwrite-guard + write + reload + notify block with a call to `writeAgentFile`.
126
+ The `join(targetDir, ...)` and `buildEjectContent(cfg)` calls remain in the caller.
127
+ 3. **`src/ui/agent-creation-wizard.ts`** — `showManualWizard` method: replace the inline overwrite-guard + write + reload + notify block with a call to `writeAgentFile`.
128
+ The `join(targetDir, ...)` and content-assembly calls remain in the caller.
129
+ 4. **New `test/ui/agent-file-writer.test.ts`** — unit tests for `writeAgentFile`.
130
+ 5. **`docs/architecture/architecture.md`** — add `agent-file-writer.ts` to the `ui/` layout listing and update the production-duplication section to mark the clone group as resolved.
131
+
132
+ ## Test Impact Analysis
133
+
134
+ 1. The new `agent-file-writer.test.ts` enables focused unit tests for the overwrite-guard + write + reload + notify sequence — previously this logic was only testable through the higher-level `ejectAgent` and `showManualWizard` flows.
135
+ 2. Existing tests in `agent-config-editor.test.ts` (eject overwrite prompt, eject write) and `agent-creation-wizard.test.ts` (manual wizard overwrite prompt, manual wizard write) remain as integration-level tests that verify the full flow still works end-to-end.
136
+ They should not be removed — they test the caller's orchestration, not just the write logic.
137
+ 3. No existing tests become redundant with this extraction.
138
+
139
+ ## TDD Order
140
+
141
+ 1. **Red → Green: `writeAgentFile` writes when target does not exist**
142
+ - New `test/ui/agent-file-writer.test.ts` with tests: writes file, reloads registry, notifies user, returns `true`.
143
+ - New `src/ui/agent-file-writer.ts` with the extracted function.
144
+ - Commit: `feat: extract writeAgentFile overwrite-guard function (#217)`
145
+
146
+ 2. **Red → Green: `writeAgentFile` overwrite guard**
147
+ - Add tests: prompts for overwrite when file exists; writes and returns `true` when confirmed; does not write and returns `false` when declined.
148
+ - Implementation should already pass (the guard is part of the function body from step 1).
149
+ - Commit: `test: add overwrite-guard tests for writeAgentFile (#217)`
150
+
151
+ 3. **Refactor: wire `ejectAgent` to use `writeAgentFile`**
152
+ - Replace the inline overwrite-guard block in `AgentConfigEditor.ejectAgent` with a call to `writeAgentFile`.
153
+ - Existing tests in `agent-config-editor.test.ts` must continue to pass.
154
+ - Commit: `refactor: use writeAgentFile in AgentConfigEditor.ejectAgent (#217)`
155
+
156
+ 4. **Refactor: wire `showManualWizard` to use `writeAgentFile`**
157
+ - Replace the inline overwrite-guard block in `AgentCreationWizard.showManualWizard` with a call to `writeAgentFile`.
158
+ - Existing tests in `agent-creation-wizard.test.ts` must continue to pass.
159
+ - Commit: `refactor: use writeAgentFile in AgentCreationWizard.showManualWizard (#217)`
160
+
161
+ 5. **Docs: update architecture**
162
+ - Add `agent-file-writer.ts` to the `ui/` layout listing in `docs/architecture/architecture.md`.
163
+ - Update the production-duplication section to mark the clone group as resolved.
164
+ - Commit: `docs: update architecture for writeAgentFile extraction (#217)`
165
+
166
+ ## Risks and Mitigations
167
+
168
+ 1. **Notification message format drift** — The extracted function uses `${label} ${targetPath}` for the notification.
169
+ Both current callers produce messages matching this pattern (`"Ejected ${name} to ${targetPath}"` and `"Created ${targetPath}"`).
170
+ The label parameter gives callers full control over the prefix, so no format is baked in.
171
+ 2. **Existing test fragility** — Tests use `expect.stringContaining("already exists")` for the overwrite prompt, which is stable across the extraction.
172
+ No test rewrites needed.
173
+
174
+ ## Open Questions
175
+
176
+ None — the issue's proposed change section is unambiguous and the dependency (#214) is resolved.
@@ -33,3 +33,29 @@ All checks green: full suite, `pnpm run check`, `pnpm run lint`, `pnpm fallow de
33
33
  - The `makeCtx` helper in the test file creates a minimal `SessionContext` satisfying only `sessionManager.getBranch()`; the extra required fields (`cwd`, `model`, `modelRegistry`, `getSystemPrompt`) are satisfied with stubs.
34
34
  - The `eslint-disable` comment on the `getBranch()` nullability check was preserved unchanged through the refactor.
35
35
  - No deviations from the plan.
36
+
37
+ ## Stage: Final Retrospective (2026-05-26T02:50:00Z)
38
+
39
+ ### Session summary
40
+
41
+ Completed the full issue lifecycle (plan → TDD → ship → retro) in a single session with zero rework or user corrections.
42
+ Released as `pi-subagents-v7.5.1`.
43
+ Test count: 939 → 958 (+19 tests in new `test/session/context.test.ts`).
44
+
45
+ ### Observations
46
+
47
+ #### What went well
48
+
49
+ - Zero-deviation execution: the architecture roadmap specified exact decomposition targets, the plan translated them into 3 TDD steps, and implementation was a straight transcription.
50
+ - Multi-model cost efficiency: `claude-sonnet-4-6` for planning/TDD, `deepseek-v4-flash` for shipping (~$0.002 for the entire ship workflow), `claude-opus-4-6` for retro synthesis.
51
+ - Incremental verification at every stage: per-file test runs after each TDD step, full suite + `pnpm run check` + `pnpm run lint` + `pnpm fallow dead-code` after the last step, repo-root lint before push.
52
+
53
+ #### What caused friction (agent side)
54
+
55
+ None identified.
56
+ The issue was well-scoped, the architecture roadmap was unambiguous, and the existing code had no surprising edge cases.
57
+
58
+ #### What caused friction (user side)
59
+
60
+ None identified.
61
+ The user ran four prompt commands in sequence (`/plan-issue`, `/tdd-plan`, `/ship-issue`, `/retro`) with no corrections or redirections needed.
@@ -0,0 +1,80 @@
1
+ ---
2
+ issue: 216
3
+ issue_title: "Decompose startAgent in agent-manager.ts (Phase 13, Step 3)"
4
+ ---
5
+
6
+ # Retro: #216 — Decompose startAgent in agent-manager.ts
7
+
8
+ ## Stage: Planning (2026-05-25T20:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Analyzed the `startAgent` method's structural problems beyond surface-level length.
13
+ The original issue proposed extracting three methods (`handleRunCompletion`, `handleRunError`, `finalizeBackgroundRun`).
14
+ Through design discussion, identified the root cause as **mutable closure state without an owner** — two `let` variables shared across three closures — and proposed a `RunHandle` lifecycle object as the missing collaborator.
15
+
16
+ ### Observations
17
+
18
+ - The initial mechanical-extraction approach (3 methods) wouldn't have eliminated the mutable closure variables — `.then()`/`.catch()` would still close over `unsubRecordObserver` and `detach`.
19
+ `RunHandle` eliminates these entirely by owning the resource-release handles.
20
+ - `WorktreeState` has an ask-tell smell: callers call `worktrees.cleanup()` then `worktreeState.recordCleanup()`.
21
+ Adding `performCleanup()` is a small prep step that simplifies `RunHandle`'s completion/error methods.
22
+ - `record.description` is already available on `AgentRecord`, so `RunHandle` doesn't need `description` as a separate dependency — it can use `record.description` for worktree cleanup.
23
+ - `RunResult` is already exported from `agent-runner.ts`, so `RunHandle.complete()` can accept it directly without a new type.
24
+ - The `.catch()` handler doesn't wrap `onAgentCompleted` in try/catch while `.then()` does — `finalizeBackgroundRun` unifies this by always wrapping, preventing an observer error from blocking `drainQueue()`.
25
+ - `fireOnFinished` idempotency is important: if `complete()` throws after worktree cleanup but before returning, `.catch()` → `fail()` must not double-fire the background finalization.
26
+ `AgentRecord`'s transition guards (`if (this._status !== "stopped")`) provide a second safety net.
27
+
28
+ ## Stage: Implementation — TDD (2026-05-25T23:20:00Z)
29
+
30
+ ### Session summary
31
+
32
+ Completed all 4 TDD steps across 5 commits (one extra for the type-annotation fixup caught by `pnpm run check`).
33
+ Added 4 new tests for `WorktreeState.performCleanup`; total test count rose from 958 to 962.
34
+ All 60 test files pass; `pnpm run check`, `pnpm run lint`, and `pnpm fallow dead-code` all clean.
35
+
36
+ ### Observations
37
+
38
+ - One deviation from the plan: the `makeWorktrees` test helper in `worktree-state.test.ts` needed an explicit `WorktreeCleanupResult` type annotation on its `result` parameter — TypeScript inferred `{ hasChanges: boolean }` (no optional `branch`/`path` fields) from the default argument, which caused a type error on the call site that passed `{ hasChanges: true, branch: "pi-agent-1" }`.
39
+ Fixed in the same commit as the `RunHandle` step.
40
+ - `RunHandle` landed exactly as designed: `wireSignal`, `attachObserver`, `complete`, `fail`, `releaseListeners`, `fireOnFinished` (idempotent). `startAgent` is now ~40 lines with zero mutable `let` bindings and one-liner `.then()`/`.catch()` handlers.
41
+ - `flushPendingSteers` and `setupWorktree` extracted cleanly — each about 8 lines, no surprises.
42
+ - The `WorktreeCleanupResult` import needed to be added to the test file alongside the existing `WorktreeManager` import for the type annotation fix — minor but worth noting for the next engineer.
43
+ - Architecture doc updated: Step 3 entry now reflects `RunHandle` rather than the original `handleRunCompletion`/`handleRunError` proposal.
44
+
45
+ ## Stage: Final Retrospective (2026-05-26T15:10:00Z)
46
+
47
+ ### Session summary
48
+
49
+ Issue #216 was planned, implemented via 4 TDD steps (5 commits), shipped, CI verified (after a GitHub Actions outage), and released as `pi-subagents-v7.6.0`.
50
+ The final design replaced the original mechanical-extraction proposal with a `RunHandle` lifecycle object that eliminated mutable closure state from `startAgent`.
51
+
52
+ ### Observations
53
+
54
+ #### What went well
55
+
56
+ - The user's two design redirections during planning ("What collaborators are still missing?"
57
+ and "Make the change that makes the change easy") transformed a mechanical extraction plan into a structural improvement.
58
+ The resulting `RunHandle` eliminated the root cause (mutable closure state) rather than just shortening the method.
59
+ - The prep-step pattern worked exactly as intended: `WorktreeState.performCleanup` (step 1) and `finalizeBackgroundRun` (step 3) made the `RunHandle` rewrite (step 4) straightforward.
60
+ Step 4's large edit landed cleanly with all 962 tests passing on the first run.
61
+ - Two Explore subagents dispatched during planning (reading collaborator files and checking `WorktreeState` details) gathered the right context efficiently — `RunResult` being already exported and `record.description` being available at cleanup time were both discovered this way and shaped the `RunHandle` interface.
62
+
63
+ #### What caused friction (agent side)
64
+
65
+ - `premature-convergence` — accepted the issue's proposed mechanical extraction (3 methods) at face value and spent analysis time on LOC arithmetic before the user redirected toward structural thinking.
66
+ Impact: two user redirections needed; no rework since no code was committed yet.
67
+ - `instruction-violation` (self-identified) — the testing skill says "run `pnpm run check` immediately after" changing a shared interface, but step 1 added `performCleanup` to `WorktreeState` without running `pnpm run check`.
68
+ The type error in the test helper (`makeWorktrees` default parameter needing `WorktreeCleanupResult` annotation) went undetected for 3 commits until step 4's `pnpm run check`.
69
+ Impact: added friction but no rework — fixed in the same commit.
70
+
71
+ #### What caused friction (user side)
72
+
73
+ - The user's design redirections were necessary and well-timed.
74
+ No friction from the user side — the two interventions were strategic and saved significant implementation effort.
75
+
76
+ ### Diagnostic details
77
+
78
+ - **Model-performance correlation** — two Explore subagents ran on `claude-haiku-4-5`; appropriate for read-only codebase search (reading collaborator files, checking types and test patterns).
79
+ - **Feedback-loop gap analysis** — `pnpm run check` ran only after step 4 (the `RunHandle` commit); should have run after step 1 (`WorktreeState.performCleanup` is a shared interface change per the testing skill).
80
+ The gap allowed a type annotation error to persist for 3 commits.
@@ -0,0 +1,36 @@
1
+ ---
2
+ issue: 217
3
+ issue_title: "Extract overwrite guard from UI (Phase 13, Step 4)"
4
+ ---
5
+
6
+ # Retro: #217 — Extract overwrite guard from UI
7
+
8
+ ## Stage: Planning (2026-05-26T20:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced a 5-step TDD plan to extract the duplicated overwrite-guard + write + reload + notify pattern from `AgentConfigEditor.ejectAgent` and `AgentCreationWizard.showManualWizard` into a shared `writeAgentFile` function in a new `src/ui/agent-file-writer.ts` module.
13
+ Confirmed dependency #214 (closure-to-class conversion) is already closed.
14
+
15
+ ### Observations
16
+
17
+ - The `showGenerateWizard` overwrite guard was explicitly scoped out — it has different lifecycle semantics (spawned agent writes the file, post-write check is conditional).
18
+ This avoids a leaky abstraction with a discriminator parameter.
19
+ - Narrow ISP interfaces (`FileWriter`, `WriterUI`, `Reloadable`) keep the extracted function decoupled from the full `AgentFileOps` and `MenuUI` interfaces — 2/6 and 2/6 methods respectively.
20
+ - Both consumer call sites hold `this.fileOps` and `this.registry` as private fields and receive `ui` as a method parameter, so no constructor or wiring changes are needed.
21
+ - Existing tests in both consumer test files use `expect.stringContaining("already exists")` for overwrite prompts, which is stable across the extraction.
22
+
23
+ ## Stage: Implementation — TDD (2026-05-26T20:40:00Z)
24
+
25
+ ### Session summary
26
+
27
+ Implemented `writeAgentFile` in new `src/ui/agent-file-writer.ts`, replaced the inline overwrite-guard blocks in `AgentConfigEditor.ejectAgent` and `AgentCreationWizard.showManualWizard`, and updated the architecture doc.
28
+ All 5 plan steps completed across 4 commits (plan steps 1 and 2 folded into one).
29
+ Test count: 962 → 970 (+8 new tests in `test/ui/agent-file-writer.test.ts`).
30
+
31
+ ### Observations
32
+
33
+ - Plan steps 1 and 2 naturally collapsed into a single commit — writing all 8 tests at once and implementing the full function body (including the guard) in one pass was cleaner than splitting them artificially.
34
+ - Both consumer refactors were straightforward one-import-add + one-block-replace edits; all existing tests passed without modification, confirming the extraction preserved exact behavior.
35
+ - The notification label `"Ejected ${name} to"` (with trailing space absorbed by `${targetPath}`) matched the pre-existing message format `"Ejected test-agent to /path"` exactly — no test assertions changed.
36
+ - `FileWriter`, `WriterUI`, and `Reloadable` narrow interfaces are exported from `agent-file-writer.ts`; both consumer files import the concrete types from their original sources, satisfying TypeScript's structural checker without any casts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "7.5.1",
3
+ "version": "7.7.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/service.ts"
@@ -12,7 +12,7 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent";
12
12
  import { AgentTypeRegistry } from "#src/config/agent-types";
13
13
  import { debugLog } from "#src/debug";
14
14
  import { AgentRecord } from "#src/lifecycle/agent-record";
15
- import type { AgentRunner } from "#src/lifecycle/agent-runner";
15
+ import type { AgentRunner, RunResult } from "#src/lifecycle/agent-runner";
16
16
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
17
17
  import type { WorktreeManager } from "#src/lifecycle/worktree";
18
18
  import { WorktreeState } from "#src/lifecycle/worktree-state";
@@ -21,6 +21,95 @@ import { subscribeRecordObserver } from "#src/observation/record-observer";
21
21
  import type { RunConfig } from "#src/runtime";
22
22
  import type { AgentInvocation, IsolationMode, ShellExec, SubagentType, ThinkingLevel } from "#src/types";
23
23
 
24
+ /**
25
+ * RunHandle — per-run lifecycle object that owns cleanup state.
26
+ *
27
+ * Owns the observer unsubscribe and parent-signal detach handles acquired during
28
+ * a run. Exposes `complete()` and `fail()` as the only way to finish a run,
29
+ * eliminating mutable closure variables from `startAgent`.
30
+ * `fireOnFinished` is idempotent — safe to call from both success and error paths.
31
+ */
32
+ class RunHandle {
33
+ private unsub?: () => void;
34
+ private detachFn?: () => void;
35
+ private onFinished?: () => void;
36
+
37
+ constructor(
38
+ private readonly record: AgentRecord,
39
+ private readonly worktrees: WorktreeManager,
40
+ onFinished?: () => void,
41
+ ) {
42
+ this.onFinished = onFinished;
43
+ }
44
+
45
+ /** Wire a parent AbortSignal so it stops this agent when fired. */
46
+ wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
47
+ if (!signal) return;
48
+ const listener = () => onAbort();
49
+ signal.addEventListener("abort", listener, { once: true });
50
+ this.detachFn = () => signal.removeEventListener("abort", listener);
51
+ }
52
+
53
+ /** Store the record-observer unsubscribe handle (called from onSessionCreated). */
54
+ attachObserver(unsub: () => void): void {
55
+ this.unsub = unsub;
56
+ }
57
+
58
+ /** Complete a run successfully — clean up, transition record, fire onFinished. */
59
+ complete(result: RunResult): string {
60
+ this.releaseListeners();
61
+
62
+ let finalResult = result.responseText;
63
+ if (this.record.worktreeState) {
64
+ const wtResult = this.record.worktreeState.performCleanup(this.worktrees, this.record.description);
65
+ if (wtResult.hasChanges && wtResult.branch) {
66
+ finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
67
+ }
68
+ }
69
+
70
+ if (result.aborted) this.record.markAborted(finalResult);
71
+ else if (result.steered) this.record.markSteered(finalResult);
72
+ else this.record.markCompleted(finalResult);
73
+
74
+ // Update execution with the final session/outputFile from the runner
75
+ this.record.execution = {
76
+ session: result.session,
77
+ outputFile: result.sessionFile ?? this.record.execution?.outputFile,
78
+ };
79
+
80
+ this.fireOnFinished();
81
+ return result.responseText;
82
+ }
83
+
84
+ /** Fail a run — mark error, best-effort worktree cleanup, fire onFinished. */
85
+ fail(err: unknown): void {
86
+ this.record.markError(err);
87
+ this.releaseListeners();
88
+
89
+ if (this.record.worktreeState) {
90
+ try {
91
+ this.record.worktreeState.performCleanup(this.worktrees, this.record.description);
92
+ } catch (cleanupErr) { debugLog("cleanupWorktree on agent error", cleanupErr); }
93
+ }
94
+
95
+ this.fireOnFinished();
96
+ }
97
+
98
+ private releaseListeners(): void {
99
+ this.unsub?.();
100
+ this.unsub = undefined;
101
+ this.detachFn?.();
102
+ this.detachFn = undefined;
103
+ }
104
+
105
+ /** Fire the onFinished callback at most once. */
106
+ private fireOnFinished(): void {
107
+ const fn = this.onFinished;
108
+ this.onFinished = undefined;
109
+ fn?.();
110
+ }
111
+ }
112
+
24
113
  export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
25
114
 
26
115
  /** Observer interface for agent lifecycle notifications. */
@@ -192,39 +281,20 @@ export class AgentManager {
192
281
 
193
282
  /** Actually start an agent (called immediately or from queue drain). */
194
283
  private startAgent(id: string, record: AgentRecord, { snapshot, type, prompt, options }: SpawnArgs) {
195
- // Worktree isolation: try to create a temporary git worktree. Strict —
196
- // fail loud if not possible (no silent fallback to main tree). Done
197
- // BEFORE state mutation so a throw doesn't leave the record half-running.
198
- let worktreeCwd: string | undefined;
199
- if (options.isolation === "worktree") {
200
- const wt = this.worktrees.create(id);
201
- if (!wt) {
202
- throw new Error(
203
- 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
204
- 'Initialize git and commit at least once, or omit `isolation`.',
205
- );
206
- }
207
- record.worktreeState = new WorktreeState(wt);
208
- worktreeCwd = wt.path;
209
- }
284
+ const worktreeCwd = this.setupWorktree(id, record, options.isolation);
210
285
 
211
286
  record.markRunning(Date.now());
212
287
  if (options.isBackground) this.runningBackground++;
213
288
  this.observer?.onAgentStarted(record);
214
289
 
215
- // Wire parent abort signal to stop the subagent when the parent is interrupted
216
- let detachParentSignal: (() => void) | undefined;
217
- if (options.signal) {
218
- const onParentAbort = () => this.abort(id);
219
- options.signal.addEventListener("abort", onParentAbort, { once: true });
220
- detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort);
221
- }
222
- const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
223
-
224
- let unsubRecordObserver: (() => void) | undefined;
290
+ const handle = new RunHandle(
291
+ record, this.worktrees,
292
+ options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
293
+ );
294
+ handle.wireSignal(options.signal, () => this.abort(id));
225
295
 
226
296
  const runConfig = this.getRunConfig?.();
227
- const promise = this.runner.run(snapshot, type, prompt, {
297
+ record.promise = this.runner.run(snapshot, type, prompt, {
228
298
  context: {
229
299
  exec: this.exec,
230
300
  registry: this.registry,
@@ -243,76 +313,49 @@ export class AgentManager {
243
313
  // before the run completes (e.g. in background agent status messages).
244
314
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
245
315
  const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
246
- // Set the execution-state collaborator — born complete at session creation.
247
316
  record.execution = { session, outputFile };
248
- // Flush any steers that arrived before the session was ready
249
- const buffered = this.pendingSteers.get(id);
250
- if (buffered?.length) {
251
- for (const msg of buffered) {
252
- session.steer(msg).catch(() => {});
253
- }
254
- this.pendingSteers.delete(id);
255
- }
256
- // Subscribe record observer for stats accumulation
257
- unsubRecordObserver = subscribeRecordObserver(session, record, {
317
+ this.flushPendingSteers(id, session);
318
+ handle.attachObserver(subscribeRecordObserver(session, record, {
258
319
  onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
259
- });
320
+ }));
260
321
  options.onSessionCreated?.(session, record);
261
322
  },
262
323
  })
263
- .then(({ responseText, session, aborted, steered, sessionFile }) => {
264
- unsubRecordObserver?.();
265
- detach();
266
-
267
- // Clean up worktree before transition so the final result includes branch text
268
- let finalResult = responseText;
269
- if (record.worktreeState) {
270
- const wtResult = this.worktrees.cleanup(record.worktreeState, options.description);
271
- record.worktreeState.recordCleanup(wtResult);
272
- if (wtResult.hasChanges && wtResult.branch) {
273
- finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
274
- }
275
- }
276
-
277
- // Transition — guards against overwriting externally-stopped status
278
- if (aborted) record.markAborted(finalResult);
279
- else if (steered) record.markSteered(finalResult);
280
- else record.markCompleted(finalResult);
281
-
282
- // Update execution collaborator with final session/outputFile from runner
283
- record.execution = { session, outputFile: sessionFile ?? record.execution?.outputFile };
284
-
285
- if (options.isBackground) {
286
- this.runningBackground--;
287
- try { this.observer?.onAgentCompleted(record); } catch (err) { debugLog("onAgentCompleted observer", err); }
288
- this.drainQueue();
289
- }
290
- return responseText;
291
- })
292
- .catch((err: unknown) => {
293
- record.markError(err);
294
-
295
- unsubRecordObserver?.();
296
- detach();
297
-
298
- // Best-effort worktree cleanup on error
299
- if (record.worktreeState) {
300
- try {
301
- const wtResult = this.worktrees.cleanup(record.worktreeState, options.description);
302
- record.worktreeState.recordCleanup(wtResult);
324
+ .then((result) => handle.complete(result))
325
+ .catch((err: unknown) => { handle.fail(err); return ""; });
326
+ }
303
327
 
304
- } catch (err) { debugLog("cleanupWorktree on agent error", err); }
305
- }
328
+ /** Create a worktree for isolated agents. Throws (strict) if isolation is requested but impossible. */
329
+ private setupWorktree(
330
+ id: string, record: AgentRecord, isolation: IsolationMode | undefined,
331
+ ): string | undefined {
332
+ if (isolation !== "worktree") return undefined;
333
+ const wt = this.worktrees.create(id);
334
+ if (!wt) {
335
+ throw new Error(
336
+ 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
337
+ 'Initialize git and commit at least once, or omit `isolation`.',
338
+ );
339
+ }
340
+ record.worktreeState = new WorktreeState(wt);
341
+ return wt.path;
342
+ }
306
343
 
307
- if (options.isBackground) {
308
- this.runningBackground--;
309
- this.observer?.onAgentCompleted(record);
310
- this.drainQueue();
311
- }
312
- return "";
313
- });
344
+ /** Flush any steers buffered before the session was ready. */
345
+ private flushPendingSteers(id: string, session: AgentSession): void {
346
+ const buffered = this.pendingSteers.get(id);
347
+ if (!buffered?.length) return;
348
+ for (const msg of buffered) {
349
+ session.steer(msg).catch(() => {});
350
+ }
351
+ this.pendingSteers.delete(id);
352
+ }
314
353
 
315
- record.promise = promise;
354
+ /** Decrement background counter, notify observer (crash-safe), and drain the queue. */
355
+ private finalizeBackgroundRun(record: AgentRecord): void {
356
+ this.runningBackground--;
357
+ try { this.observer?.onAgentCompleted(record); } catch (err) { debugLog("onAgentCompleted observer", err); }
358
+ this.drainQueue();
316
359
  }
317
360
 
318
361
  /** Start queued agents up to the concurrency limit. */
@@ -6,7 +6,7 @@
6
6
  * cleanupResult is recorded once at completion or error — it is not set at construction.
7
7
  */
8
8
 
9
- import type { WorktreeCleanupResult, WorktreeInfo } from "#src/lifecycle/worktree";
9
+ import type { WorktreeCleanupResult, WorktreeInfo, WorktreeManager } from "#src/lifecycle/worktree";
10
10
 
11
11
  export type { WorktreeCleanupResult, WorktreeInfo };
12
12
 
@@ -32,4 +32,14 @@ export class WorktreeState {
32
32
  recordCleanup(result: WorktreeCleanupResult): void {
33
33
  this._cleanupResult = result;
34
34
  }
35
+
36
+ /**
37
+ * Perform worktree cleanup and record the result.
38
+ * Tell-Don't-Ask: callers no longer need to orchestrate cleanup + recordCleanup separately.
39
+ */
40
+ performCleanup(worktrees: WorktreeManager, description: string): WorktreeCleanupResult {
41
+ const result = worktrees.cleanup(this, description);
42
+ this._cleanupResult = result;
43
+ return result;
44
+ }
35
45
  }
@@ -10,6 +10,7 @@ import { join } from "node:path";
10
10
  import type { AgentTypeRegistry } from "#src/config/agent-types";
11
11
  import type { AgentConfig } from "#src/types";
12
12
  import type { AgentFileOps } from "#src/ui/agent-file-ops";
13
+ import { writeAgentFile } from "#src/ui/agent-file-writer";
13
14
  import type { MenuUI } from "#src/ui/agent-menu";
14
15
 
15
16
  // ---- Pure helpers ----
@@ -142,17 +143,14 @@ export class AgentConfigEditor {
142
143
  : this.personalAgentsDir;
143
144
 
144
145
  const targetPath = join(targetDir, `${name}.md`);
145
- if (this.fileOps.exists(targetPath)) {
146
- const overwrite = await ui.confirm(
147
- "Overwrite",
148
- `${targetPath} already exists. Overwrite?`,
149
- );
150
- if (!overwrite) return;
151
- }
152
-
153
- this.fileOps.write(targetPath, buildEjectContent(cfg));
154
- this.registry.reload();
155
- ui.notify(`Ejected ${name} to ${targetPath}`, "info");
146
+ await writeAgentFile(
147
+ this.fileOps,
148
+ ui,
149
+ this.registry,
150
+ targetPath,
151
+ buildEjectContent(cfg),
152
+ `Ejected ${name} to`,
153
+ );
156
154
  }
157
155
 
158
156
  private async disableAgent(ui: MenuUI, name: string): Promise<void> {
@@ -11,6 +11,7 @@ import { BUILTIN_TOOL_NAMES } from "#src/config/agent-types";
11
11
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
12
12
  import type { AgentRecord } from "#src/types";
13
13
  import type { AgentFileOps } from "#src/ui/agent-file-ops";
14
+ import { writeAgentFile } from "#src/ui/agent-file-writer";
14
15
  import type { MenuUI } from "#src/ui/agent-menu";
15
16
 
16
17
  // ---- Deps interface ----
@@ -233,16 +234,6 @@ ${systemPrompt}
233
234
 
234
235
  const targetPath = join(targetDir, `${name}.md`);
235
236
 
236
- if (this.fileOps.exists(targetPath)) {
237
- const overwrite = await ui.confirm(
238
- "Overwrite",
239
- `${targetPath} already exists. Overwrite?`,
240
- );
241
- if (!overwrite) return;
242
- }
243
-
244
- this.fileOps.write(targetPath, content);
245
- this.registry.reload();
246
- ui.notify(`Created ${targetPath}`, "info");
237
+ await writeAgentFile(this.fileOps, ui, this.registry, targetPath, content, "Created");
247
238
  }
248
239
  }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * agent-file-writer.ts — Shared overwrite-guard + write + reload + notify helper.
3
+ *
4
+ * Extracted from AgentConfigEditor.ejectAgent and AgentCreationWizard.showManualWizard
5
+ * to eliminate the duplicated 20-line pattern. Uses narrow interfaces (ISP) so callers
6
+ * are not forced to depend on the full AgentFileOps or MenuUI shapes.
7
+ */
8
+
9
+ // ---- Narrow interfaces ----
10
+
11
+ /** Minimal file operations needed by the overwrite-guard-and-write pattern. */
12
+ export interface FileWriter {
13
+ exists(filePath: string): boolean;
14
+ write(filePath: string, content: string): void;
15
+ }
16
+
17
+ /** Minimal UI needed by the overwrite-guard-and-write pattern. */
18
+ export interface WriterUI {
19
+ confirm(title: string, message: string): Promise<boolean>;
20
+ notify(message: string, level: "info" | "warning" | "error"): void;
21
+ }
22
+
23
+ /** Registry that can be reloaded after file changes. */
24
+ export interface Reloadable {
25
+ reload(): void;
26
+ }
27
+
28
+ // ---- Function ----
29
+
30
+ /**
31
+ * Write an agent `.md` file with an overwrite guard.
32
+ *
33
+ * If `targetPath` already exists, prompts the user for confirmation before writing.
34
+ * On write: reloads the registry and notifies the user as `"${label} ${targetPath}"`.
35
+ *
36
+ * Returns `true` if the file was written, `false` if the user declined to overwrite.
37
+ */
38
+ export async function writeAgentFile(
39
+ fileOps: FileWriter,
40
+ ui: WriterUI,
41
+ registry: Reloadable,
42
+ targetPath: string,
43
+ content: string,
44
+ label: string,
45
+ ): Promise<boolean> {
46
+ if (fileOps.exists(targetPath)) {
47
+ const overwrite = await ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`);
48
+ if (!overwrite) return false;
49
+ }
50
+
51
+ fileOps.write(targetPath, content);
52
+ registry.reload();
53
+ ui.notify(`${label} ${targetPath}`, "info");
54
+ return true;
55
+ }