@gotgenes/pi-subagents 5.5.0 → 5.6.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,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.6.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v5.5.0...pi-subagents-v5.6.0) (2026-05-20)
9
+
10
+
11
+ ### Features
12
+
13
+ * convert AgentManager to options-bag constructor with DI ([1292cec](https://github.com/gotgenes/pi-packages/commit/1292cec60c24d8e657985c53b9b3413089c2a79d))
14
+ * define AgentRunner interface in agent-runner.ts ([6a3c85a](https://github.com/gotgenes/pi-packages/commit/6a3c85a445daf0e4e8c01620eb6ae1a8237f1766))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * mark [#84](https://github.com/gotgenes/pi-packages/issues/84) as done in plan ([#72](https://github.com/gotgenes/pi-packages/issues/72)) ([5cfa1ec](https://github.com/gotgenes/pi-packages/commit/5cfa1ecf080f95fbd6b4aec05b27cc9672f60267))
20
+ * **retro:** add retro notes for issue [#84](https://github.com/gotgenes/pi-packages/issues/84) ([99d9016](https://github.com/gotgenes/pi-packages/commit/99d90161df5fe9d302514e33c2d2d9fbfb248f25))
21
+
8
22
  ## [5.5.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v5.4.1...pi-subagents-v5.5.0) (2026-05-20)
9
23
 
10
24
 
@@ -35,7 +35,7 @@ Any test of `AgentManager` must mock entire modules via `vi.mock()`, coupling th
35
35
  | #71 | Extract pure agent-session assembler | ✓ Done |
36
36
  | #76 | Inject `cwd` into `AgentManager` | ✓ Done |
37
37
  | #80 | Consolidate `getConfig`/`getAgentConfig` | ✓ Done |
38
- | #84 | Extract `GitWorktreeManager` class from worktree.ts | Pending |
38
+ | #84 | Extract `GitWorktreeManager` class from worktree.ts | Done |
39
39
 
40
40
  ### Prior art
41
41
 
@@ -201,7 +201,7 @@ expect(runner.run).toHaveBeenCalled();
201
201
 
202
202
  ### `src/worktree.ts` (no changes in this issue)
203
203
 
204
- `WorktreeManager` interface and `GitWorktreeManager` class are added by prerequisite #84.
204
+ `WorktreeManager` interface and `GitWorktreeManager` class were added by #84.
205
205
 
206
206
  ### `src/agent-runner.ts` (modified)
207
207
 
@@ -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.6.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 ----
@@ -61,49 +62,55 @@ export default function (pi: ExtensionAPI) {
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");