@gotgenes/pi-subagents 5.5.0 → 5.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,240 @@
1
+ ---
2
+ issue: 87
3
+ issue_title: "refactor: evolve SubagentRuntime from data bag to object with methods"
4
+ ---
5
+
6
+ # Evolve SubagentRuntime from data bag to object with methods
7
+
8
+ ## Problem Statement
9
+
10
+ `SubagentRuntime` (introduced in #69) consolidates all mutable extension state into one object, but it remains a plain data bag — an interface with public mutable fields and no methods.
11
+ This causes two structural smells in `index.ts`:
12
+
13
+ 1. **Output arguments** — handlers write raw fields on the runtime instead of calling methods:
14
+ `runtime.currentCtx = { pi, ctx }` and `runtime.currentCtx = undefined`.
15
+ 2. **Law of Demeter violations** — 8 occurrences of `runtime.widget!.method()` across 4 call sites in `index.ts`, where callers reach through the runtime to talk to the widget with unsafe `!` non-null assertions.
16
+
17
+ Issue #70 (extract event handlers) explicitly lists this issue as a prerequisite.
18
+ Without these methods, extracted handlers would just move the output-argument and LoD smells from `index.ts` into handler classes.
19
+
20
+ ## Goals
21
+
22
+ - Convert `SubagentRuntime` from an interface + factory to a class with methods.
23
+ - Add session-context methods: `setSessionContext(pi, ctx)` and `clearSessionContext()`.
24
+ - Add widget delegation methods that absorb the `runtime.widget!` reach-throughs: `setUICtx()`, `onTurnStart()`, `markFinished()`, `updateWidget()`, `ensureTimer()`.
25
+ - Update all 10 call sites in `index.ts` to use the new methods — eliminate raw `currentCtx` field writes and `widget!` assertions.
26
+ - No behavior change; pure structural refactor.
27
+
28
+ ## Non-Goals
29
+
30
+ - Extracting event handlers into separate files (that is #70).
31
+ - Adding methods for `defaultMaxTurns` / `graceTurns` writes — those field writes remain as-is; the issue scope covers `currentCtx` and `widget!` only.
32
+ - Changing the `SubagentsService` interface.
33
+ - Making `widget` private — `index.ts` still assigns `runtime.widget = new AgentWidget(...)` after construction.
34
+ - Adding new features.
35
+
36
+ ## Background
37
+
38
+ ### Prior art
39
+
40
+ `pi-permission-system`'s `PermissionSession` is a class with lifecycle methods (`refreshConfig`, `resetForNewSession`, `shutdown`).
41
+ Lifecycle handlers in `src/handlers/lifecycle.ts` call those methods instead of writing fields.
42
+ This issue brings `SubagentRuntime` to the same level.
43
+
44
+ ### Current runtime shape
45
+
46
+ `src/runtime.ts` exports a `SubagentRuntime` interface and a `createSubagentRuntime()` factory:
47
+
48
+ ```typescript
49
+ export interface SubagentRuntime {
50
+ defaultMaxTurns: number | undefined;
51
+ graceTurns: number;
52
+ currentCtx: { pi: unknown; ctx: unknown } | undefined;
53
+ readonly agentActivity: Map<string, AgentActivity>;
54
+ widget: AgentWidget | null;
55
+ }
56
+ ```
57
+
58
+ ### Call sites to migrate
59
+
60
+ Two `currentCtx` writes (output arguments):
61
+
62
+ | Line | Current | After |
63
+ | ---- | ---------------------------------- | ------------------------------------ |
64
+ | 126 | `runtime.currentCtx = { pi, ctx }` | `runtime.setSessionContext(pi, ctx)` |
65
+ | 138 | `runtime.currentCtx = undefined` | `runtime.clearSessionContext()` |
66
+
67
+ Eight `widget!` reach-throughs (LoD violations):
68
+
69
+ | Line | Current | After |
70
+ | ---- | ------------------------------------------- | ----------------------------------- |
71
+ | 60 | `runtime.widget!.markFinished(id)` | `runtime.markFinished(id)` |
72
+ | 61 | `runtime.widget!.update()` | `runtime.updateWidget()` |
73
+ | 149 | `runtime.widget!.setUICtx(ctx.ui as UICtx)` | `runtime.setUICtx(ctx.ui as UICtx)` |
74
+ | 150 | `runtime.widget!.onTurnStart()` | `runtime.onTurnStart()` |
75
+ | 204 | `runtime.widget!.setUICtx(ctx as UICtx)` | `runtime.setUICtx(ctx as UICtx)` |
76
+ | 205 | `runtime.widget!.ensureTimer()` | `runtime.ensureTimer()` |
77
+ | 206 | `runtime.widget!.update()` | `runtime.updateWidget()` |
78
+ | 207 | `runtime.widget!.markFinished(id)` | `runtime.markFinished(id)` |
79
+
80
+ ### Dependency chain
81
+
82
+ Issue #69 (SubagentRuntime) is closed/implemented.
83
+ This issue (#87) is a prerequisite for #70 (extract event handlers).
84
+ The #70 plan defines narrow handler interfaces (`LifecycleRuntime`, `ToolStartRuntime`) that the runtime class satisfies structurally.
85
+
86
+ ### Relevant constraints from AGENTS.md / code-style skill
87
+
88
+ - Keep modules focused and composable (one concern per file).
89
+ - Do not pass a shared dependency bag to functions that only use a subset — define narrow interfaces per consumer.
90
+ - Do not write back into a received dependency bag (output arguments).
91
+ - Do not reach through an injected collaborator to talk to a stranger (Law of Demeter).
92
+ - When multiple callers perform the same reach-through, the missing abstraction is a method on the intermediate object that delegates internally.
93
+
94
+ ## Design Overview
95
+
96
+ ### Class conversion
97
+
98
+ Convert `SubagentRuntime` from an interface to a class.
99
+ Public fields stay as-is — callers that read `runtime.defaultMaxTurns`, `runtime.currentCtx`, `runtime.agentActivity`, etc. continue to work.
100
+ The `createSubagentRuntime()` factory becomes a thin alias returning `new SubagentRuntime()`, preserving backward compatibility for `index.ts` and existing tests during the transition.
101
+
102
+ ```typescript
103
+ export class SubagentRuntime {
104
+ defaultMaxTurns: number | undefined = undefined;
105
+ graceTurns: number = 5;
106
+ currentCtx: { pi: unknown; ctx: unknown } | undefined = undefined;
107
+ readonly agentActivity: Map<string, AgentActivity> = new Map();
108
+ widget: AgentWidget | null = null;
109
+
110
+ setSessionContext(pi: unknown, ctx: unknown): void {
111
+ this.currentCtx = { pi, ctx };
112
+ }
113
+
114
+ clearSessionContext(): void {
115
+ this.currentCtx = undefined;
116
+ }
117
+
118
+ setUICtx(ctx: UICtx): void {
119
+ this.widget?.setUICtx(ctx);
120
+ }
121
+
122
+ onTurnStart(): void {
123
+ this.widget?.onTurnStart();
124
+ }
125
+
126
+ markFinished(id: string): void {
127
+ this.widget?.markFinished(id);
128
+ }
129
+
130
+ updateWidget(): void {
131
+ this.widget?.update();
132
+ }
133
+
134
+ ensureTimer(): void {
135
+ this.widget?.ensureTimer();
136
+ }
137
+ }
138
+ ```
139
+
140
+ ### Widget delegation null safety
141
+
142
+ Current code uses `runtime.widget!.method()` — an unsafe non-null assertion that would throw if widget were null.
143
+ The delegation methods use optional chaining (`this.widget?.method()`), which silently no-ops when widget is null.
144
+ This is safe and intentional: widget is always assigned before any agent can complete, so the null path is unreachable in practice, but the delegation removes the assertion smell.
145
+ The #70 plan explicitly expects this behavior: "Widget null safety: after #87, the runtime's delegation methods handle null internally."
146
+
147
+ ### UICtx type import
148
+
149
+ `runtime.ts` already imports `AgentActivity` and `AgentWidget` from `ui/agent-widget.ts`.
150
+ Adding `UICtx` to the same type import is consistent with the existing dependency.
151
+ `UICtx` is a lean local interface (two method signatures), not a Pi SDK type.
152
+
153
+ ### What stays unchanged
154
+
155
+ - `RunConfig` interface — remains as-is.
156
+ - `defaultMaxTurns` / `graceTurns` field writes from settings appliers — out of scope per non-goals.
157
+ - `runtime.currentCtx` reads via getter callbacks (`getCtx: () => runtime.currentCtx`) — reads are not output arguments.
158
+ - `runtime.widget = new AgentWidget(...)` assignment — `widget` stays public.
159
+ - `agentActivity` map usage across notification, tool, and menu deps — unchanged.
160
+
161
+ ## Module-Level Changes
162
+
163
+ ### `src/runtime.ts` (modified)
164
+
165
+ - Convert `SubagentRuntime` from `export interface` to `export class` with field initializers.
166
+ - Add `setSessionContext(pi, ctx)` and `clearSessionContext()` methods.
167
+ - Add `setUICtx(ctx)`, `onTurnStart()`, `markFinished(id)`, `updateWidget()`, `ensureTimer()` delegation methods.
168
+ - Add `UICtx` to the existing type import from `./ui/agent-widget.js`.
169
+ - Keep `createSubagentRuntime()` as `() => new SubagentRuntime()` for backward compat.
170
+ - Keep `RunConfig` interface unchanged.
171
+
172
+ ### `src/index.ts` (modified)
173
+
174
+ - Replace `runtime.currentCtx = { pi, ctx }` with `runtime.setSessionContext(pi, ctx)` (line 126).
175
+ - Replace `runtime.currentCtx = undefined` with `runtime.clearSessionContext()` (line 138).
176
+ - Replace all 8 `runtime.widget!.method()` reach-throughs with `runtime.method()` delegation calls (lines 60, 61, 149, 150, 204–207).
177
+ - No import changes needed — `runtime` is already imported via the factory.
178
+
179
+ ### `test/runtime.test.ts` (modified)
180
+
181
+ - Add tests for `setSessionContext` / `clearSessionContext` methods.
182
+ - Add tests for each widget delegation method using duck-typed widget stubs.
183
+ - Add test verifying delegation methods no-op when widget is null.
184
+ - Existing tests remain — factory defaults, field mutability, instance isolation, and widget assignment all still apply.
185
+
186
+ ## Test Impact Analysis
187
+
188
+ ### New unit tests enabled
189
+
190
+ 1. `test/runtime.test.ts` additions — `setSessionContext` sets `currentCtx` correctly; `clearSessionContext` resets it to `undefined`.
191
+ 2. `test/runtime.test.ts` additions — Each widget delegation method forwards to the widget's corresponding method; all delegation methods silently no-op when widget is null.
192
+
193
+ ### Existing tests that become redundant
194
+
195
+ None.
196
+ The existing `runtime.test.ts` tests cover factory defaults, field mutability, and instance isolation — all still valid with the class conversion.
197
+ The "fields are independently mutable" test exercises direct field writes, which remain supported.
198
+
199
+ ### Existing tests that stay as-is
200
+
201
+ - `test/runtime.test.ts` — All 5 existing tests pass unchanged (the class satisfies the same structural contract as the previous interface-based object).
202
+ - `test/print-mode.test.ts` — Calls `session_shutdown` via the extension's handler map; transparent to runtime internals.
203
+ - All other test files — No dependency on `SubagentRuntime` fields or methods.
204
+
205
+ ## TDD Order
206
+
207
+ 1. **Convert `SubagentRuntime` to a class; add session-context methods.**
208
+ Convert the interface to a class with field initializers matching current defaults.
209
+ Add `setSessionContext(pi, ctx)` and `clearSessionContext()` methods.
210
+ Update `createSubagentRuntime()` to return `new SubagentRuntime()`.
211
+ Add tests in `runtime.test.ts`: `setSessionContext` sets `currentCtx`; `clearSessionContext` resets to `undefined`; round-trip set→clear.
212
+ Run existing tests to verify no regressions.
213
+ Commit: `feat: add session-context methods to SubagentRuntime`
214
+
215
+ 2. **Add widget delegation methods; add tests.**
216
+ Add `setUICtx(ctx)`, `onTurnStart()`, `markFinished(id)`, `updateWidget()`, `ensureTimer()` methods to the class.
217
+ Add `UICtx` to the type import from `./ui/agent-widget.js`.
218
+ Add tests in `runtime.test.ts`: each delegation method forwards to the widget stub; all methods no-op when widget is null.
219
+ Commit: `feat: add widget delegation methods to SubagentRuntime`
220
+
221
+ 3. **Migrate all call sites in `index.ts` to use the new methods.**
222
+ Replace the 2 `currentCtx` writes with `setSessionContext` / `clearSessionContext`.
223
+ Replace the 8 `widget!` reach-throughs with delegation methods.
224
+ Run full test suite and `pnpm run check`.
225
+ Commit: `refactor: use SubagentRuntime methods in extension factory (#87)`
226
+
227
+ ## Risks and Mitigations
228
+
229
+ | Risk | Mitigation |
230
+ | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
231
+ | Class conversion breaks code that constructs `SubagentRuntime` as a plain object | Only `createSubagentRuntime()` constructs runtime instances (in `index.ts` and tests). The factory is updated to return `new SubagentRuntime()`. No code constructs a `{ ... } as SubagentRuntime` literal. |
232
+ | Widget delegation silently swallows errors when widget is null (changes behavior from throw to no-op) | The null path is unreachable in practice — widget is always assigned before any agent completes. The silent no-op is strictly safer than the `!` assertion. The #70 plan explicitly expects this behavior. |
233
+ | Adding `UICtx` import to `runtime.ts` increases coupling to the widget module | `runtime.ts` already imports `AgentActivity` and `AgentWidget` from the same module. `UICtx` is a lean 2-method interface, not a Pi SDK type. Coupling is minimal and consistent. |
234
+ | Remaining output-argument writes (`defaultMaxTurns`, `graceTurns`) are left unaddressed | Explicitly out of scope per the issue's acceptance criteria. Can be addressed in a follow-up if the pattern becomes painful. |
235
+
236
+ ## Open Questions
237
+
238
+ - Should `createSubagentRuntime()` be removed in favor of `new SubagentRuntime()` directly?
239
+ The factory adds no value over a no-arg constructor, but removing it widens the blast radius without benefit.
240
+ Defer — remove it as part of #70 or a future cleanup if it feels redundant.
@@ -0,0 +1,46 @@
1
+ ---
2
+ issue: 72
3
+ issue_title: "refactor: dependency-inject AgentManager's collaborators"
4
+ ---
5
+
6
+ # Retro: #72 — dependency-inject AgentManager's collaborators
7
+
8
+ ## Final Retrospective (2026-05-20T17:50:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Defined `AgentRunner` and `WorktreeManager` interfaces, converted `AgentManager`'s 6-positional-parameter constructor to an options bag with injected collaborators, migrated all 19 test sites from `vi.mock()` to `vi.fn()` stubs, and added 7 new DI-enabled tests.
13
+ The planning phase required significant user redirection to arrive at the right abstractions; the TDD execution phase was clean with zero rework.
14
+ Released as `pi-subagents-v5.6.0`.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - The `ask_user` interactions during planning surfaced genuine design decisions (options bag vs positional constructor, lifecycle callback grouping) that the issue body left open.
21
+ The user's responses were substantive and redirecting.
22
+ - The user's "make the change that makes the change easy" framing identified #84 (`GitWorktreeManager` extraction) as a prerequisite, which made #72's implementation clean — zero type-shuffling needed.
23
+ - The lift-and-shift test migration (Phase B → Phase C) worked exactly as planned: introduce `createManager()` helper under the old constructor, then switch it to the options bag atomically.
24
+ All 19 test sites migrated with no logic changes.
25
+ - `Promise.withResolvers` (ES2024) in the new queueing test made controlled async coordination clean — no manual resolve/reject wiring.
26
+
27
+ #### What caused friction (agent side)
28
+
29
+ - `wrong-abstraction` — Spent ~4 analysis cycles on "how to move types between files" (`ToolActivity`, `RunOptions`, `WorktreeInfo` → `types.ts`) when the real question was "what objects want to exist?"
30
+ The user had to redirect three times: "are there real objects with state?", "what state IS in AgentRunner?", and "we haven't pulled all the threads."
31
+ Impact: added ~10 minutes of back-and-forth during planning, but ultimately produced a better design (stateful `WorktreeManager` vs stateless `AgentRunner` seam, plus #84 as prep).
32
+ The dependency-graph analysis itself was sound — it confirmed no circular deps — but it answered a question nobody was asking.
33
+
34
+ - `premature-convergence` — First draft of the plan included `WorktreeManager` extraction as "Phase A step 1" inside #72.
35
+ The user asked "did we create another issue that we need to tackle first?"
36
+ — pointing out that the prep work should be its own issue.
37
+ Impact: minor rework to update the plan and file #84; no code rework since it was caught during planning. (User-caught.)
38
+
39
+ #### What caused friction (user side)
40
+
41
+ - The user's early redirect ("take a step back — does the AgentManager really need six params?") could have been even more direct — e.g., "before we discuss constructor shape, what higher-level abstractions are missing?"
42
+ That said, the Socratic approach ultimately led to a better shared understanding of why `WorktreeManager` is a real object and `AgentRunner` is a seam.
43
+
44
+ ### Changes made
45
+
46
+ 1. Retro file created at `packages/pi-subagents/docs/retro/0072-inject-agent-manager-collaborators.md`.
@@ -0,0 +1,37 @@
1
+ ---
2
+ issue: 84
3
+ issue_title: "refactor: extract GitWorktreeManager class from worktree.ts free functions"
4
+ ---
5
+
6
+ # Retro: #84 — extract GitWorktreeManager class from worktree.ts free functions
7
+
8
+ ## Final Retrospective (2026-05-20T13:31:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Extracted a `WorktreeManager` interface and `GitWorktreeManager` class from the three free functions in `worktree.ts`.
13
+ The two-step TDD cycle (add tests → add implementation) executed cleanly with no rework or deviations from the plan.
14
+ Released as `pi-subagents-v5.5.0`.
15
+
16
+ ### Observations
17
+
18
+ #### What went well
19
+
20
+ - The issue body included an exact "Proposed Interface" section with TypeScript code, which made the plan nearly mechanical and eliminated all design ambiguity.
21
+ The `ask-user` step was correctly skipped.
22
+ - The two-step TDD cycle was appropriately minimal for a thin delegation extraction — no over-engineering of the test or commit structure.
23
+ - The full pipeline (plan → TDD → ship → release) completed in a single pass with zero corrections.
24
+
25
+ #### What caused friction (agent side)
26
+
27
+ No friction observed.
28
+ The issue was well-scoped and the existing pipeline instructions handled every step.
29
+
30
+ #### What caused friction (user side)
31
+
32
+ No friction observed.
33
+ The pipeline was driven cleanly with `/plan-issue` → `/tdd-plan` → `/ship-issue`.
34
+
35
+ ### Changes made
36
+
37
+ 1. Retro file created at `packages/pi-subagents/docs/retro/0084-extract-git-worktree-manager.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "5.5.0",
3
+ "version": "5.7.0",
4
4
  "exports": {
5
5
  ".": "./src/service.ts"
6
6
  },
@@ -9,12 +9,12 @@
9
9
  import { randomUUID } from "node:crypto";
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
11
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
- import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
12
+ import type { AgentRunner, ToolActivity } from "./agent-runner.js";
13
13
  import { debugLog } from "./debug.js";
14
14
  import type { RunConfig } from "./runtime.js";
15
15
  import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
16
16
  import { addUsage } from "./usage.js";
17
- import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
17
+ import type { WorktreeManager } from "./worktree.js";
18
18
 
19
19
  export type OnAgentComplete = (record: AgentRecord) => void;
20
20
  export type OnAgentStart = (record: AgentRecord) => void;
@@ -24,6 +24,16 @@ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; toke
24
24
  /** Default max concurrent background agents. */
25
25
  const DEFAULT_MAX_CONCURRENT = 4;
26
26
 
27
+ export interface AgentManagerOptions {
28
+ runner: AgentRunner;
29
+ worktrees: WorktreeManager;
30
+ maxConcurrent?: number;
31
+ getRunConfig?: () => RunConfig;
32
+ onStart?: OnAgentStart;
33
+ onComplete?: OnAgentComplete;
34
+ onCompact?: OnAgentCompact;
35
+ }
36
+
27
37
  interface SpawnArgs {
28
38
  pi: ExtensionAPI;
29
39
  ctx: ExtensionContext;
@@ -72,7 +82,8 @@ export class AgentManager {
72
82
  private onComplete?: OnAgentComplete;
73
83
  private onStart?: OnAgentStart;
74
84
  private onCompact?: OnAgentCompact;
75
- private readonly cwd: string;
85
+ private readonly runner: AgentRunner;
86
+ private readonly worktrees: WorktreeManager;
76
87
  private maxConcurrent: number;
77
88
  private getRunConfig?: () => RunConfig;
78
89
 
@@ -81,20 +92,14 @@ export class AgentManager {
81
92
  /** Number of currently running background agents. */
82
93
  private runningBackground = 0;
83
94
 
84
- constructor(
85
- cwd: string,
86
- onComplete?: OnAgentComplete,
87
- maxConcurrent = DEFAULT_MAX_CONCURRENT,
88
- onStart?: OnAgentStart,
89
- onCompact?: OnAgentCompact,
90
- getRunConfig?: () => RunConfig,
91
- ) {
92
- this.cwd = cwd;
93
- this.onComplete = onComplete;
94
- this.onStart = onStart;
95
- this.onCompact = onCompact;
96
- this.getRunConfig = getRunConfig;
97
- this.maxConcurrent = maxConcurrent;
95
+ constructor(options: AgentManagerOptions) {
96
+ this.runner = options.runner;
97
+ this.worktrees = options.worktrees;
98
+ this.onComplete = options.onComplete;
99
+ this.onStart = options.onStart;
100
+ this.onCompact = options.onCompact;
101
+ this.getRunConfig = options.getRunConfig;
102
+ this.maxConcurrent = options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
98
103
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
99
104
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
100
105
  this.cleanupInterval.unref();
@@ -164,7 +169,7 @@ export class AgentManager {
164
169
  // BEFORE state mutation so a throw doesn't leave the record half-running.
165
170
  let worktreeCwd: string | undefined;
166
171
  if (options.isolation === "worktree") {
167
- const wt = createWorktree(ctx.cwd, id);
172
+ const wt = this.worktrees.create(id);
168
173
  if (!wt) {
169
174
  throw new Error(
170
175
  'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
@@ -190,7 +195,7 @@ export class AgentManager {
190
195
  const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
191
196
 
192
197
  const runConfig = this.getRunConfig?.();
193
- const promise = runAgent(ctx, type, prompt, {
198
+ const promise = this.runner.run(ctx, type, prompt, {
194
199
  pi,
195
200
  model: options.model,
196
201
  maxTurns: options.maxTurns,
@@ -247,7 +252,7 @@ export class AgentManager {
247
252
 
248
253
  // Clean up worktree if used
249
254
  if (record.worktree) {
250
- const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
255
+ const wtResult = this.worktrees.cleanup(record.worktree, options.description);
251
256
  record.worktreeResult = wtResult;
252
257
  if (wtResult.hasChanges && wtResult.branch) {
253
258
  record.result = (record.result ?? "") +
@@ -281,7 +286,7 @@ export class AgentManager {
281
286
  // Best-effort worktree cleanup on error
282
287
  if (record.worktree) {
283
288
  try {
284
- const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
289
+ const wtResult = this.worktrees.cleanup(record.worktree, options.description);
285
290
  record.worktreeResult = wtResult;
286
291
  } catch (err) { debugLog("cleanupWorktree on agent error", err); }
287
292
  }
@@ -351,7 +356,7 @@ export class AgentManager {
351
356
  record.error = undefined;
352
357
 
353
358
  try {
354
- const responseText = await resumeAgent(record.session, prompt, {
359
+ const responseText = await this.runner.resume(record.session, prompt, {
355
360
  onToolActivity: (activity) => {
356
361
  if (activity.type === "end") record.toolUses++;
357
362
  },
@@ -488,6 +493,6 @@ export class AgentManager {
488
493
  }
489
494
  this.agents.clear();
490
495
  // Prune any orphaned git worktrees (crash recovery)
491
- try { pruneWorktrees(this.cwd); } catch (err) { debugLog("pruneWorktrees on dispose", err); }
496
+ try { this.worktrees.prune(); } catch (err) { debugLog("pruneWorktrees on dispose", err); }
492
497
  }
493
498
  }
@@ -129,6 +129,23 @@ export interface RunResult {
129
129
  steered: boolean;
130
130
  }
131
131
 
132
+ /** Options for resuming an existing agent session. */
133
+ export interface ResumeOptions {
134
+ onToolActivity?: (activity: ToolActivity) => void;
135
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
136
+ onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
137
+ signal?: AbortSignal;
138
+ }
139
+
140
+ /**
141
+ * Execution boundary: decouples AgentManager (lifecycle management) from the
142
+ * SDK session orchestration in runAgent/resumeAgent.
143
+ */
144
+ export interface AgentRunner {
145
+ run(ctx: ExtensionContext, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult>;
146
+ resume(session: AgentSession, prompt: string, options?: ResumeOptions): Promise<string>;
147
+ }
148
+
132
149
  /**
133
150
  * Subscribe to a session and collect the last assistant message text.
134
151
  * Returns an object with a `getText()` getter and an `unsubscribe` function.
@@ -375,19 +392,7 @@ export async function runAgent(
375
392
  export async function resumeAgent(
376
393
  session: AgentSession,
377
394
  prompt: string,
378
- options: {
379
- onToolActivity?: (activity: ToolActivity) => void;
380
- onAssistantUsage?: (usage: {
381
- input: number;
382
- output: number;
383
- cacheWrite: number;
384
- }) => void;
385
- onCompaction?: (info: {
386
- reason: "manual" | "threshold" | "overflow";
387
- tokensBefore: number;
388
- }) => void;
389
- signal?: AbortSignal;
390
- } = {},
395
+ options: ResumeOptions = {},
391
396
  ): Promise<string> {
392
397
  const collector = collectResponseText(session);
393
398
  const cleanupAbort = forwardAbortSignal(session, options.signal);
package/src/index.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  import { join } from "node:path";
14
14
  import { defineTool, type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
15
15
  import { AgentManager } from "./agent-manager.js";
16
- import { getAgentConversation, normalizeMaxTurns, steerAgent } from "./agent-runner.js";
16
+ import { getAgentConversation, normalizeMaxTurns, resumeAgent, runAgent, steerAgent } from "./agent-runner.js";
17
17
  import { getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveAgentConfig, } from "./agent-types.js";
18
18
  import { loadCustomAgents } from "./custom-agents.js";
19
19
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
@@ -33,6 +33,7 @@ import {
33
33
  AgentWidget,
34
34
  type UICtx,
35
35
  } from "./ui/agent-widget.js";
36
+ import { GitWorktreeManager } from "./worktree.js";
36
37
 
37
38
  export default function (pi: ExtensionAPI) {
38
39
  // ---- Register custom notification renderer ----
@@ -56,54 +57,60 @@ export default function (pi: ExtensionAPI) {
56
57
  const notifications = createNotificationSystem({
57
58
  sendMessage: (msg, opts) => pi.sendMessage(msg as any, opts as any),
58
59
  agentActivity: runtime.agentActivity,
59
- markFinished: (id) => runtime.widget!.markFinished(id),
60
- updateWidget: () => runtime.widget!.update(),
60
+ markFinished: (id) => runtime.markFinished(id),
61
+ updateWidget: () => runtime.updateWidget(),
61
62
  });
62
63
 
63
64
  // Background completion: emit lifecycle event and delegate to notification system
64
- const manager = new AgentManager(process.cwd(), (record) => {
65
- // Emit lifecycle event based on terminal status
66
- const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
67
- const eventData = buildEventData(record);
68
- if (isError) {
69
- pi.events.emit("subagents:failed", eventData);
70
- } else {
71
- pi.events.emit("subagents:completed", eventData);
72
- }
65
+ const manager = new AgentManager({
66
+ runner: { run: runAgent, resume: resumeAgent },
67
+ worktrees: new GitWorktreeManager(process.cwd()),
68
+ onComplete: (record) => {
69
+ // Emit lifecycle event based on terminal status
70
+ const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
71
+ const eventData = buildEventData(record);
72
+ if (isError) {
73
+ pi.events.emit("subagents:failed", eventData);
74
+ } else {
75
+ pi.events.emit("subagents:completed", eventData);
76
+ }
73
77
 
74
- // Persist final record for cross-extension history reconstruction
75
- pi.appendEntry("subagents:record", {
76
- id: record.id, type: record.type, description: record.description,
77
- status: record.status, result: record.result, error: record.error,
78
- startedAt: record.startedAt, completedAt: record.completedAt,
79
- });
78
+ // Persist final record for cross-extension history reconstruction
79
+ pi.appendEntry("subagents:record", {
80
+ id: record.id, type: record.type, description: record.description,
81
+ status: record.status, result: record.result, error: record.error,
82
+ startedAt: record.startedAt, completedAt: record.completedAt,
83
+ });
80
84
 
81
- // Skip notification if result was already consumed via get_subagent_result
82
- if (record.resultConsumed) {
83
- notifications.cleanupCompleted(record.id);
84
- return;
85
- }
85
+ // Skip notification if result was already consumed via get_subagent_result
86
+ if (record.resultConsumed) {
87
+ notifications.cleanupCompleted(record.id);
88
+ return;
89
+ }
86
90
 
87
- notifications.sendCompletion(record);
88
- }, undefined, (record) => {
89
- // Emit started event when agent transitions to running (including from queue)
90
- pi.events.emit("subagents:started", {
91
- id: record.id,
92
- type: record.type,
93
- description: record.description,
94
- });
95
- }, (record, info) => {
96
- // Emit compacted event when agent's session compacts (preserves count on record).
97
- pi.events.emit("subagents:compacted", {
98
- id: record.id,
99
- type: record.type,
100
- description: record.description,
101
- reason: info.reason,
102
- tokensBefore: info.tokensBefore,
103
- compactionCount: record.compactionCount,
104
- });
105
- },
106
- () => ({ defaultMaxTurns: runtime.defaultMaxTurns, graceTurns: runtime.graceTurns }));
91
+ notifications.sendCompletion(record);
92
+ },
93
+ onStart: (record) => {
94
+ // Emit started event when agent transitions to running (including from queue)
95
+ pi.events.emit("subagents:started", {
96
+ id: record.id,
97
+ type: record.type,
98
+ description: record.description,
99
+ });
100
+ },
101
+ onCompact: (record, info) => {
102
+ // Emit compacted event when agent's session compacts (preserves count on record).
103
+ pi.events.emit("subagents:compacted", {
104
+ id: record.id,
105
+ type: record.type,
106
+ description: record.description,
107
+ reason: info.reason,
108
+ tokensBefore: info.tokensBefore,
109
+ compactionCount: record.compactionCount,
110
+ });
111
+ },
112
+ getRunConfig: () => ({ defaultMaxTurns: runtime.defaultMaxTurns, graceTurns: runtime.graceTurns }),
113
+ });
107
114
 
108
115
  // Typed service published via Symbol.for() for cross-extension access.
109
116
  // Consumers: const { getSubagentsService } = await import("@gotgenes/pi-subagents");
@@ -116,7 +123,7 @@ export default function (pi: ExtensionAPI) {
116
123
  publishSubagentsService(service);
117
124
 
118
125
  pi.on("session_start", async (_event, ctx) => {
119
- runtime.currentCtx = { pi, ctx };
126
+ runtime.setSessionContext(pi, ctx);
120
127
  manager.clearCompleted();
121
128
  });
122
129
 
@@ -128,7 +135,7 @@ export default function (pi: ExtensionAPI) {
128
135
  // If the session is going down, there's nothing left to consume agent results.
129
136
  pi.on("session_shutdown", async () => {
130
137
  unpublishSubagentsService();
131
- runtime.currentCtx = undefined;
138
+ runtime.clearSessionContext();
132
139
  manager.abortAll();
133
140
  notifications.dispose();
134
141
  manager.dispose();
@@ -139,8 +146,8 @@ export default function (pi: ExtensionAPI) {
139
146
 
140
147
  // Grab UI context from first tool execution + clear lingering widget on new turn
141
148
  pi.on("tool_execution_start", async (_event, ctx) => {
142
- runtime.widget!.setUICtx(ctx.ui as UICtx);
143
- runtime.widget!.onTurnStart();
149
+ runtime.setUICtx(ctx.ui as UICtx);
150
+ runtime.onTurnStart();
144
151
  });
145
152
 
146
153
  /** Build the full type list text dynamically from the unified registry. */
@@ -194,10 +201,10 @@ export default function (pi: ExtensionAPI) {
194
201
  listAgents: () => manager.listAgents(),
195
202
  },
196
203
  widget: {
197
- setUICtx: (ctx) => runtime.widget!.setUICtx(ctx as UICtx),
198
- ensureTimer: () => runtime.widget!.ensureTimer(),
199
- update: () => runtime.widget!.update(),
200
- markFinished: (id) => runtime.widget!.markFinished(id),
204
+ setUICtx: (ctx) => runtime.setUICtx(ctx as UICtx),
205
+ ensureTimer: () => runtime.ensureTimer(),
206
+ update: () => runtime.updateWidget(),
207
+ markFinished: (id) => runtime.markFinished(id),
201
208
  },
202
209
  agentActivity: runtime.agentActivity,
203
210
  emitEvent: (name, data) => pi.events.emit(name, data),