@gotgenes/pi-subagents 10.0.0 → 10.1.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.
@@ -35,3 +35,48 @@ Pre-completion reviewer returned **PASS**.
35
35
  - The general-purpose agent type's `displayName: "Agent"` in `default-agents.ts` and `agent-types.ts` fallback was correctly left unchanged; `display.test.ts` still passes with `"Agent"`.
36
36
  - The description body inside the `agent-tool.ts` template literal needed separate edits because the guideline lines are not tab-indented (inside a backtick template literal, tab indentation does not apply).
37
37
  - Pre-completion reviewer: PASS — all deterministic checks, conventional commits, documentation, code design, tests, Mermaid diagrams, and dead-code gate all passed.
38
+
39
+ ## Stage: Final Retrospective (2026-05-27T14:07:32Z)
40
+
41
+ ### Session summary
42
+
43
+ Completed the full plan→TDD→ship→retro lifecycle for #242 in a single session.
44
+ Released as `pi-subagents-v10.0.0` (major bump from `feat!:` breaking change).
45
+ Found and fixed one stale `Agent` tool reference in `.pi/skills/pre-completion/SKILL.md`.
46
+
47
+ ### Observations
48
+
49
+ #### What went well
50
+
51
+ - Three-model pipeline (opus for planning, sonnet for TDD, deepseek-flash for shipping) matched task complexity to model capability with no quality issues.
52
+ - The plan's distinction between tool name (`"Agent"`) and agent-type `displayName` (`"Agent"`) prevented false-positive test updates — 8 test files reference `"Agent"` but only 4 needed changes.
53
+ - Pre-completion reviewer caught no issues (PASS), confirming thorough planning.
54
+
55
+ #### What caused friction (agent side)
56
+
57
+ 1. `missing-context` — Two failed `Edit` calls on `agent-tool.ts` line 175: the template literal's guideline lines have no tab indentation, but the agent initially assumed tab depth from the surrounding function.
58
+ Impact: 3 extra tool calls (grep to inspect actual indentation, then successful edit); no rework.
59
+ Self-identified.
60
+ 2. `wrong-abstraction` — Retro file edit duplicated Planning observations into the TDD stage because the `Edit` `oldText` matched from the Observations heading and the replacement included both old and new content.
61
+ Impact: 2 extra tool calls (read file, full `write` to fix); no rework.
62
+ Self-identified.
63
+ 3. `missing-context` — `.pi/skills/pre-completion/SKILL.md` line 32 references the `Agent` tool by name but was not in the plan's scope.
64
+ The plan checked pi-permission-system docs, `README.md`, and architecture docs but did not grep skill files for the old tool name.
65
+ Impact: discovered during retro; fixed as a retro change.
66
+
67
+ #### What caused friction (user side)
68
+
69
+ - None — the full pipeline ran with zero user corrections.
70
+
71
+ ### Diagnostic details
72
+
73
+ - **Model-performance correlation** — Pre-completion reviewer ran as a default-model subagent (292.7s, 36 tool uses, 63.9k tokens).
74
+ Appropriate for the judgment-heavy review task.
75
+ Ship stage on `deepseek-v4-flash` was notably efficient for purely mechanical work.
76
+ - **Feedback-loop gap analysis** — Verification was incremental: baseline check before TDD, per-file tests after Red and Green phases, full suite after implementation, then check + lint + fallow.
77
+ No gaps.
78
+
79
+ ### Changes made
80
+
81
+ 1. `.pi/skills/pre-completion/SKILL.md` — updated stale `Agent` tool reference to `subagent` on line 32.
82
+ 2. `.pi/agents/pre-completion-reviewer.md` — added rename-grep heuristic to the Skills bullet under Forward documentation checks: "When the change renames a symbol, grep `.pi/skills/` and `.pi/prompts/` for the old name."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "10.0.0",
3
+ "version": "10.1.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,23 +11,23 @@ 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";
14
+ import { Agent } from "#src/lifecycle/agent";
15
15
  import type { AgentRunner, RunResult } from "#src/lifecycle/agent-runner";
16
16
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
17
17
  import type { WorktreeManager } from "#src/lifecycle/worktree";
18
- 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
24
  /**
25
- * RunHandle per-run lifecycle object that owns cleanup state.
25
+ * RunHandle - per-run lifecycle object that owns cleanup state.
26
26
  *
27
27
  * Owns the observer unsubscribe and parent-signal detach handles acquired during
28
28
  * a run. Exposes `complete()` and `fail()` as the only way to finish a run,
29
29
  * eliminating mutable closure variables from `startAgent`.
30
- * `fireOnFinished` is idempotent safe to call from both success and error paths.
30
+ * `fireOnFinished` is idempotent - safe to call from both success and error paths.
31
31
  */
32
32
  class RunHandle {
33
33
  private unsub?: () => void;
@@ -35,7 +35,7 @@ class RunHandle {
35
35
  private onFinished?: () => void;
36
36
 
37
37
  constructor(
38
- private readonly record: AgentRecord,
38
+ private readonly record: Agent,
39
39
  private readonly worktrees: WorktreeManager,
40
40
  onFinished?: () => void,
41
41
  ) {
@@ -55,7 +55,7 @@ class RunHandle {
55
55
  this.unsub = unsub;
56
56
  }
57
57
 
58
- /** Complete a run successfully clean up, transition record, fire onFinished. */
58
+ /** Complete a run successfully - clean up, transition record, fire onFinished. */
59
59
  complete(result: RunResult): string {
60
60
  this.releaseListeners();
61
61
 
@@ -81,7 +81,7 @@ class RunHandle {
81
81
  return result.responseText;
82
82
  }
83
83
 
84
- /** Fail a run mark error, best-effort worktree cleanup, fire onFinished. */
84
+ /** Fail a run - mark error, best-effort worktree cleanup, fire onFinished. */
85
85
  fail(err: unknown): void {
86
86
  this.record.markError(err);
87
87
  this.releaseListeners();
@@ -114,11 +114,11 @@ export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; toke
114
114
 
115
115
  /** Observer interface for agent lifecycle notifications. */
116
116
  export interface AgentManagerObserver {
117
- onAgentStarted(record: AgentRecord): void;
118
- onAgentCompleted(record: AgentRecord): void;
119
- onAgentCompacted(record: AgentRecord, info: CompactionInfo): void;
117
+ onAgentStarted(record: Agent): void;
118
+ onAgentCompleted(record: Agent): void;
119
+ onAgentCompacted(record: Agent, info: CompactionInfo): void;
120
120
  /** Fires synchronously after a background agent record is created (before startAgent). */
121
- onAgentCreated(record: AgentRecord): void;
121
+ onAgentCreated(record: Agent): void;
122
122
  }
123
123
 
124
124
  /** Default max concurrent background agents. */
@@ -129,7 +129,7 @@ export interface AgentManagerOptions {
129
129
  worktrees: WorktreeManager;
130
130
  exec: ShellExec;
131
131
  registry: AgentTypeRegistry;
132
- /** Injected getter for the concurrency limit owned by SettingsManager. */
132
+ /** Injected getter for the concurrency limit - owned by SettingsManager. */
133
133
  getMaxConcurrent?: () => number;
134
134
  getRunConfig?: () => RunConfig;
135
135
  observer?: AgentManagerObserver;
@@ -160,25 +160,25 @@ export interface AgentSpawnConfig {
160
160
  thinkingLevel?: ThinkingLevel;
161
161
  isBackground?: boolean;
162
162
  /**
163
- * Skip the maxConcurrent queue check for this spawn start immediately even
163
+ * Skip the maxConcurrent queue check for this spawn - start immediately even
164
164
  * if the configured concurrency limit would otherwise queue it. Useful for
165
165
  * callers (e.g. cross-extension RPC) that must not be deferred by the queue.
166
166
  */
167
167
  bypassQueue?: boolean;
168
- /** Isolation mode "worktree" creates a temp git worktree for the agent. */
168
+ /** Isolation mode - "worktree" creates a temp git worktree for the agent. */
169
169
  isolation?: IsolationMode;
170
170
  /** Resolved invocation snapshot captured for UI display. */
171
171
  invocation?: AgentInvocation;
172
- /** Parent abort signal when aborted, the subagent is also stopped. */
172
+ /** Parent abort signal - when aborted, the subagent is also stopped. */
173
173
  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. */
174
+ /** Called when the agent session is created - receives the session and the agent's record. */
175
+ onSessionCreated?: (session: AgentSession, record: Agent) => void;
176
+ /** Parent session identity - grouped fields that travel together from the tool boundary. */
177
177
  parentSession?: ParentSessionInfo;
178
178
  }
179
179
 
180
180
  export class AgentManager {
181
- private agents = new Map<string, AgentRecord>();
181
+ private agents = new Map<string, Agent>();
182
182
  private cleanupInterval: ReturnType<typeof setInterval>;
183
183
  private readonly observer?: AgentManagerObserver;
184
184
  private readonly runner: AgentRunner;
@@ -192,9 +192,6 @@ export class AgentManager {
192
192
  private queue: { id: string; args: SpawnArgs }[] = [];
193
193
  /** Number of currently running background agents. */
194
194
  private runningBackground = 0;
195
- /** Steers buffered for agents whose session hasn’t been created yet. */
196
- private pendingSteers = new Map<string, string[]>();
197
-
198
195
  constructor(options: AgentManagerOptions) {
199
196
  this.runner = options.runner;
200
197
  this.worktrees = options.worktrees;
@@ -216,19 +213,6 @@ export class AgentManager {
216
213
  this.drainQueue();
217
214
  }
218
215
 
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
216
  /**
233
217
  * Spawn an agent and return its ID immediately (for background use).
234
218
  * If the concurrency limit is reached, the agent is queued.
@@ -241,7 +225,7 @@ export class AgentManager {
241
225
  ): string {
242
226
  const id = randomUUID().slice(0, 17);
243
227
  const abortController = new AbortController();
244
- const record = new AgentRecord({
228
+ const record = new Agent({
245
229
  id,
246
230
  type,
247
231
  description: options.description,
@@ -263,12 +247,12 @@ export class AgentManager {
263
247
  const args: SpawnArgs = { snapshot, type, prompt, options };
264
248
 
265
249
  if (options.isBackground && !options.bypassQueue && this.runningBackground >= this._getMaxConcurrent()) {
266
- // Queue it will be started when a running agent completes
250
+ // Queue it - will be started when a running agent completes
267
251
  this.queue.push({ id, args });
268
252
  return id;
269
253
  }
270
254
 
271
- // startAgent can throw (e.g. strict worktree-isolation failure) clean
255
+ // startAgent can throw (e.g. strict worktree-isolation failure) - clean
272
256
  // up the record so callers don't see an orphan in `listAgents()`.
273
257
  try {
274
258
  this.startAgent(id, record, args);
@@ -280,8 +264,8 @@ export class AgentManager {
280
264
  }
281
265
 
282
266
  /** 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);
267
+ private startAgent(id: string, record: Agent, { snapshot, type, prompt, options }: SpawnArgs) {
268
+ const worktreeCwd = record.setupWorktree(this.worktrees, options.isolation);
285
269
 
286
270
  record.markRunning(Date.now());
287
271
  if (options.isBackground) this.runningBackground++;
@@ -314,8 +298,8 @@ export class AgentManager {
314
298
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
315
299
  const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
316
300
  record.execution = { session, outputFile };
317
- this.flushPendingSteers(id, session);
318
- handle.attachObserver(subscribeRecordObserver(session, record, {
301
+ record.flushPendingSteers(session);
302
+ handle.attachObserver(subscribeAgentObserver(session, record, {
319
303
  onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
320
304
  }));
321
305
  options.onSessionCreated?.(session, record);
@@ -325,34 +309,8 @@ export class AgentManager {
325
309
  .catch((err: unknown) => { handle.fail(err); return ""; });
326
310
  }
327
311
 
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(() => {});
350
- }
351
- this.pendingSteers.delete(id);
352
- }
353
-
354
312
  /** Decrement background counter, notify observer (crash-safe), and drain the queue. */
355
- private finalizeBackgroundRun(record: AgentRecord): void {
313
+ private finalizeBackgroundRun(record: Agent): void {
356
314
  this.runningBackground--;
357
315
  try { this.observer?.onAgentCompleted(record); } catch (err) { debugLog("onAgentCompleted observer", err); }
358
316
  this.drainQueue();
@@ -367,7 +325,7 @@ export class AgentManager {
367
325
  try {
368
326
  this.startAgent(next.id, record, next.args);
369
327
  } catch (err) {
370
- // Late failure (e.g. strict worktree-isolation) surface on the record
328
+ // Late failure (e.g. strict worktree-isolation) - surface on the record
371
329
  // so the user/agent can see it via /agents, then keep draining.
372
330
  record.markError(err);
373
331
  this.observer?.onAgentCompleted(record);
@@ -384,7 +342,7 @@ export class AgentManager {
384
342
  type: SubagentType,
385
343
  prompt: string,
386
344
  options: Omit<AgentSpawnConfig, "isBackground">,
387
- ): Promise<AgentRecord> {
345
+ ): Promise<Agent> {
388
346
  const id = this.spawn(snapshot, type, prompt, { ...options, isBackground: false });
389
347
  const record = this.agents.get(id)!;
390
348
  await record.promise;
@@ -398,14 +356,14 @@ export class AgentManager {
398
356
  id: string,
399
357
  prompt: string,
400
358
  signal?: AbortSignal,
401
- ): Promise<AgentRecord | undefined> {
359
+ ): Promise<Agent | undefined> {
402
360
  const record = this.agents.get(id);
403
361
  const session = record?.session;
404
362
  if (!session) return undefined;
405
363
 
406
364
  record.resetForResume(Date.now());
407
365
 
408
- const unsubResume = subscribeRecordObserver(session, record, {
366
+ const unsubResume = subscribeAgentObserver(session, record, {
409
367
  onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
410
368
  });
411
369
 
@@ -423,11 +381,11 @@ export class AgentManager {
423
381
  return record;
424
382
  }
425
383
 
426
- getRecord(id: string): AgentRecord | undefined {
384
+ getRecord(id: string): Agent | undefined {
427
385
  return this.agents.get(id);
428
386
  }
429
387
 
430
- listAgents(): AgentRecord[] {
388
+ listAgents(): Agent[] {
431
389
  return [...this.agents.values()].sort(
432
390
  (a, b) => b.startedAt - a.startedAt,
433
391
  );
@@ -444,18 +402,14 @@ export class AgentManager {
444
402
  return true;
445
403
  }
446
404
 
447
- if (record.status !== "running") return false;
448
- record.abortController?.abort();
449
- record.markStopped();
450
- return true;
405
+ return record.abort();
451
406
  }
452
407
 
453
408
  /** Dispose a record's session and remove it from the map. */
454
- private removeRecord(id: string, record: AgentRecord): void {
409
+ private removeRecord(id: string, record: Agent): void {
455
410
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
456
411
  record.session?.dispose?.();
457
412
  this.agents.delete(id);
458
- this.pendingSteers.delete(id);
459
413
  }
460
414
 
461
415
  private cleanup() {
@@ -501,11 +455,7 @@ export class AgentManager {
501
455
  this.queue = [];
502
456
  // Abort running agents
503
457
  for (const record of this.agents.values()) {
504
- if (record.status === "running") {
505
- record.abortController?.abort();
506
- record.markStopped();
507
- count++;
508
- }
458
+ if (record.abort()) count++;
509
459
  }
510
460
  return count;
511
461
  }
@@ -513,7 +463,7 @@ export class AgentManager {
513
463
  /** Wait for all running and queued agents to complete (including queued ones). */
514
464
  // fallow-ignore-next-line unused-class-member
515
465
  async waitForAll(): Promise<void> {
516
- // Loop because drainQueue respects the concurrency limit as running
466
+ // Loop because drainQueue respects the concurrency limit - as running
517
467
  // agents finish they start queued ones, which need awaiting too.
518
468
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop with explicit break
519
469
  while (true) {
@@ -15,37 +15,22 @@ import { registerChildSession, unregisterChildSession } from "#src/lifecycle/per
15
15
  import { extractAssistantContent } from "#src/session/content-items";
16
16
  import { extractText } from "#src/session/context";
17
17
  import type { EnvInfo } from "#src/session/env";
18
- import { type AssemblerIO, assembleSessionConfig, type ToolFilterConfig } from "#src/session/session-config";
18
+ import { type AssemblerIO, assembleSessionConfig } from "#src/session/session-config";
19
19
  import type { ShellExec, SubagentType, ThinkingLevel } from "#src/types";
20
20
 
21
21
  /** Names of tools registered by this extension that subagents must NOT inherit. */
22
22
  const EXCLUDED_TOOL_NAMES = ["subagent", "get_subagent_result", "steer_subagent"];
23
23
 
24
24
  /**
25
- * Filter the session's active tool names according to extension rules.
25
+ * Filter the session's active tool names: remove recursion-guard tools.
26
26
  *
27
- * Run twice - once before `bindExtensions` (filters built-in tools) and once after
28
- * (filters extension-registered tools, which only join the active set during
29
- * `bindExtensions`). Extracting this keeps the two callsites consistent and makes
30
- * the post-bind re-filter trivial.
27
+ * Run once after `bindExtensions` so extension-registered tools (added during
28
+ * `bindExtensions`) are also covered by the guard.
31
29
  *
32
30
  * @param activeTools Names currently active on the session.
33
- * @param config Tool filtering configuration from the assembled session config.
34
31
  */
35
- function filterActiveTools(
36
- activeTools: string[],
37
- config: ToolFilterConfig,
38
- ): string[] {
39
- const { toolNames, extensions } = config;
40
- if (!extensions) {
41
- return activeTools;
42
- }
43
- const builtinToolNameSet = new Set(toolNames);
44
- return activeTools.filter((t) => {
45
- if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
46
- if (builtinToolNameSet.has(t)) return true;
47
- return true;
48
- });
32
+ function filterActiveTools(activeTools: string[]): string[] {
33
+ return activeTools.filter((t) => !EXCLUDED_TOOL_NAMES.includes(t));
49
34
  }
50
35
 
51
36
  /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
@@ -305,7 +290,7 @@ export async function runAgent(
305
290
  const loader = io.createResourceLoader({
306
291
  cwd: cfg.effectiveCwd,
307
292
  agentDir,
308
- noExtensions: !cfg.toolFilter.extensions,
293
+ noExtensions: !cfg.extensions,
309
294
  noSkills: cfg.noSkills,
310
295
  noPromptTemplates: true,
311
296
  noThemes: true,
@@ -329,18 +314,11 @@ export async function runAgent(
329
314
  settingsManager: io.createSettingsManager(cfg.effectiveCwd, agentDir),
330
315
  modelRegistry: snapshot.modelRegistry,
331
316
  model: cfg.model,
332
- tools: cfg.toolFilter.toolNames,
317
+ tools: cfg.toolNames,
333
318
  resourceLoader: loader,
334
319
  thinkingLevel: cfg.thinkingLevel,
335
320
  });
336
321
 
337
- // Filter active tools: remove our own tools to prevent nesting.
338
- // First pass - over built-in tools, before bindExtensions registers extension tools.
339
- if (cfg.toolFilter.extensions) {
340
- const filtered = filterActiveTools(session.getActiveToolNames(), cfg.toolFilter);
341
- session.setActiveToolsByName(filtered);
342
- }
343
-
344
322
  // Register with pi-permission-system's SubagentSessionRegistry before
345
323
  // bindExtensions() so isSubagentExecutionContext() hits the registry on the
346
324
  // first check during child extension initialization. Unregistered in the
@@ -356,14 +334,13 @@ export async function runAgent(
356
334
  // respect the active tool set. All ExtensionBindings fields are optional.
357
335
  await session.bindExtensions({});
358
336
 
359
- // Patch 2 (RepOne #443): re-filter active tools after bindExtensions.
360
- // Extension-registered tools (added during bindExtensions) are not in the
361
- // session's active set when the first filter pass runs above. Without this
362
- // re-filter, EXCLUDED_TOOL_NAMES would not be applied to extension-registered
363
- // tools. Run the same filter against the post-bind active set.
364
- if (cfg.toolFilter.extensions) {
365
- const refiltered = filterActiveTools(session.getActiveToolNames(), cfg.toolFilter);
366
- session.setActiveToolsByName(refiltered);
337
+ // Apply recursion guard: remove our own tools from the child's active set.
338
+ // Runs after bindExtensions so extension-registered tools are included in the
339
+ // post-bind active set. Only needed when extensions are loaded (extensions: false
340
+ // means no extension tools were registered, so the guard is a no-op).
341
+ if (cfg.extensions) {
342
+ const filtered = filterActiveTools(session.getActiveToolNames());
343
+ session.setActiveToolsByName(filtered);
367
344
  }
368
345
 
369
346
  options.onSessionCreated?.(session);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * agent-record.ts — AgentRecord class with encapsulated status-transition logic.
2
+ * agent.ts — Agent class with encapsulated status-transition logic and per-agent behavior.
3
3
  *
4
4
  * Status transitions (status, result, error, startedAt, completedAt) are owned
5
5
  * by the class and exposed via transition methods. External code reads these
@@ -8,6 +8,9 @@
8
8
  * Stats (toolUses, lifetimeUsage, compactionCount) are owned by the class and
9
9
  * accumulated via mutation methods (incrementToolUses, addUsage, incrementCompactions).
10
10
  *
11
+ * Behavior (abort, steer buffering, worktree setup) lives on the agent
12
+ * rather than on AgentManager — each agent manages its own lifecycle concerns.
13
+ *
11
14
  * Phase-specific collaborators (execution, worktreeState, notification) are attached
12
15
  * after construction as lifecycle information becomes available.
13
16
  */
@@ -16,11 +19,12 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent";
16
19
  import type { ExecutionState } from "#src/lifecycle/execution-state";
17
20
  import type { LifetimeUsage } from "#src/lifecycle/usage";
18
21
  import { addUsage } from "#src/lifecycle/usage";
19
- import type { WorktreeState } from "#src/lifecycle/worktree-state";
22
+ import type { WorktreeManager } from "#src/lifecycle/worktree";
23
+ import { WorktreeState } from "#src/lifecycle/worktree-state";
20
24
  import type { NotificationState } from "#src/observation/notification-state";
21
- import type { AgentInvocation, SubagentType } from "#src/types";
25
+ import type { AgentInvocation, IsolationMode, SubagentType } from "#src/types";
22
26
 
23
- export type AgentRecordStatus =
27
+ export type AgentStatus =
24
28
  | "queued"
25
29
  | "running"
26
30
  | "completed"
@@ -29,11 +33,11 @@ export type AgentRecordStatus =
29
33
  | "stopped"
30
34
  | "error";
31
35
 
32
- export interface AgentRecordInit {
36
+ export interface AgentInit {
33
37
  id: string;
34
38
  type: SubagentType;
35
39
  description: string;
36
- status?: AgentRecordStatus;
40
+ status?: AgentStatus;
37
41
  startedAt?: number;
38
42
  completedAt?: number;
39
43
  result?: string;
@@ -43,7 +47,7 @@ export interface AgentRecordInit {
43
47
  promise?: Promise<string>;
44
48
  }
45
49
 
46
- export class AgentRecord {
50
+ export class Agent {
47
51
  // Identity — set once at construction
48
52
  readonly id: string;
49
53
  readonly type: SubagentType;
@@ -51,8 +55,8 @@ export class AgentRecord {
51
55
  readonly invocation?: AgentInvocation;
52
56
 
53
57
  // Transition state — encapsulated behind getters, mutated only via transition methods
54
- private _status: AgentRecordStatus;
55
- get status(): AgentRecordStatus { return this._status; }
58
+ private _status: AgentStatus;
59
+ get status(): AgentStatus { return this._status; }
56
60
 
57
61
  private _result?: string;
58
62
  get result(): string | undefined { return this._result; }
@@ -86,6 +90,29 @@ export class AgentRecord {
86
90
  worktreeState?: WorktreeState;
87
91
  notification?: NotificationState;
88
92
 
93
+ /**
94
+ * Create a git worktree for isolated execution, set worktreeState, and return the worktree path.
95
+ * Returns undefined if isolation is not "worktree".
96
+ * Throws if worktree creation fails (strict isolation).
97
+ */
98
+ setupWorktree(worktrees: WorktreeManager, isolation: IsolationMode | undefined): string | undefined {
99
+ if (isolation !== "worktree") return undefined;
100
+ const wt = worktrees.create(this.id);
101
+ if (!wt) {
102
+ throw new Error(
103
+ 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
104
+ 'Initialize git and commit at least once, or omit `isolation`.',
105
+ );
106
+ }
107
+ this.worktreeState = new WorktreeState(wt);
108
+ return wt.path;
109
+ }
110
+
111
+ // Steer buffer — messages queued before the session is ready
112
+ private _pendingSteers: string[] = [];
113
+ /** Number of steer messages waiting to be delivered. */
114
+ get pendingSteerCount(): number { return this._pendingSteers.length; }
115
+
89
116
  /** The active agent session, or undefined before the session is created. */
90
117
  get session(): AgentSession | undefined {
91
118
  return this.execution?.session;
@@ -96,7 +123,7 @@ export class AgentRecord {
96
123
  return this.execution?.outputFile;
97
124
  }
98
125
 
99
- constructor(init: AgentRecordInit) {
126
+ constructor(init: AgentInit) {
100
127
  this.id = init.id;
101
128
  this.type = init.type;
102
129
  this.description = init.description;
@@ -190,6 +217,37 @@ export class AgentRecord {
190
217
  this._completedAt = completedAt ?? Date.now();
191
218
  }
192
219
 
220
+ /**
221
+ * Abort a running agent: fire AbortController and transition to stopped.
222
+ * Returns false if the agent is not running.
223
+ * Queue removal stays on AgentManager until #230 extracts ConcurrencyQueue.
224
+ */
225
+ abort(): boolean {
226
+ if (this._status !== "running") return false;
227
+ this.abortController?.abort();
228
+ this.markStopped();
229
+ return true;
230
+ }
231
+
232
+ /**
233
+ * Buffer a steer message for delivery once the session is ready.
234
+ * Called when steer is requested before onSessionCreated fires.
235
+ */
236
+ queueSteer(message: string): void {
237
+ this._pendingSteers.push(message);
238
+ }
239
+
240
+ /**
241
+ * Flush all buffered steer messages to the session and clear the buffer.
242
+ * Called from onSessionCreated once the session is available.
243
+ */
244
+ flushPendingSteers(session: AgentSession): void {
245
+ for (const msg of this._pendingSteers) {
246
+ session.steer(msg).catch(() => {});
247
+ }
248
+ this._pendingSteers = [];
249
+ }
250
+
193
251
  /** Reset for resume: running status, new startedAt, clear completedAt/result/error. */
194
252
  resetForResume(startedAt: number): void {
195
253
  this._status = "running";
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * execution-state.ts — ExecutionState: execution-phase state for a running agent.
3
3
  *
4
- * Constructed and attached to AgentRecord when onSessionCreated fires inside startAgent().
4
+ * Constructed and attached to Agent when onSessionCreated fires inside startAgent().
5
5
  * Contains the session and output file — the two fields that become known once the
6
- * runner creates the session. promise stays as a separate AgentRecord field because
6
+ * runner creates the session. promise stays as a separate Agent field because
7
7
  * it is set at a different moment (after runner.run() returns).
8
8
  */
9
9