@gotgenes/pi-subagents 10.0.1 → 10.2.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,80 @@
1
+ ---
2
+ issue: 227
3
+ issue_title: "Evolve AgentRecord into Agent with behavior (Phase 15, Step 1)"
4
+ ---
5
+
6
+ # Retro: #227 — Evolve AgentRecord into Agent with behavior
7
+
8
+ ## Stage: Planning (2026-05-27T12:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced an 8-step TDD plan to move per-agent behavior (`abort`, `queueSteer`/`flushPendingSteers`, `setupWorktree`) from `AgentManager` into `AgentRecord`, then rename `AgentRecord` → `Agent` across the codebase.
13
+ The plan follows a "add behavior first, rename last" strategy to keep behavior diffs small and the rename commit purely mechanical.
14
+
15
+ ### Observations
16
+
17
+ - `AgentRecord` is internal-only (public API is `SubagentRecord` in `service.ts`), so the rename is non-breaking.
18
+ - The `queueSteer` method can be removed from `AgentManagerLike` and `SteerToolManager` interfaces entirely — both callers (`steer-tool`, `service-adapter`) already hold the agent reference from `getRecord()`, so they can call `agent.queueSteer()` directly.
19
+ - Queue removal in `abort()` must stay on `AgentManager` until #230 extracts `ConcurrencyQueue`.
20
+ - `RunHandle` ownership explicitly deferred to #228 — the plan does not touch `RunHandle` at all.
21
+ - The rename step (step 7) touches ~30 files but is purely mechanical; all behavior changes land in steps 1–6.
22
+
23
+ ## Stage: Implementation — TDD (2026-05-27T13:00:00Z)
24
+
25
+ ### Session summary
26
+
27
+ Completed all 8 TDD steps from the plan.
28
+ Added 9 new tests (steer buffering, `abort()`, `setupWorktree()`) and migrated 977 existing tests to the renamed `Agent` class.
29
+ Test count went from 977 to 986 across 62 test files.
30
+
31
+ ### Observations
32
+
33
+ - Fallow reported `AgentInit` and `AgentStatus` as unused type exports from `types.ts`; suppressed with `// fallow-ignore-next-line unused-type` (correct singular form — tool's error message hints at this).
34
+ - `ESLint` auto-removed an `as any` cast in the `setupWorktree` test (the mock `WorktreeManager` already satisfied the interface structurally); staged and re-committed cleanly.
35
+ - Biome auto-formatted several test files during the rename commit; re-staged and re-committed.
36
+ - Pre-completion reviewer returned **WARN** for 4 stale diagram/table references in `architecture.md` and the `package-pi-subagents` skill table; all fixed before the final commit.
37
+ - No deviations from the plan's behavior design; the `queueSteer` removal from manager interfaces worked exactly as anticipated in the retro notes.
38
+
39
+ ## Stage: Final Retrospective (2026-05-27T17:22:00Z)
40
+
41
+ ### Session summary
42
+
43
+ Completed all stages in a single session: planning, 8 TDD steps, pre-completion review, shipping, and release as `pi-subagents-v10.1.0`.
44
+ Three behaviors (`abort`, steer buffering, worktree setup) moved from `AgentManager` to `Agent`, followed by a codebase-wide rename (33 files).
45
+
46
+ ### Observations
47
+
48
+ #### What went well
49
+
50
+ - The "add behavior first, rename last" strategy kept behavior-adding commits small (1–2 files each) and the rename commit purely mechanical.
51
+ - Planning identified that `queueSteer` could be removed from `AgentManagerLike` and `SteerToolManager` entirely — this simplified the delegation step and eliminated an unnecessary indirection layer.
52
+ - Pre-completion reviewer caught 4 stale Mermaid diagram references and a skill table entry that the plan's step 8 did not anticipate; all fixed before shipping.
53
+
54
+ #### What caused friction (agent side)
55
+
56
+ 1. `scope-drift` — Added `AgentInit` and `AgentStatus` to the `types.ts` re-export barrel during the rename step without verifying any file imports them from that path.
57
+ Impact: fallow flagged dead code, triggering a 4-call suppression trial (`unused-export` → `unused-types` → `unused-type`), then the user identified the real fix (remove the speculative re-exports entirely), requiring a follow-up `fix:` commit after docs were already done.
58
+ 2. `missing-context` — During the mechanical rename (step 7), `sed` commands matched `#test/helpers/make-record` but missed the relative import `"./helpers/make-record"` in `conversation-viewer.test.ts`.
59
+ Impact: `pnpm run check` caught it in 1 tool call; minimal rework.
60
+ 3. `missing-context` — The fallow skill documents `unused-export` as a suppression kind but not `unused-type`.
61
+ Impact: 3 wrong guesses before the correct suppression syntax.
62
+ Self-identified after fallow's error message suggested the correct kind name.
63
+
64
+ #### What caused friction (user side)
65
+
66
+ - The user's question about whether the fallow suppressions could be removed in a future step was a valuable prompt — it surfaced that the re-exports were speculative and could be removed immediately.
67
+ Earlier intervention (e.g., during the TDD stage when the suppressions were added) would have avoided the `fix:` commit.
68
+
69
+ ### Diagnostic details
70
+
71
+ - **Model-performance correlation** — Pre-completion reviewer ran as `pre-completion-reviewer` subagent (default model); appropriate for judgment-heavy work (doc staleness, code design review).
72
+ No model mismatches.
73
+ - **Feedback-loop gap analysis** — `pnpm run check` was run after every delegation step (steps 2, 4, 6, 7) and after every behavior-adding step (steps 1, 3, 5).
74
+ Verification was incremental throughout, not deferred to the end.
75
+ The `conversation-viewer.test.ts` import miss in step 7 was caught immediately by the type checker.
76
+
77
+ ### Changes made
78
+
79
+ 1. `.pi/skills/fallow/SKILL.md` — Added `unused-type` suppression example alongside existing `unused-export` example.
80
+ 2. `AGENTS.md` — Added "no speculative re-exports" rule to Code Style section.
@@ -0,0 +1,42 @@
1
+ ---
2
+ issue: 228
3
+ issue_title: "Convert startAgent to async/await, move run lifecycle to Agent (Phase 15, Step 2)"
4
+ ---
5
+
6
+ # Retro: #228 — Convert startAgent to async/await, move run lifecycle to Agent
7
+
8
+ ## Stage: Planning (2026-05-27T20:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Planned the async `startAgent` conversion and decided to dissolve `RunHandle` into Agent methods rather than moving it as a separate class.
13
+ Identified three preparatory steps (narrow promise type, add Agent methods, hoist worktree setup) that make the final async conversion a minimal diff.
14
+
15
+ ### Observations
16
+
17
+ - The original issue proposed `Agent.createRunHandle()` as a factory, keeping RunHandle as a separate class.
18
+ Analysis showed 5 of 6 RunHandle concerns are Agent state mutations — RunHandle is doing work that belongs on Agent.
19
+ The clincher was `resume()` in `agent-manager.ts`: it duplicates RunHandle's pattern manually, and #232 wants to unify them.
20
+ Dissolving RunHandle gives both `startAgent` and `resume` the same primitives (`completeRun`, `failRun`, `releaseListeners`).
21
+ - The synchronous-throw contract in `spawn()` for worktree failures requires hoisting `record.setupWorktree()` out of `startAgent` before the async conversion.
22
+ Without this prep step, async `startAgent` would turn the throw into a rejected promise that `spawn()` doesn't catch.
23
+ - `promise: Promise<string>` → `Promise<void>` is safe because the resolved string is dead — every consumer reads `record.result` instead.
24
+ Only one test assertion reads the resolved value.
25
+ - `completeRun`/`failRun` take `worktrees: WorktreeManager` as a parameter rather than storing it on Agent (ISP — only needed at run end, exactly two callers).
26
+
27
+ ## Stage: Implementation — TDD (2026-05-27T20:40:00Z)
28
+
29
+ ### Session summary
30
+
31
+ Implemented all 6 TDD steps: narrowed `promise` to `Promise<void>`, added 6 run lifecycle methods to Agent (+19 tests), replaced `RunHandle` with Agent methods (-85 LOC), hoisted worktree setup to callers, converted `startAgent` to async/await, and updated architecture docs.
32
+ Test count: 986 → 1005.
33
+
34
+ ### Observations
35
+
36
+ - Step 1 (promise narrowing) required fixing 3 additional test files not listed in the plan: `make-agent.test.ts`, `service-adapter.test.ts`, `get-result-tool.test.ts`.
37
+ All were trivial `Promise.resolve("done")` → `Promise.resolve()` changes and a cast removal.
38
+ - The lift-and-shift approach worked cleanly — each of the 5 implementation commits was small and independently green.
39
+ The most impactful commit was step 3 (replace RunHandle, -96/+6 lines) which was risk-free because step 2 had already introduced the Agent methods.
40
+ - Pre-completion reviewer returned WARN for stale `AgentRecord` and `run-handle.ts` references in `architecture.md` class diagram and layout listing.
41
+ These were pre-existing staleness from #227's rename that wasn't fully propagated to Mermaid diagrams.
42
+ Fixed by amending the docs commit.
@@ -35,3 +35,36 @@ A follow-up skill maintenance commit updated `.pi/skills/package-pi-subagents/SK
35
35
  - Pre-completion reviewer returned **WARN** for stale `package-pi-subagents` skill content: the "Patch 2 scheduled for removal" note and the `// Patch 2 (RepOne` grep instruction were both stale after #239 completion.
36
36
  Fixed immediately as a follow-up `docs:` commit before writing retro notes.
37
37
  - `pnpm fallow dead-code` passed with 0 issues — no orphaned exports left behind.
38
+
39
+ ## Stage: Final Retrospective (2026-05-27T14:40:00Z)
40
+
41
+ ### Session summary
42
+
43
+ Completed the full issue lifecycle (plan → TDD → ship → retro) in a single continuous session.
44
+ Issue #239 shipped as `pi-subagents-v10.0.1` with 7 commits (2 refactor, 4 docs, 1 release).
45
+ Phase 14 is now fully complete, unblocking Phase 15 (#227–#232).
46
+
47
+ ### Observations
48
+
49
+ #### What went well
50
+
51
+ - The plan's type-dependency-chain ordering (`SessionConfig` first → expected compile errors → `agent-runner.ts` resolves them) produced zero surprises during TDD.
52
+ Each step's red/green boundary was exactly where the plan predicted.
53
+ - Pre-completion reviewer caught stale `package-pi-subagents` skill content ("Patch 2 scheduled for removal" and a `// Patch 2 (RepOne` grep instruction) that no longer matched source.
54
+ Fixed before shipping — the reviewer earned its keep.
55
+ - Multi-model routing was well-matched: `claude-sonnet-4-6` for planning and TDD (judgment + code), `deepseek-v4-flash` for shipping (mechanical checklist), `claude-opus-4-6` for retrospective (synthesis).
56
+ - Feedback loops were incremental: `pnpm vitest run` after each test change, `pnpm run check` after Step 1 to confirm expected errors, full suite + lint + dead-code after all steps.
57
+
58
+ #### What caused friction (agent side)
59
+
60
+ No friction points identified.
61
+ This was the final step of a 3-step phase with both dependencies already closed, a well-scoped plan, and internal-only API changes — the simplest possible lifecycle.
62
+
63
+ #### What caused friction (user side)
64
+
65
+ None observed.
66
+ The user's involvement was limited to issuing the four standard lifecycle commands (`/plan-issue`, `/tdd-plan`, `/ship-issue`, `/retro`) with no corrections or redirections needed.
67
+
68
+ ### Changes made
69
+
70
+ 1. Appended Final Retrospective stage entry to `packages/pi-subagents/docs/retro/0239-collapse-filter-active-tools.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "10.0.1",
3
+ "version": "10.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/service.ts"
@@ -1,5 +1,5 @@
1
1
  /**
2
- * agent-manager.ts Tracks agents, background execution, resume support.
2
+ * agent-manager.ts - Tracks agents, background execution, resume support.
3
3
  *
4
4
  * Background agents are subject to a configurable concurrency limit (default: 4).
5
5
  * Excess agents are queued and auto-started as running agents complete.
@@ -11,114 +11,25 @@ import type { Model } from "@earendil-works/pi-ai";
11
11
  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
- import { AgentRecord } from "#src/lifecycle/agent-record";
15
- import type { AgentRunner, RunResult } from "#src/lifecycle/agent-runner";
14
+ import { Agent } from "#src/lifecycle/agent";
15
+ import type { AgentRunner } 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
- import { WorktreeState } from "#src/lifecycle/worktree-state";
18
+
19
19
  import { NotificationState } from "#src/observation/notification-state";
20
- import { subscribeRecordObserver } from "#src/observation/record-observer";
20
+ import { subscribeAgentObserver } 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
-
113
24
  export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
114
25
 
115
26
  /** Observer interface for agent lifecycle notifications. */
116
27
  export interface AgentManagerObserver {
117
- onAgentStarted(record: AgentRecord): void;
118
- onAgentCompleted(record: AgentRecord): void;
119
- onAgentCompacted(record: AgentRecord, info: CompactionInfo): void;
28
+ onAgentStarted(record: Agent): void;
29
+ onAgentCompleted(record: Agent): void;
30
+ onAgentCompacted(record: Agent, info: CompactionInfo): void;
120
31
  /** Fires synchronously after a background agent record is created (before startAgent). */
121
- onAgentCreated(record: AgentRecord): void;
32
+ onAgentCreated(record: Agent): void;
122
33
  }
123
34
 
124
35
  /** Default max concurrent background agents. */
@@ -129,7 +40,7 @@ export interface AgentManagerOptions {
129
40
  worktrees: WorktreeManager;
130
41
  exec: ShellExec;
131
42
  registry: AgentTypeRegistry;
132
- /** Injected getter for the concurrency limit owned by SettingsManager. */
43
+ /** Injected getter for the concurrency limit - owned by SettingsManager. */
133
44
  getMaxConcurrent?: () => number;
134
45
  getRunConfig?: () => RunConfig;
135
46
  observer?: AgentManagerObserver;
@@ -160,25 +71,25 @@ export interface AgentSpawnConfig {
160
71
  thinkingLevel?: ThinkingLevel;
161
72
  isBackground?: boolean;
162
73
  /**
163
- * Skip the maxConcurrent queue check for this spawn start immediately even
74
+ * Skip the maxConcurrent queue check for this spawn - start immediately even
164
75
  * if the configured concurrency limit would otherwise queue it. Useful for
165
76
  * callers (e.g. cross-extension RPC) that must not be deferred by the queue.
166
77
  */
167
78
  bypassQueue?: boolean;
168
- /** Isolation mode "worktree" creates a temp git worktree for the agent. */
79
+ /** Isolation mode - "worktree" creates a temp git worktree for the agent. */
169
80
  isolation?: IsolationMode;
170
81
  /** Resolved invocation snapshot captured for UI display. */
171
82
  invocation?: AgentInvocation;
172
- /** Parent abort signal when aborted, the subagent is also stopped. */
83
+ /** Parent abort signal - when aborted, the subagent is also stopped. */
173
84
  signal?: AbortSignal;
174
- /** Called when the agent session is created receives the session and the agent's record. */
175
- onSessionCreated?: (session: AgentSession, record: AgentRecord) => void;
176
- /** Parent session identity grouped fields that travel together from the tool boundary. */
85
+ /** Called when the agent session is created - receives the session and the agent's record. */
86
+ onSessionCreated?: (session: AgentSession, record: Agent) => void;
87
+ /** Parent session identity - grouped fields that travel together from the tool boundary. */
177
88
  parentSession?: ParentSessionInfo;
178
89
  }
179
90
 
180
91
  export class AgentManager {
181
- private agents = new Map<string, AgentRecord>();
92
+ private agents = new Map<string, Agent>();
182
93
  private cleanupInterval: ReturnType<typeof setInterval>;
183
94
  private readonly observer?: AgentManagerObserver;
184
95
  private readonly runner: AgentRunner;
@@ -192,9 +103,6 @@ export class AgentManager {
192
103
  private queue: { id: string; args: SpawnArgs }[] = [];
193
104
  /** Number of currently running background agents. */
194
105
  private runningBackground = 0;
195
- /** Steers buffered for agents whose session hasn’t been created yet. */
196
- private pendingSteers = new Map<string, string[]>();
197
-
198
106
  constructor(options: AgentManagerOptions) {
199
107
  this.runner = options.runner;
200
108
  this.worktrees = options.worktrees;
@@ -216,19 +124,6 @@ export class AgentManager {
216
124
  this.drainQueue();
217
125
  }
218
126
 
219
- /**
220
- * Buffer a steer message for an agent whose session isn’t ready yet.
221
- * Returns false if the agent id is not tracked (already cleaned up or unknown).
222
- * Called by steer-tool and service-adapter when record.execution is undefined.
223
- */
224
- queueSteer(id: string, message: string): boolean {
225
- if (!this.agents.has(id)) return false;
226
- const steers = this.pendingSteers.get(id) ?? [];
227
- steers.push(message);
228
- this.pendingSteers.set(id, steers);
229
- return true;
230
- }
231
-
232
127
  /**
233
128
  * Spawn an agent and return its ID immediately (for background use).
234
129
  * If the concurrency limit is reached, the agent is queued.
@@ -241,7 +136,7 @@ export class AgentManager {
241
136
  ): string {
242
137
  const id = randomUUID().slice(0, 17);
243
138
  const abortController = new AbortController();
244
- const record = new AgentRecord({
139
+ const record = new Agent({
245
140
  id,
246
141
  type,
247
142
  description: options.description,
@@ -263,15 +158,16 @@ export class AgentManager {
263
158
  const args: SpawnArgs = { snapshot, type, prompt, options };
264
159
 
265
160
  if (options.isBackground && !options.bypassQueue && this.runningBackground >= this._getMaxConcurrent()) {
266
- // Queue it will be started when a running agent completes
161
+ // Queue it - will be started when a running agent completes
267
162
  this.queue.push({ id, args });
268
163
  return id;
269
164
  }
270
165
 
271
- // startAgent can throw (e.g. strict worktree-isolation failure) clean
166
+ // setupWorktree can throw (e.g. strict worktree-isolation failure) - clean
272
167
  // up the record so callers don't see an orphan in `listAgents()`.
273
168
  try {
274
- this.startAgent(id, record, args);
169
+ record.setupWorktree(this.worktrees, options.isolation);
170
+ record.promise = this.startAgent(id, record, args);
275
171
  } catch (err) {
276
172
  this.agents.delete(id);
277
173
  throw err;
@@ -280,79 +176,53 @@ export class AgentManager {
280
176
  }
281
177
 
282
178
  /** Actually start an agent (called immediately or from queue drain). */
283
- private startAgent(id: string, record: AgentRecord, { snapshot, type, prompt, options }: SpawnArgs) {
284
- const worktreeCwd = this.setupWorktree(id, record, options.isolation);
285
-
179
+ private async startAgent(id: string, record: Agent, { snapshot, type, prompt, options }: SpawnArgs): Promise<void> {
286
180
  record.markRunning(Date.now());
287
181
  if (options.isBackground) this.runningBackground++;
288
182
  this.observer?.onAgentStarted(record);
289
183
 
290
- const handle = new RunHandle(
291
- record, this.worktrees,
184
+ record.setOnRunFinished(
292
185
  options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
293
186
  );
294
- handle.wireSignal(options.signal, () => this.abort(id));
187
+ record.wireSignal(options.signal, () => this.abort(id));
295
188
 
296
189
  const runConfig = this.getRunConfig?.();
297
- record.promise = this.runner.run(snapshot, type, prompt, {
298
- context: {
299
- exec: this.exec,
300
- registry: this.registry,
301
- cwd: worktreeCwd,
302
- parentSession: options.parentSession,
303
- },
304
- model: options.model,
305
- maxTurns: options.maxTurns,
306
- defaultMaxTurns: runConfig?.defaultMaxTurns,
307
- graceTurns: runConfig?.graceTurns,
308
- isolated: options.isolated,
309
- thinkingLevel: options.thinkingLevel,
310
- signal: record.abortController!.signal,
311
- onSessionCreated: (session) => {
312
- // Capture the session file path early so it's available for display
313
- // before the run completes (e.g. in background agent status messages).
314
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
315
- const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
316
- record.execution = { session, outputFile };
317
- this.flushPendingSteers(id, session);
318
- handle.attachObserver(subscribeRecordObserver(session, record, {
319
- onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
320
- }));
321
- options.onSessionCreated?.(session, record);
322
- },
323
- })
324
- .then((result) => handle.complete(result))
325
- .catch((err: unknown) => { handle.fail(err); return ""; });
326
- }
327
-
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
- }
343
-
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(() => {});
190
+ try {
191
+ const result = await this.runner.run(snapshot, type, prompt, {
192
+ context: {
193
+ exec: this.exec,
194
+ registry: this.registry,
195
+ cwd: record.worktreeState?.path,
196
+ parentSession: options.parentSession,
197
+ },
198
+ model: options.model,
199
+ maxTurns: options.maxTurns,
200
+ defaultMaxTurns: runConfig?.defaultMaxTurns,
201
+ graceTurns: runConfig?.graceTurns,
202
+ isolated: options.isolated,
203
+ thinkingLevel: options.thinkingLevel,
204
+ signal: record.abortController!.signal,
205
+ onSessionCreated: (session) => {
206
+ // Capture the session file path early so it's available for display
207
+ // before the run completes (e.g. in background agent status messages).
208
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
209
+ const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
210
+ record.execution = { session, outputFile };
211
+ record.flushPendingSteers(session);
212
+ record.attachObserver(subscribeAgentObserver(session, record, {
213
+ onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
214
+ }));
215
+ options.onSessionCreated?.(session, record);
216
+ },
217
+ });
218
+ record.completeRun(result, this.worktrees);
219
+ } catch (err) {
220
+ record.failRun(err, this.worktrees);
350
221
  }
351
- this.pendingSteers.delete(id);
352
222
  }
353
223
 
354
224
  /** Decrement background counter, notify observer (crash-safe), and drain the queue. */
355
- private finalizeBackgroundRun(record: AgentRecord): void {
225
+ private finalizeBackgroundRun(record: Agent): void {
356
226
  this.runningBackground--;
357
227
  try { this.observer?.onAgentCompleted(record); } catch (err) { debugLog("onAgentCompleted observer", err); }
358
228
  this.drainQueue();
@@ -365,9 +235,10 @@ export class AgentManager {
365
235
  const record = this.agents.get(next.id);
366
236
  if (record?.status !== "queued") continue;
367
237
  try {
368
- this.startAgent(next.id, record, next.args);
238
+ record.setupWorktree(this.worktrees, next.args.options.isolation);
239
+ record.promise = this.startAgent(next.id, record, next.args);
369
240
  } catch (err) {
370
- // Late failure (e.g. strict worktree-isolation) surface on the record
241
+ // Late failure (e.g. strict worktree-isolation) - surface on the record
371
242
  // so the user/agent can see it via /agents, then keep draining.
372
243
  record.markError(err);
373
244
  this.observer?.onAgentCompleted(record);
@@ -384,7 +255,7 @@ export class AgentManager {
384
255
  type: SubagentType,
385
256
  prompt: string,
386
257
  options: Omit<AgentSpawnConfig, "isBackground">,
387
- ): Promise<AgentRecord> {
258
+ ): Promise<Agent> {
388
259
  const id = this.spawn(snapshot, type, prompt, { ...options, isBackground: false });
389
260
  const record = this.agents.get(id)!;
390
261
  await record.promise;
@@ -398,14 +269,14 @@ export class AgentManager {
398
269
  id: string,
399
270
  prompt: string,
400
271
  signal?: AbortSignal,
401
- ): Promise<AgentRecord | undefined> {
272
+ ): Promise<Agent | undefined> {
402
273
  const record = this.agents.get(id);
403
274
  const session = record?.session;
404
275
  if (!session) return undefined;
405
276
 
406
277
  record.resetForResume(Date.now());
407
278
 
408
- const unsubResume = subscribeRecordObserver(session, record, {
279
+ const unsubResume = subscribeAgentObserver(session, record, {
409
280
  onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
410
281
  });
411
282
 
@@ -423,11 +294,11 @@ export class AgentManager {
423
294
  return record;
424
295
  }
425
296
 
426
- getRecord(id: string): AgentRecord | undefined {
297
+ getRecord(id: string): Agent | undefined {
427
298
  return this.agents.get(id);
428
299
  }
429
300
 
430
- listAgents(): AgentRecord[] {
301
+ listAgents(): Agent[] {
431
302
  return [...this.agents.values()].sort(
432
303
  (a, b) => b.startedAt - a.startedAt,
433
304
  );
@@ -444,18 +315,14 @@ export class AgentManager {
444
315
  return true;
445
316
  }
446
317
 
447
- if (record.status !== "running") return false;
448
- record.abortController?.abort();
449
- record.markStopped();
450
- return true;
318
+ return record.abort();
451
319
  }
452
320
 
453
321
  /** Dispose a record's session and remove it from the map. */
454
- private removeRecord(id: string, record: AgentRecord): void {
322
+ private removeRecord(id: string, record: Agent): void {
455
323
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
456
324
  record.session?.dispose?.();
457
325
  this.agents.delete(id);
458
- this.pendingSteers.delete(id);
459
326
  }
460
327
 
461
328
  private cleanup() {
@@ -501,11 +368,7 @@ export class AgentManager {
501
368
  this.queue = [];
502
369
  // Abort running agents
503
370
  for (const record of this.agents.values()) {
504
- if (record.status === "running") {
505
- record.abortController?.abort();
506
- record.markStopped();
507
- count++;
508
- }
371
+ if (record.abort()) count++;
509
372
  }
510
373
  return count;
511
374
  }
@@ -513,7 +376,7 @@ export class AgentManager {
513
376
  /** Wait for all running and queued agents to complete (including queued ones). */
514
377
  // fallow-ignore-next-line unused-class-member
515
378
  async waitForAll(): Promise<void> {
516
- // Loop because drainQueue respects the concurrency limit as running
379
+ // Loop because drainQueue respects the concurrency limit - as running
517
380
  // agents finish they start queued ones, which need awaiting too.
518
381
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop with explicit break
519
382
  while (true) {
@@ -521,7 +384,7 @@ export class AgentManager {
521
384
  const pending = [...this.agents.values()]
522
385
  .filter(r => r.status === "running" || r.status === "queued")
523
386
  .map(r => r.promise)
524
- .filter((p): p is Promise<string> => p != null);
387
+ .filter((p): p is Promise<void> => p != null);
525
388
  if (pending.length === 0) break;
526
389
  await Promise.allSettled(pending);
527
390
  }