@gotgenes/pi-subagents 10.1.0 → 10.2.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.
@@ -9,106 +9,16 @@
9
9
  import { randomUUID } from "node:crypto";
10
10
  import type { Model } from "@earendil-works/pi-ai";
11
11
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
12
- import { AgentTypeRegistry } from "#src/config/agent-types";
13
12
  import { debugLog } from "#src/debug";
14
13
  import { Agent } from "#src/lifecycle/agent";
15
- import type { AgentRunner, RunResult } from "#src/lifecycle/agent-runner";
14
+ import type { AgentRunner } from "#src/lifecycle/agent-runner";
16
15
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
17
16
  import type { WorktreeManager } from "#src/lifecycle/worktree";
18
17
 
19
18
  import { NotificationState } from "#src/observation/notification-state";
20
19
  import { subscribeAgentObserver } from "#src/observation/record-observer";
21
20
  import type { RunConfig } from "#src/runtime";
22
- import type { AgentInvocation, IsolationMode, ShellExec, SubagentType, ThinkingLevel } from "#src/types";
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: Agent,
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
- }
21
+ import type { AgentInvocation, IsolationMode, SubagentType, ThinkingLevel } from "#src/types";
112
22
 
113
23
  export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
114
24
 
@@ -127,8 +37,6 @@ const DEFAULT_MAX_CONCURRENT = 4;
127
37
  export interface AgentManagerOptions {
128
38
  runner: AgentRunner;
129
39
  worktrees: WorktreeManager;
130
- exec: ShellExec;
131
- registry: AgentTypeRegistry;
132
40
  /** Injected getter for the concurrency limit - owned by SettingsManager. */
133
41
  getMaxConcurrent?: () => number;
134
42
  getRunConfig?: () => RunConfig;
@@ -183,8 +91,6 @@ export class AgentManager {
183
91
  private readonly observer?: AgentManagerObserver;
184
92
  private readonly runner: AgentRunner;
185
93
  private readonly worktrees: WorktreeManager;
186
- private readonly exec: ShellExec;
187
- private readonly registry: AgentTypeRegistry;
188
94
  private readonly _getMaxConcurrent: () => number;
189
95
  private getRunConfig?: () => RunConfig;
190
96
 
@@ -195,8 +101,6 @@ export class AgentManager {
195
101
  constructor(options: AgentManagerOptions) {
196
102
  this.runner = options.runner;
197
103
  this.worktrees = options.worktrees;
198
- this.exec = options.exec;
199
- this.registry = options.registry;
200
104
  this.observer = options.observer;
201
105
  this.getRunConfig = options.getRunConfig;
202
106
  this._getMaxConcurrent = options.getMaxConcurrent ?? (() => DEFAULT_MAX_CONCURRENT);
@@ -252,10 +156,11 @@ export class AgentManager {
252
156
  return id;
253
157
  }
254
158
 
255
- // startAgent can throw (e.g. strict worktree-isolation failure) - clean
159
+ // setupWorktree can throw (e.g. strict worktree-isolation failure) - clean
256
160
  // up the record so callers don't see an orphan in `listAgents()`.
257
161
  try {
258
- this.startAgent(id, record, args);
162
+ record.setupWorktree(this.worktrees, options.isolation);
163
+ record.promise = this.startAgent(id, record, args);
259
164
  } catch (err) {
260
165
  this.agents.delete(id);
261
166
  throw err;
@@ -264,49 +169,47 @@ export class AgentManager {
264
169
  }
265
170
 
266
171
  /** Actually start an agent (called immediately or from queue drain). */
267
- private startAgent(id: string, record: Agent, { snapshot, type, prompt, options }: SpawnArgs) {
268
- const worktreeCwd = record.setupWorktree(this.worktrees, options.isolation);
269
-
172
+ private async startAgent(id: string, record: Agent, { snapshot, type, prompt, options }: SpawnArgs): Promise<void> {
270
173
  record.markRunning(Date.now());
271
174
  if (options.isBackground) this.runningBackground++;
272
175
  this.observer?.onAgentStarted(record);
273
176
 
274
- const handle = new RunHandle(
275
- record, this.worktrees,
177
+ record.setOnRunFinished(
276
178
  options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
277
179
  );
278
- handle.wireSignal(options.signal, () => this.abort(id));
180
+ record.wireSignal(options.signal, () => this.abort(id));
279
181
 
280
182
  const runConfig = this.getRunConfig?.();
281
- record.promise = this.runner.run(snapshot, type, prompt, {
282
- context: {
283
- exec: this.exec,
284
- registry: this.registry,
285
- cwd: worktreeCwd,
286
- parentSession: options.parentSession,
287
- },
288
- model: options.model,
289
- maxTurns: options.maxTurns,
290
- defaultMaxTurns: runConfig?.defaultMaxTurns,
291
- graceTurns: runConfig?.graceTurns,
292
- isolated: options.isolated,
293
- thinkingLevel: options.thinkingLevel,
294
- signal: record.abortController!.signal,
295
- onSessionCreated: (session) => {
296
- // Capture the session file path early so it's available for display
297
- // before the run completes (e.g. in background agent status messages).
298
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
299
- const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
300
- record.execution = { session, outputFile };
301
- record.flushPendingSteers(session);
302
- handle.attachObserver(subscribeAgentObserver(session, record, {
303
- onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
304
- }));
305
- options.onSessionCreated?.(session, record);
306
- },
307
- })
308
- .then((result) => handle.complete(result))
309
- .catch((err: unknown) => { handle.fail(err); return ""; });
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
+ }
310
213
  }
311
214
 
312
215
  /** Decrement background counter, notify observer (crash-safe), and drain the queue. */
@@ -323,7 +226,8 @@ export class AgentManager {
323
226
  const record = this.agents.get(next.id);
324
227
  if (record?.status !== "queued") continue;
325
228
  try {
326
- this.startAgent(next.id, record, next.args);
229
+ record.setupWorktree(this.worktrees, next.args.options.isolation);
230
+ record.promise = this.startAgent(next.id, record, next.args);
327
231
  } catch (err) {
328
232
  // Late failure (e.g. strict worktree-isolation) - surface on the record
329
233
  // so the user/agent can see it via /agents, then keep draining.
@@ -471,7 +375,7 @@ export class AgentManager {
471
375
  const pending = [...this.agents.values()]
472
376
  .filter(r => r.status === "running" || r.status === "queued")
473
377
  .map(r => r.promise)
474
- .filter((p): p is Promise<string> => p != null);
378
+ .filter((p): p is Promise<void> => p != null);
475
379
  if (pending.length === 0) break;
476
380
  await Promise.allSettled(pending);
477
381
  }
@@ -114,19 +114,27 @@ export interface SessionFactoryIO {
114
114
  */
115
115
  export type RunnerIO = EnvironmentIO & SessionFactoryIO;
116
116
 
117
+ /**
118
+ * Dependencies owned by the runner — injected at construction time.
119
+ *
120
+ * Groups the IO boundary with the two static domain deps (exec, registry)
121
+ * that every run() call needs but that do not vary per call.
122
+ */
123
+ export interface RunnerDeps {
124
+ io: RunnerIO;
125
+ exec: ShellExec;
126
+ registry: AgentConfigLookup;
127
+ }
128
+
117
129
  // ── Public interfaces ─────────────────────────────────────────────────────────
118
130
 
119
131
  /**
120
- * Parent execution context - where/who is running.
132
+ * Per-call execution context fields that vary per spawn.
121
133
  *
122
- * Groups the four fields that describe the parent environment and identity,
123
- * separating them from the per-call execution parameters in RunOptions.
134
+ * Static dependencies (exec, registry) live on RunnerDeps; this interface
135
+ * carries only the two per-call fields that AgentManager supplies at spawn time.
124
136
  */
125
137
  export interface RunContext {
126
- /** Shell-exec callback for detectEnv - injected from pi.exec(). */
127
- exec: ShellExec;
128
- /** Agent config lookup - provides resolveAgentConfig and getToolNamesForType. */
129
- registry: AgentConfigLookup;
130
138
  /** Override working directory (e.g. for worktree isolation). */
131
139
  cwd?: string;
132
140
  /** Parent session identity (file path + session ID). */
@@ -182,15 +190,16 @@ export interface AgentRunner {
182
190
  }
183
191
 
184
192
  /**
185
- * Concrete AgentRunner backed by a RunnerIO boundary.
193
+ * Concrete AgentRunner backed by RunnerDeps.
186
194
  *
187
- * Captures io at construction time so AgentManager remains IO-unaware.
195
+ * Captures IO, exec, and registry at construction time so AgentManager
196
+ * remains unaware of runner-internal dependencies.
188
197
  */
189
198
  export class ConcreteAgentRunner implements AgentRunner {
190
- constructor(private readonly io: RunnerIO) {}
199
+ constructor(private readonly deps: RunnerDeps) {}
191
200
 
192
201
  run(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: RunOptions): Promise<RunResult> {
193
- return runAgent(snapshot, type, prompt, options, this.io);
202
+ return runAgent(snapshot, type, prompt, options, this.deps);
194
203
  }
195
204
 
196
205
  resume(session: AgentSession, prompt: string, options?: ResumeOptions): Promise<string> {
@@ -253,11 +262,11 @@ export async function runAgent(
253
262
  type: SubagentType,
254
263
  prompt: string,
255
264
  options: RunOptions,
256
- io: RunnerIO,
265
+ deps: RunnerDeps,
257
266
  ): Promise<RunResult> {
258
267
  // Resolve working directory upfront - needed for detectEnv before assembly.
259
268
  const effectiveCwd = options.context.cwd ?? snapshot.cwd;
260
- const env = await io.detectEnv(options.context.exec, effectiveCwd);
269
+ const env = await deps.io.detectEnv(deps.exec, effectiveCwd);
261
270
 
262
271
  // Assemble session configuration (synchronous, no SDK objects).
263
272
  const cfg = assembleSessionConfig(
@@ -275,11 +284,11 @@ export async function runAgent(
275
284
  thinkingLevel: options.thinkingLevel,
276
285
  },
277
286
  env,
278
- options.context.registry,
279
- io.assemblerIO,
287
+ deps.registry,
288
+ deps.io.assemblerIO,
280
289
  );
281
290
 
282
- const agentDir = io.getAgentDir();
291
+ const agentDir = deps.io.getAgentDir();
283
292
 
284
293
  // Load extensions/skills: true → load; false → don't.
285
294
  // Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md - upstream's
@@ -287,7 +296,7 @@ export async function runAgent(
287
296
  // would defeat prompt_mode: replace and isolated: true. Parent context, if
288
297
  // wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
289
298
  // is embedded in systemPromptOverride) or inherit_context (conversation).
290
- const loader = io.createResourceLoader({
299
+ const loader = deps.io.createResourceLoader({
291
300
  cwd: cfg.effectiveCwd,
292
301
  agentDir,
293
302
  noExtensions: !cfg.extensions,
@@ -303,15 +312,15 @@ export async function runAgent(
303
312
  // Create a persisted SessionManager so transcripts are written in Pi's
304
313
  // official JSONL format. Falls back to a temp directory when the parent
305
314
  // session is not persisted (e.g. headless/API mode).
306
- const sessionDir = io.deriveSessionDir(options.context.parentSession?.parentSessionFile, cfg.effectiveCwd);
307
- const sessionManager = io.createSessionManager(cfg.effectiveCwd, sessionDir);
315
+ const sessionDir = deps.io.deriveSessionDir(options.context.parentSession?.parentSessionFile, cfg.effectiveCwd);
316
+ const sessionManager = deps.io.createSessionManager(cfg.effectiveCwd, sessionDir);
308
317
  sessionManager.newSession({ parentSession: options.context.parentSession?.parentSessionId });
309
318
 
310
- const { session } = await io.createSession({
319
+ const { session } = await deps.io.createSession({
311
320
  cwd: cfg.effectiveCwd,
312
321
  agentDir,
313
322
  sessionManager,
314
- settingsManager: io.createSettingsManager(cfg.effectiveCwd, agentDir),
323
+ settingsManager: deps.io.createSettingsManager(cfg.effectiveCwd, agentDir),
315
324
  modelRegistry: snapshot.modelRegistry,
316
325
  model: cfg.model,
317
326
  tools: cfg.toolNames,
@@ -16,6 +16,8 @@
16
16
  */
17
17
 
18
18
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
19
+ import { debugLog } from "#src/debug";
20
+ import type { RunResult } from "#src/lifecycle/agent-runner";
19
21
  import type { ExecutionState } from "#src/lifecycle/execution-state";
20
22
  import type { LifetimeUsage } from "#src/lifecycle/usage";
21
23
  import { addUsage } from "#src/lifecycle/usage";
@@ -44,7 +46,7 @@ export interface AgentInit {
44
46
  error?: string;
45
47
  abortController?: AbortController;
46
48
  invocation?: AgentInvocation;
47
- promise?: Promise<string>;
49
+ promise?: Promise<void>;
48
50
  }
49
51
 
50
52
  export class Agent {
@@ -83,7 +85,7 @@ export class Agent {
83
85
  /** AbortController for cancelling this agent. Set at construction; used only by AgentManager. */
84
86
  readonly abortController?: AbortController;
85
87
  /** Promise for the full agent run (including post-processing). Set once by AgentManager. */
86
- promise?: Promise<string>;
88
+ promise?: Promise<void>;
87
89
 
88
90
  // Phase-specific collaborators — each born complete when their info becomes available
89
91
  execution?: ExecutionState;
@@ -248,12 +250,90 @@ export class Agent {
248
250
  this._pendingSteers = [];
249
251
  }
250
252
 
251
- /** Reset for resume: running status, new startedAt, clear completedAt/result/error. */
253
+ /** Reset for resume: running status, new startedAt, clear completedAt/result/error/listeners. */
252
254
  resetForResume(startedAt: number): void {
253
255
  this._status = "running";
254
256
  this._startedAt = startedAt;
255
257
  this._completedAt = undefined;
256
258
  this._result = undefined;
257
259
  this._error = undefined;
260
+ this.releaseListeners();
261
+ this._onRunFinished = undefined;
262
+ }
263
+
264
+ // --- Per-run listener state (released on completion or resume reset) ---
265
+ private _unsub?: () => void;
266
+ private _detachFn?: () => void;
267
+ private _onRunFinished?: () => void;
268
+
269
+ /** Wire a parent AbortSignal so it stops this agent when fired. */
270
+ wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
271
+ if (!signal) return;
272
+ const listener = () => onAbort();
273
+ signal.addEventListener("abort", listener, { once: true });
274
+ this._detachFn = () => signal.removeEventListener("abort", listener);
275
+ }
276
+
277
+ /** Store the record-observer unsubscribe handle. */
278
+ attachObserver(unsub: () => void): void {
279
+ this._unsub = unsub;
280
+ }
281
+
282
+ /** Release observer + signal listener handles. */
283
+ releaseListeners(): void {
284
+ this._unsub?.();
285
+ this._unsub = undefined;
286
+ this._detachFn?.();
287
+ this._detachFn = undefined;
288
+ }
289
+
290
+ /** Set the callback fired once when the run finishes (for concurrency drain). */
291
+ setOnRunFinished(fn: (() => void) | undefined): void {
292
+ this._onRunFinished = fn;
293
+ }
294
+
295
+ /** Fire the onRunFinished callback at most once. */
296
+ private fireOnRunFinished(): void {
297
+ const fn = this._onRunFinished;
298
+ this._onRunFinished = undefined;
299
+ fn?.();
300
+ }
301
+
302
+ /** Complete a run: release listeners, worktree cleanup, status transition, execution update, fire onRunFinished. */
303
+ completeRun(result: RunResult, worktrees: WorktreeManager): void {
304
+ this.releaseListeners();
305
+
306
+ let finalResult = result.responseText;
307
+ if (this.worktreeState) {
308
+ const wtResult = this.worktreeState.performCleanup(worktrees, this.description);
309
+ if (wtResult.hasChanges && wtResult.branch) {
310
+ finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
311
+ }
312
+ }
313
+
314
+ if (result.aborted) this.markAborted(finalResult);
315
+ else if (result.steered) this.markSteered(finalResult);
316
+ else this.markCompleted(finalResult);
317
+
318
+ this.execution = {
319
+ session: result.session,
320
+ outputFile: result.sessionFile ?? this.execution?.outputFile,
321
+ };
322
+
323
+ this.fireOnRunFinished();
324
+ }
325
+
326
+ /** Fail a run: mark error, release listeners, best-effort worktree cleanup, fire onRunFinished. */
327
+ failRun(err: unknown, worktrees: WorktreeManager): void {
328
+ this.markError(err);
329
+ this.releaseListeners();
330
+
331
+ if (this.worktreeState) {
332
+ try {
333
+ this.worktreeState.performCleanup(worktrees, this.description);
334
+ } catch (cleanupErr) { debugLog("cleanupWorktree on agent error", cleanupErr); }
335
+ }
336
+
337
+ this.fireOnRunFinished();
258
338
  }
259
339
  }