@gotgenes/pi-subagents 10.2.1 → 11.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ ---
2
+ issue: 229
3
+ issue_title: "Agent born complete: Agent.run() absorbs startAgent (Phase 15, Step 4)"
4
+ ---
5
+
6
+ # Retro: #229 — Agent born complete: Agent.run() absorbs startAgent
7
+
8
+ ## Stage: Planning (2026-05-27T18:00:00Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced a 9-step TDD plan for absorbing `AgentManager.startAgent()` into `Agent.run()`.
13
+ Key design decisions: per-agent `AgentLifecycleObserver` interface passed at construction (chosen over callback fields and EventEmitter), and fully async worktree error surface (chosen over split sync/async).
14
+
15
+ ### Observations
16
+
17
+ - **Observer pattern chosen over callbacks:** The per-agent `AgentLifecycleObserver` interface replaces three separate mechanisms (`onSessionCreated` callback, `setOnRunFinished`, `onCompact` callback).
18
+ All methods are optional, composed by `AgentManager.buildObserver()` per spawn.
19
+ - **`ParentSessionInfo`/`CompactionInfo` relocation needed:** `agent.ts` importing from `agent-manager.ts` would create a circular type import (agent-manager already imports `Agent`).
20
+ Moving both types to `types.ts` in step 1 avoids the cycle.
21
+ - **`AgentInit` grows wide (15+ optional fields):** Making run-config fields optional preserves backward compat for the 55+ `new Agent()` calls in tests.
22
+ Noted as a known smell — follow-up issues (#230 ConcurrencyQueue, potential `AgentInit` restructuring) may address this.
23
+ - **Async error surface changes tool behavior:** `background-spawner.ts`'s try/catch around `manager.spawn()` becomes unreachable for worktree errors.
24
+ Keeping it for robustness; the error surfaces on `record.error` instead.
25
+ - **Lift-and-shift TDD order:** Steps 3–5 incrementally change `AgentInit`, `setupWorktree`, and `completeRun`/`failRun` before step 6 adds `Agent.run()`.
26
+ This avoids a single massive step that rewrites everything at once.
27
+
28
+ ## Stage: Implementation — TDD (2026-05-28T01:00:00Z)
29
+
30
+ ### Session summary
31
+
32
+ Completed all 9 TDD steps in 9 commits (plus 2 planning/retro docs commits).
33
+ Test count went from 1005 to 1020 (+15 tests).
34
+ `AgentManager.startAgent()`, `SpawnArgs`, and `onSessionCreated` callback are deleted.
35
+ `Agent.run()` now owns the full execution lifecycle.
36
+
37
+ ### Observations
38
+
39
+ - **Steps 7–8 merged in practice:** The tool-layer `onSessionCreated` → `observer` migration (step 8) had to be done alongside the `AgentSpawnConfig` change (step 7) because removing the `onSessionCreated` field broke compilation of `background-spawner.ts` and `foreground-runner.ts`.
40
+ This was expected — they share the same type.
41
+ - **`setupWorktree` kept public:** The plan called for making it private in step 4, but it was kept public through step 6 since the manager still called it.
42
+ After step 7 (Agent.run() absorbs the call), it could be made private; left as a minor follow-up (reviewer flagged as WARN).
43
+ - **`isBackground` removed from Agent storage:** The field was declared on `AgentInit` but Agent never reads it — the manager resolves `isBackground` before construction (setting initial status and composing the observer).
44
+ Biome flagged it as unused; removed from stored fields, kept on `AgentInit` for the manager's use.
45
+ - **Worktree error surface confirmed async:** The `agent-manager.test.ts` test for synchronous worktree throw was rewritten to verify the error surfaces on `record.error` after awaiting the promise.
46
+ `background-spawner.ts` try/catch around `spawn()` retained for robustness.
47
+ - **Pre-completion reviewer:** WARN — 3 non-blocking findings: `setupWorktree` not marked private, `isBackground` dead field on `AgentInit`, and `package-pi-subagents` SKILL.md Phase 15 description referencing deleted `startAgent`.
48
+
49
+ ## Stage: Final Retrospective (2026-05-28T01:30:00Z)
50
+
51
+ ### Session summary
52
+
53
+ Planned, implemented (9 TDD steps), shipped, and released `pi-subagents-v11.0.0` in a single session spanning planning → TDD → ship → retro.
54
+ Test count: 1005 → 1020 (+15).
55
+ Also filed #249 (`pi-permission-system` bash external-directory gate bug) discovered during the pre-completion review.
56
+
57
+ ### Observations
58
+
59
+ #### What went well
60
+
61
+ - The lift-and-shift TDD strategy (steps 1–5 incrementally preparing, step 6 adding `Agent.run()`, step 7 rewriting `spawn()`) kept every commit compilable and green.
62
+ No step required backtracking.
63
+ - The `ask-user` call during planning (observer pattern vs callbacks, sync vs async error surface) front-loaded design decisions that would have caused rework if deferred.
64
+ - Pre-commit hooks caught both lint failures (`no-unnecessary-condition` on `abortController?.abort()`, `unbound-method` on observer forwarding) before they reached CI.
65
+
66
+ #### What caused friction (agent side)
67
+
68
+ - `wrong-abstraction` — The plan separated step 7 (remove `onSessionCreated` from `AgentSpawnConfig`) and step 8 (update tool-layer consumers) as distinct commits.
69
+ Removing the field immediately broke `background-spawner.ts` and `foreground-runner.ts` at compilation, forcing a merge.
70
+ Impact: added friction but no rework — the merge was straightforward since both files needed the same `onSessionCreated` → `observer` transformation.
71
+ Added a testing skill rule to catch this pattern in future plans.
72
+
73
+ #### What caused friction (user side)
74
+
75
+ - The pre-completion reviewer's Mermaid validation (`mmdc -o /tmp/mermaid-check.svg`) triggered a permission prompt from `pi-permission-system` despite `/tmp/*` being configured as `"allow"` in the global config.
76
+ This was a genuine bug (#249) in the bash external-directory gate, not a config mistake.
77
+ The prompt interrupted the automated review flow, requiring manual approval.
78
+
79
+ ### Diagnostic details
80
+
81
+ - **Model-performance correlation** — Pre-completion reviewer ran on `claude-sonnet-4-6-20260526`; appropriate for judgment-heavy review work (code design, acceptance criteria, mermaid validation).
82
+ No mismatches detected.
83
+ - **Feedback-loop gap analysis** — `pnpm run check` and `pnpm run test` were run after every TDD step commit, not just at the end.
84
+ `pnpm run lint` ran at the end (post-TDD checks) and at pre-push, which is correct since lint is slower and pre-commit hooks catch most issues incrementally.
85
+
86
+ ### Changes made
87
+
88
+ 1. `.pi/skills/testing/SKILL.md` — added TDD planning rule: when a step removes a field from a shared interface, all downstream readers must update in the same step.
89
+ 2. `packages/pi-subagents/docs/retro/0229-agent-born-complete.md` — appended Final Retrospective stage entry.
@@ -38,3 +38,34 @@ Pre-completion reviewer returned PASS.
38
38
  Fixed as a lint cleanup in the doc commit.
39
39
  - The `sed`-based bulk replacement for `runAgent(..., io)` → `runAgent(..., { io, exec, registry: mockAgentLookup })` missed one multi-line call site (the `rejects.toThrow` test wrapping the call in `expect()`).
40
40
  Caught immediately by the test run.
41
+
42
+ ## Stage: Final Retrospective (2026-05-27T22:43:52Z)
43
+
44
+ ### Session summary
45
+
46
+ Shipped #231 cleanly: CI passed on first push, issue closed, release `pi-subagents-v10.2.1` published.
47
+ The entire issue (plan → TDD → ship) completed in one sitting with no user intervention needed.
48
+
49
+ ### Observations
50
+
51
+ #### What went well
52
+
53
+ - The `RunnerDeps` design was unambiguous — the `ask_user` gate in planning correctly identified the one genuine design choice (`RunContext` fate) and got user input before proceeding.
54
+ - Pre-completion reviewer returned PASS with zero findings, confirming the mechanical refactoring was clean.
55
+ - Merging plan steps 3–5 during TDD was the right call; the testing skill rule about single-call-site interfaces caught the plan's error before any broken commit landed.
56
+
57
+ #### What caused friction (agent side)
58
+
59
+ - `wrong-abstraction` — The plan listed steps 3, 4, and 5 as separate commits and claimed "each commit is independently valid," but removing fields from `RunContext` (step 3) immediately caused TypeScript excess-property errors in `AgentManager` (step 4) and `index.ts` (step 5).
60
+ The existing `/plan-issue` rule (line 109) covers removing exports with single call sites, but did not trigger recognition because this was *shrinking* an interface, not removing one.
61
+ Impact: the TDD agent had to merge three steps on the fly — no rework, but the plan was misleading.
62
+ - `missing-context` — The `sed`-based bulk replacement for `runAgent(..., io)` missed one multi-line call site where `}, io)` appeared on a different line than the opening `runAgent(`.
63
+ Impact: one extra manual edit; caught immediately by the test run.
64
+
65
+ #### What caused friction (user side)
66
+
67
+ - No friction observed — the user's involvement was limited to confirming the `RunContext` design choice during planning.
68
+
69
+ ### Changes made
70
+
71
+ 1. `.pi/prompts/plan-issue.md` — added a rule under TDD Order: when a step removes fields from an interface, include downstream object-literal call-site updates in the same step (TypeScript excess property checking).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "10.2.1",
3
+ "version": "11.0.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/service.ts"
@@ -8,26 +8,22 @@
8
8
 
9
9
  import { randomUUID } from "node:crypto";
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
- import type { AgentSession } from "@earendil-works/pi-coding-agent";
12
11
  import { debugLog } from "#src/debug";
13
- import { Agent } from "#src/lifecycle/agent";
12
+ import { Agent, type AgentLifecycleObserver } from "#src/lifecycle/agent";
14
13
  import type { AgentRunner } from "#src/lifecycle/agent-runner";
15
14
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
16
15
  import type { WorktreeManager } from "#src/lifecycle/worktree";
17
16
 
18
- import { NotificationState } from "#src/observation/notification-state";
19
17
  import { subscribeAgentObserver } from "#src/observation/record-observer";
20
18
  import type { RunConfig } from "#src/runtime";
21
- import type { AgentInvocation, IsolationMode, SubagentType, ThinkingLevel } from "#src/types";
22
-
23
- export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
19
+ import type { AgentInvocation, CompactionInfo, IsolationMode, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
24
20
 
25
21
  /** Observer interface for agent lifecycle notifications. */
26
22
  export interface AgentManagerObserver {
27
23
  onAgentStarted(record: Agent): void;
28
24
  onAgentCompleted(record: Agent): void;
29
25
  onAgentCompacted(record: Agent, info: CompactionInfo): void;
30
- /** Fires synchronously after a background agent record is created (before startAgent). */
26
+ /** Fires synchronously after a background agent record is created (before run). */
31
27
  onAgentCreated(record: Agent): void;
32
28
  }
33
29
 
@@ -43,22 +39,6 @@ export interface AgentManagerOptions {
43
39
  observer?: AgentManagerObserver;
44
40
  }
45
41
 
46
- interface SpawnArgs {
47
- snapshot: ParentSnapshot;
48
- type: SubagentType;
49
- prompt: string;
50
- options: AgentSpawnConfig;
51
- }
52
-
53
- export interface ParentSessionInfo {
54
- /** Path to the parent session's JSONL file (for deriving the subagent session directory). */
55
- parentSessionFile?: string;
56
- /** Session ID of the parent agent (stored in the child session's parentSession header). */
57
- parentSessionId?: string;
58
- /** Tool call ID for background notification wiring. When set, spawn attaches NotificationState. */
59
- toolCallId?: string;
60
- }
61
-
62
42
  export interface AgentSpawnConfig {
63
43
  description: string;
64
44
  model?: Model<any>;
@@ -79,8 +59,8 @@ export interface AgentSpawnConfig {
79
59
  invocation?: AgentInvocation;
80
60
  /** Parent abort signal - when aborted, the subagent is also stopped. */
81
61
  signal?: AbortSignal;
82
- /** Called when the agent session is created - receives the session and the agent's record. */
83
- onSessionCreated?: (session: AgentSession, record: Agent) => void;
62
+ /** Per-agent lifecycle observer replaces onSessionCreated callback. */
63
+ observer?: AgentLifecycleObserver;
84
64
  /** Parent session identity - grouped fields that travel together from the tool boundary. */
85
65
  parentSession?: ParentSessionInfo;
86
66
  }
@@ -94,8 +74,8 @@ export class AgentManager {
94
74
  private readonly _getMaxConcurrent: () => number;
95
75
  private getRunConfig?: () => RunConfig;
96
76
 
97
- /** Queue of background agents waiting to start. */
98
- private queue: { id: string; args: SpawnArgs }[] = [];
77
+ /** Queue of background agent IDs waiting to start. */
78
+ private queue: string[] = [];
99
79
  /** Number of currently running background agents. */
100
80
  private runningBackground = 0;
101
81
  constructor(options: AgentManagerOptions) {
@@ -117,6 +97,25 @@ export class AgentManager {
117
97
  this.drainQueue();
118
98
  }
119
99
 
100
+ /** Compose a per-agent lifecycle observer from manager and spawn-config concerns. */
101
+ private buildObserver(options: AgentSpawnConfig): AgentLifecycleObserver {
102
+ return {
103
+ onStarted: (agent) => {
104
+ if (options.isBackground) this.runningBackground++;
105
+ this.observer?.onAgentStarted(agent);
106
+ },
107
+ onSessionCreated: options.observer?.onSessionCreated
108
+ ? (agent, session) => options.observer!.onSessionCreated!(agent, session)
109
+ : undefined,
110
+ onRunFinished: (agent) => {
111
+ if (options.isBackground) this.finalizeBackgroundRun(agent);
112
+ },
113
+ onCompacted: (agent, info) => {
114
+ this.observer?.onAgentCompacted(agent, info);
115
+ },
116
+ };
117
+ }
118
+
120
119
  /**
121
120
  * Spawn an agent and return its ID immediately (for background use).
122
121
  * If the concurrency limit is reached, the agent is queued.
@@ -128,90 +127,45 @@ export class AgentManager {
128
127
  options: AgentSpawnConfig,
129
128
  ): string {
130
129
  const id = randomUUID().slice(0, 17);
131
- const abortController = new AbortController();
132
130
  const record = new Agent({
133
131
  id,
134
132
  type,
135
133
  description: options.description,
136
134
  status: options.isBackground ? "queued" : "running",
137
135
  startedAt: Date.now(),
138
- abortController,
139
136
  invocation: options.invocation,
137
+ // Run config
138
+ snapshot,
139
+ prompt,
140
+ model: options.model,
141
+ maxTurns: options.maxTurns,
142
+ isolated: options.isolated,
143
+ thinkingLevel: options.thinkingLevel,
144
+ isolation: options.isolation,
145
+ parentSession: options.parentSession,
146
+ signal: options.signal,
147
+ // Shared deps
148
+ runner: this.runner,
149
+ worktrees: this.worktrees,
150
+ observer: this.buildObserver(options),
151
+ getRunConfig: this.getRunConfig,
140
152
  });
141
153
  this.agents.set(id, record);
142
154
 
143
- if (options.parentSession?.toolCallId) {
144
- record.notification = new NotificationState(options.parentSession.toolCallId);
145
- }
146
-
147
155
  if (options.isBackground) {
148
156
  this.observer?.onAgentCreated(record);
149
157
  }
150
158
 
151
- const args: SpawnArgs = { snapshot, type, prompt, options };
152
-
153
159
  if (options.isBackground && !options.bypassQueue && this.runningBackground >= this._getMaxConcurrent()) {
154
160
  // Queue it - will be started when a running agent completes
155
- this.queue.push({ id, args });
161
+ this.queue.push(id);
156
162
  return id;
157
163
  }
158
164
 
159
- // setupWorktree can throw (e.g. strict worktree-isolation failure) - clean
160
- // up the record so callers don't see an orphan in `listAgents()`.
161
- try {
162
- record.setupWorktree(this.worktrees, options.isolation);
163
- record.promise = this.startAgent(id, record, args);
164
- } catch (err) {
165
- this.agents.delete(id);
166
- throw err;
167
- }
165
+ record.promise = record.run();
168
166
  return id;
169
167
  }
170
168
 
171
- /** Actually start an agent (called immediately or from queue drain). */
172
- private async startAgent(id: string, record: Agent, { snapshot, type, prompt, options }: SpawnArgs): Promise<void> {
173
- record.markRunning(Date.now());
174
- if (options.isBackground) this.runningBackground++;
175
- this.observer?.onAgentStarted(record);
176
-
177
- record.setOnRunFinished(
178
- options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
179
- );
180
- record.wireSignal(options.signal, () => this.abort(id));
181
-
182
- const runConfig = this.getRunConfig?.();
183
- try {
184
- const result = await this.runner.run(snapshot, type, prompt, {
185
- context: {
186
- cwd: record.worktreeState?.path,
187
- parentSession: options.parentSession,
188
- },
189
- model: options.model,
190
- maxTurns: options.maxTurns,
191
- defaultMaxTurns: runConfig?.defaultMaxTurns,
192
- graceTurns: runConfig?.graceTurns,
193
- isolated: options.isolated,
194
- thinkingLevel: options.thinkingLevel,
195
- signal: record.abortController!.signal,
196
- onSessionCreated: (session) => {
197
- // Capture the session file path early so it's available for display
198
- // before the run completes (e.g. in background agent status messages).
199
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
200
- const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
201
- record.execution = { session, outputFile };
202
- record.flushPendingSteers(session);
203
- record.attachObserver(subscribeAgentObserver(session, record, {
204
- onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
205
- }));
206
- options.onSessionCreated?.(session, record);
207
- },
208
- });
209
- record.completeRun(result, this.worktrees);
210
- } catch (err) {
211
- record.failRun(err, this.worktrees);
212
- }
213
- }
214
-
215
169
  /** Decrement background counter, notify observer (crash-safe), and drain the queue. */
216
170
  private finalizeBackgroundRun(record: Agent): void {
217
171
  this.runningBackground--;
@@ -222,18 +176,10 @@ export class AgentManager {
222
176
  /** Start queued agents up to the concurrency limit. */
223
177
  private drainQueue() {
224
178
  while (this.queue.length > 0 && this.runningBackground < this._getMaxConcurrent()) {
225
- const next = this.queue.shift()!;
226
- const record = this.agents.get(next.id);
179
+ const id = this.queue.shift()!;
180
+ const record = this.agents.get(id);
227
181
  if (record?.status !== "queued") continue;
228
- try {
229
- record.setupWorktree(this.worktrees, next.args.options.isolation);
230
- record.promise = this.startAgent(next.id, record, next.args);
231
- } catch (err) {
232
- // Late failure (e.g. strict worktree-isolation) - surface on the record
233
- // so the user/agent can see it via /agents, then keep draining.
234
- record.markError(err);
235
- this.observer?.onAgentCompleted(record);
236
- }
182
+ record.promise = record.run();
237
183
  }
238
184
  }
239
185
 
@@ -301,7 +247,7 @@ export class AgentManager {
301
247
 
302
248
  // Remove from queue if queued
303
249
  if (record.status === "queued") {
304
- this.queue = this.queue.filter(q => q.id !== id);
250
+ this.queue = this.queue.filter(qid => qid !== id);
305
251
  record.markStopped();
306
252
  return true;
307
253
  }
@@ -349,8 +295,8 @@ export class AgentManager {
349
295
  abortAll(): number {
350
296
  let count = 0;
351
297
  // Clear queued agents first
352
- for (const queued of this.queue) {
353
- const record = this.agents.get(queued.id);
298
+ for (const id of this.queue) {
299
+ const record = this.agents.get(id);
354
300
  if (record) {
355
301
  record.markStopped();
356
302
  count++;
@@ -9,14 +9,13 @@ import {
9
9
  type SettingsManager,
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import type { AgentConfigLookup } from "#src/config/agent-types";
12
- import type { ParentSessionInfo } from "#src/lifecycle/agent-manager";
13
12
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
14
13
  import { registerChildSession, unregisterChildSession } from "#src/lifecycle/permission-bridge";
15
14
  import { extractAssistantContent } from "#src/session/content-items";
16
15
  import { extractText } from "#src/session/context";
17
16
  import type { EnvInfo } from "#src/session/env";
18
17
  import { type AssemblerIO, assembleSessionConfig } from "#src/session/session-config";
19
- import type { ShellExec, SubagentType, ThinkingLevel } from "#src/types";
18
+ import type { ParentSessionInfo, ShellExec, SubagentType, ThinkingLevel } from "#src/types";
20
19
 
21
20
  /** Names of tools registered by this extension that subagents must NOT inherit. */
22
21
  const EXCLUDED_TOOL_NAMES = ["subagent", "get_subagent_result", "steer_subagent"];