@gotgenes/pi-subagents 16.2.0 → 16.2.2

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,37 @@
1
+ /**
2
+ * run-listeners.ts — Per-run observer-unsubscribe and signal-detach handles.
3
+ *
4
+ * Owns the two teardown handles that a Subagent wires at run start (signal
5
+ * listener) and after session creation (record-observer unsub), releasing
6
+ * both atomically when the run ends or the agent is resumed.
7
+ */
8
+
9
+ /** Owns the per-run observer-unsubscribe and signal-detach handles. */
10
+ export class RunListeners {
11
+ private unsub?: () => void;
12
+ private detach?: () => void;
13
+
14
+ /**
15
+ * Wire a parent AbortSignal so it triggers onAbort when fired.
16
+ * No-op when signal is undefined.
17
+ */
18
+ wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
19
+ if (!signal) return;
20
+ const listener = () => onAbort();
21
+ signal.addEventListener("abort", listener, { once: true });
22
+ this.detach = () => signal.removeEventListener("abort", listener);
23
+ }
24
+
25
+ /** Store the record-observer unsubscribe handle. */
26
+ attachObserver(unsub: () => void): void {
27
+ this.unsub = unsub;
28
+ }
29
+
30
+ /** Release the observer + signal handles. Idempotent. */
31
+ release(): void {
32
+ this.unsub?.();
33
+ this.unsub = undefined;
34
+ this.detach?.();
35
+ this.detach = undefined;
36
+ }
37
+ }
@@ -168,12 +168,14 @@ export class SubagentManager {
168
168
  }
169
169
 
170
170
  if (options.isBackground && !options.bypassQueue) {
171
- // Schedule on the limiter — start() guards against abort-while-queued.
172
- void this.limiter.schedule(() => record.start());
171
+ // Schedule on the limiter — scheduleVia captures the limiter promise
172
+ // eagerly, so a queued agent is awaitable from spawn; guardedRun guards
173
+ // against abort-while-queued when the slot frees.
174
+ record.scheduleVia((thunk) => this.limiter.schedule(thunk));
173
175
  return id;
174
176
  }
175
177
 
176
- void record.start();
178
+ record.start();
177
179
  return id;
178
180
  }
179
181
 
@@ -1,21 +1,9 @@
1
1
  /**
2
- * subagent.ts — Subagent class with encapsulated status-transition logic and per-subagent behavior.
2
+ * subagent.ts — Subagent class: identity, lifecycle status, and per-subagent behavior.
3
3
  *
4
- * Status transitions (status, result, error, startedAt, completedAt) are owned
5
- * by the class and exposed via transition methods. External code reads these
6
- * fields through public properties but cannot write them directly.
7
- *
8
- * Stats (toolUses, lifetimeUsage, compactionCount) are owned by the class and
9
- * accumulated via mutation methods (incrementToolUses, addUsage, incrementCompactions).
10
- *
11
- * Behavior (abort, steer buffering) lives on the subagent rather than on
12
- * SubagentManager — each subagent manages its own lifecycle concerns.
13
- *
14
- * The child's working directory is supplied by a registered WorkspaceProvider
15
- * (the workspace seam); with no provider the child runs in the parent cwd.
16
- *
17
- * Phase-specific collaborators (subagentSession, notification) are attached
18
- * after construction as lifecycle information becomes available.
4
+ * Status/stats are delegated to the SubagentState value object; listener
5
+ * lifecycle to RunListeners; workspace prepare/dispose to WorkspaceBracket.
6
+ * Behavior (abort, steer buffering) lives here rather than on SubagentManager.
19
7
  */
20
8
 
21
9
  import type { Model } from "@earendil-works/pi-ai";
@@ -23,10 +11,12 @@ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
23
11
  import { debugLog } from "#src/debug";
24
12
  import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
25
13
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
14
+ import { RunListeners } from "#src/lifecycle/run-listeners";
26
15
  import type { SubagentSession, TurnLoopResult } from "#src/lifecycle/subagent-session";
27
16
  import { SubagentState, type SubagentStatus } from "#src/lifecycle/subagent-state";
28
17
  import type { LifetimeUsage } from "#src/lifecycle/usage";
29
- import type { Workspace, WorkspaceProvider } from "#src/lifecycle/workspace";
18
+ import type { WorkspaceProvider } from "#src/lifecycle/workspace";
19
+ import { WorkspaceBracket } from "#src/lifecycle/workspace-bracket";
30
20
  import { NotificationState } from "#src/observation/notification-state";
31
21
  import { subscribeSubagentObserver } from "#src/observation/record-observer";
32
22
  import type { RunConfig } from "#src/runtime";
@@ -105,23 +95,16 @@ export class Subagent {
105
95
  get lifetimeUsage(): Readonly<LifetimeUsage> { return this.state.lifetimeUsage; }
106
96
  get compactionCount(): number { return this.state.compactionCount; }
107
97
 
108
- /** AbortController for cancelling this agent. Created at construction. */
109
98
  readonly abortController: AbortController;
110
- /** Backing store for the run promise. Set by start(). */
111
99
  private _promise?: Promise<void>;
112
- /** Awaitable handle to the running promise. Set by start(). */
113
100
  get promise(): Promise<void> | undefined { return this._promise; }
114
101
 
115
- // Execution machinery — a single mandatory collaborator (no per-field fallbacks).
116
102
  private readonly execution: SubagentExecution;
117
- /** Workspace prepared at run-start by a provider — undefined when none is registered. */
118
- private _workspace?: Workspace;
103
+ private readonly listeners = new RunListeners();
104
+ private readonly workspaceBracket: WorkspaceBracket;
119
105
 
120
- // Phase-specific collaborators — each born complete when their info becomes available
121
- /** The born-complete child session — set when the factory returns inside run(). */
122
106
  subagentSession?: SubagentSession;
123
107
  private _notification?: NotificationState;
124
- /** Notification state for background agents — wired from parentSession.toolCallId. */
125
108
  get notification(): NotificationState | undefined { return this._notification; }
126
109
 
127
110
  // Steer buffer — messages queued before the session is ready
@@ -191,6 +174,11 @@ export class Subagent {
191
174
  // Execution machinery — a single mandatory collaborator
192
175
  this.execution = init.execution;
193
176
 
177
+ // Per-run lifecycle collaborators
178
+ this.workspaceBracket = new WorkspaceBracket(
179
+ this.execution.getWorkspaceProvider ?? (() => undefined),
180
+ );
181
+
194
182
  // Notification state — created from parentSession.toolCallId if present
195
183
  const toolCallId = init.execution.parentSession?.toolCallId;
196
184
  if (toolCallId) {
@@ -210,27 +198,26 @@ export class Subagent {
210
198
  async run(): Promise<void> {
211
199
  this.markRunning(Date.now());
212
200
  this.execution.observer?.onStarted?.(this);
213
- this.wireSignal(this.execution.signal, () => this.abort());
201
+ this.listeners.wireSignal(this.execution.signal, () => this.abort());
214
202
 
203
+ // Guard the await so the no-provider path stays synchronous, preserving
204
+ // the original run() timing: the factory is called in the same turn as
205
+ // spawn() when no workspace provider is registered.
215
206
  let cwd: string | undefined;
216
- try {
217
- // A registered workspace provider supplies the child's cwd and owns its
218
- // teardown; with no provider the child runs in the parent cwd.
219
- const provider = this.execution.getWorkspaceProvider?.();
220
- if (provider) {
221
- this._workspace = await provider.prepare({
207
+ if (this.workspaceBracket.hasProvider()) {
208
+ try {
209
+ cwd = await this.workspaceBracket.prepare({
222
210
  agentId: this.id,
223
211
  agentType: this.type,
224
212
  baseCwd: this.execution.baseCwd,
225
213
  invocation: this.invocation,
226
214
  });
227
- cwd = this._workspace?.cwd;
215
+ } catch (err) {
216
+ this.markError(err);
217
+ this.listeners.release();
218
+ this.execution.observer?.onRunFinished?.(this);
219
+ return;
228
220
  }
229
- } catch (err) {
230
- this.markError(err);
231
- this.releaseListeners();
232
- this.execution.observer?.onRunFinished?.(this);
233
- return;
234
221
  }
235
222
 
236
223
  try {
@@ -249,7 +236,7 @@ export class Subagent {
249
236
  }
250
237
 
251
238
  this.flushPendingSteers();
252
- this.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
239
+ this.listeners.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
253
240
  onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
254
241
  }));
255
242
  this.execution.observer?.onSessionCreated?.(this);
@@ -269,19 +256,32 @@ export class Subagent {
269
256
  }
270
257
 
271
258
  /**
272
- * Start execution: call run(), store the promise internally, and return it.
273
- *
274
- * Guards against non-active states (e.g. abort-while-queued): if the agent
275
- * is neither queued nor running, the promise resolves immediately (no-op).
276
- * This folds the inline status guard out of SubagentManager's limiter callback.
259
+ * Start execution immediately (foreground / bypassQueue paths).
260
+ * Stores the run promise so it is awaitable via the `promise` getter.
277
261
  */
278
- start(): Promise<void> {
279
- if (this.status !== "queued" && this.status !== "running") {
280
- this._promise = Promise.resolve();
281
- return this._promise;
282
- }
283
- this._promise = this.run();
284
- return this._promise;
262
+ start(): void {
263
+ this._promise = this.guardedRun();
264
+ }
265
+
266
+ /**
267
+ * Schedule execution through an external concurrency scheduler (the limiter).
268
+ * Captures the scheduler's promise eagerly, so a still-queued agent is
269
+ * awaitable via the `promise` getter from spawn — not only once its slot opens.
270
+ * The guard in guardedRun() makes an abort-while-queued run a no-op when the
271
+ * slot finally frees.
272
+ */
273
+ scheduleVia(schedule: (thunk: () => Promise<void>) => Promise<void>): void {
274
+ this._promise = schedule(() => this.guardedRun());
275
+ }
276
+
277
+ /**
278
+ * Run unless the agent left the active set before its slot opened
279
+ * (e.g. abort-while-queued): a non-queued, non-running status resolves
280
+ * immediately without running.
281
+ */
282
+ private guardedRun(): Promise<void> {
283
+ if (this.status !== "queued" && this.status !== "running") return Promise.resolve();
284
+ return this.run();
285
285
  }
286
286
 
287
287
  /**
@@ -300,7 +300,7 @@ export class Subagent {
300
300
  }
301
301
 
302
302
  this.resetForResume(Date.now());
303
- this.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
303
+ this.listeners.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
304
304
  onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
305
305
  }));
306
306
 
@@ -310,7 +310,7 @@ export class Subagent {
310
310
  } catch (err) {
311
311
  this.markError(err);
312
312
  } finally {
313
- this.releaseListeners();
313
+ this.listeners.release();
314
314
  }
315
315
  }
316
316
 
@@ -406,48 +406,21 @@ export class Subagent {
406
406
  /** Reset for resume: running status, new startedAt, clear completedAt/result/error/listeners. */
407
407
  resetForResume(startedAt: number): void {
408
408
  this.state.resetForResume(startedAt);
409
- this.releaseListeners();
410
- }
411
-
412
- // --- Per-run listener state (released on completion or resume reset) ---
413
- private _unsub?: () => void;
414
- private _detachFn?: () => void;
415
-
416
- /** Wire a parent AbortSignal so it stops this agent when fired. */
417
- wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
418
- if (!signal) return;
419
- const listener = () => onAbort();
420
- signal.addEventListener("abort", listener, { once: true });
421
- this._detachFn = () => signal.removeEventListener("abort", listener);
422
- }
423
-
424
- /** Store the record-observer unsubscribe handle. */
425
- attachObserver(unsub: () => void): void {
426
- this._unsub = unsub;
427
- }
428
-
429
- /** Release observer + signal listener handles. */
430
- releaseListeners(): void {
431
- this._unsub?.();
432
- this._unsub = undefined;
433
- this._detachFn?.();
434
- this._detachFn = undefined;
409
+ this.listeners.release();
435
410
  }
436
411
 
437
412
  /** Complete a run: release listeners, dispose the workspace, status transition, notify observer. */
438
413
  completeRun(result: TurnLoopResult): void {
439
- this.releaseListeners();
440
-
441
- let finalResult = result.responseText;
442
- if (this._workspace) {
443
- const finalStatus: SubagentStatus = result.aborted
444
- ? "aborted"
445
- : result.steered
446
- ? "steered"
447
- : "completed";
448
- const disposeResult = this._workspace.dispose({ status: finalStatus, description: this.description });
449
- if (disposeResult?.resultAddendum) finalResult += disposeResult.resultAddendum;
450
- }
414
+ this.listeners.release();
415
+
416
+ const finalStatus: SubagentStatus = result.aborted
417
+ ? "aborted"
418
+ : result.steered
419
+ ? "steered"
420
+ : "completed";
421
+ const finalResult =
422
+ result.responseText +
423
+ this.workspaceBracket.dispose({ status: finalStatus, description: this.description });
451
424
 
452
425
  if (result.aborted) this.markAborted(finalResult);
453
426
  else if (result.steered) this.markSteered(finalResult);
@@ -464,10 +437,10 @@ export class Subagent {
464
437
  /** Fail a run: mark error, release listeners, best-effort workspace dispose, notify observer. */
465
438
  failRun(err: unknown): void {
466
439
  this.markError(err);
467
- this.releaseListeners();
440
+ this.listeners.release();
468
441
 
469
442
  try {
470
- if (this._workspace) this._workspace.dispose({ status: "error", description: this.description });
443
+ this.workspaceBracket.dispose({ status: "error", description: this.description });
471
444
  } catch (cleanupErr) { debugLog("workspace dispose on agent error", cleanupErr); }
472
445
 
473
446
  this.execution.observer?.onRunFinished?.(this);
@@ -0,0 +1,59 @@
1
+ /**
2
+ * workspace-bracket.ts — Owned prepare/dispose lifecycle for a child workspace.
3
+ *
4
+ * Captures the provider resolver (not the provider itself) so provider
5
+ * resolution stays lazy at run-start. The prepared Workspace is held
6
+ * privately; dispose() centralises the guard and addendum-unwrap so callers
7
+ * never reach through to workspace.dispose().resultAddendum directly.
8
+ *
9
+ * dispose() deliberately does NOT catch errors — the best-effort try/catch
10
+ * for failRun() belongs at the call site, preserving the per-caller semantics.
11
+ */
12
+
13
+ import type {
14
+ Workspace,
15
+ WorkspaceDisposeOutcome,
16
+ WorkspacePrepareContext,
17
+ WorkspaceProvider,
18
+ } from "#src/lifecycle/workspace";
19
+
20
+ /** Owns the child workspace lifecycle: prepare at run-start, dispose at run-end. */
21
+ export class WorkspaceBracket {
22
+ private prepared?: Workspace;
23
+
24
+ constructor(private readonly resolveProvider: () => WorkspaceProvider | undefined) {}
25
+
26
+ /**
27
+ * Returns true when a workspace provider is currently registered.
28
+ * Use to guard the `await prepare(...)` call and avoid an unnecessary
29
+ * microtask boundary in the no-provider path.
30
+ */
31
+ hasProvider(): boolean {
32
+ return this.resolveProvider() !== undefined;
33
+ }
34
+
35
+ /**
36
+ * Resolve the registered provider and prepare the child workspace.
37
+ * Returns the workspace's cwd, or undefined when no provider is registered
38
+ * or the provider resolves to undefined.
39
+ */
40
+ async prepare(ctx: WorkspacePrepareContext): Promise<string | undefined> {
41
+ const provider = this.resolveProvider();
42
+ if (!provider) return undefined;
43
+ this.prepared = await provider.prepare(ctx);
44
+ return this.prepared?.cwd;
45
+ }
46
+
47
+ /**
48
+ * Dispose the prepared workspace (if any) and return the result addendum
49
+ * verbatim. Returns an empty string when no workspace was prepared or when
50
+ * the workspace returns no addendum.
51
+ *
52
+ * Throws propagate — wrap in try/catch at the call site when best-effort
53
+ * disposal is desired (e.g. failRun).
54
+ */
55
+ dispose(outcome: WorkspaceDisposeOutcome): string {
56
+ if (!this.prepared) return "";
57
+ return this.prepared.dispose(outcome)?.resultAddendum ?? "";
58
+ }
59
+ }
@@ -0,0 +1,97 @@
1
+ import type { SubagentManagerObserver } from "#src/lifecycle/subagent-manager";
2
+ import { buildEventData, type NotificationSystem } from "#src/observation/notification";
3
+ import type { CompactionInfo, Subagent } from "#src/types";
4
+
5
+ /** Emit callback — a subset of `pi.events.emit`. */
6
+ export type EventEmit = (channel: string, data: unknown) => void;
7
+
8
+ /** Append callback — a subset of `pi.appendEntry`. */
9
+ export type AppendEntry = (customType: string, data: unknown) => void;
10
+
11
+ export interface SubagentEventsObserverDeps {
12
+ emit: EventEmit;
13
+ appendEntry: AppendEntry;
14
+ notifications: NotificationSystem;
15
+ }
16
+
17
+ /**
18
+ * Receives agent lifecycle notifications from SubagentManager and dispatches
19
+ * them to three concerns: pi.events lifecycle events, session-entry persistence,
20
+ * and completion notifications.
21
+ *
22
+ * Constructed with narrow deps (emit, appendEntry, NotificationSystem) so all
23
+ * three concerns are unit-testable without booting the extension.
24
+ */
25
+ export class SubagentEventsObserver implements SubagentManagerObserver {
26
+ private readonly emit: EventEmit;
27
+ private readonly appendEntry: AppendEntry;
28
+ private readonly notifications: NotificationSystem;
29
+
30
+ constructor(deps: SubagentEventsObserverDeps) {
31
+ this.emit = deps.emit;
32
+ this.appendEntry = deps.appendEntry;
33
+ this.notifications = deps.notifications;
34
+ }
35
+
36
+ onSubagentStarted(record: Subagent): void {
37
+ // Emit started event when agent transitions to running (including from queue).
38
+ this.emit("subagents:started", {
39
+ id: record.id,
40
+ type: record.type,
41
+ description: record.description,
42
+ });
43
+ }
44
+
45
+ onSubagentCompleted(record: Subagent): void {
46
+ // Emit lifecycle event based on terminal status.
47
+ const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted";
48
+ const eventData = buildEventData(record);
49
+ if (isError) {
50
+ this.emit("subagents:failed", eventData);
51
+ } else {
52
+ this.emit("subagents:completed", eventData);
53
+ }
54
+
55
+ // Persist final record for cross-extension history reconstruction.
56
+ this.appendEntry("subagents:record", {
57
+ id: record.id,
58
+ type: record.type,
59
+ description: record.description,
60
+ status: record.status,
61
+ result: record.result,
62
+ error: record.error,
63
+ startedAt: record.startedAt,
64
+ completedAt: record.completedAt,
65
+ });
66
+
67
+ // Skip notification if result was already consumed via get_subagent_result.
68
+ if (record.notification?.resultConsumed) {
69
+ this.notifications.cleanupCompleted(record.id);
70
+ return;
71
+ }
72
+
73
+ this.notifications.sendCompletion(record);
74
+ }
75
+
76
+ onSubagentCompacted(record: Subagent, info: CompactionInfo): void {
77
+ // Emit compacted event when agent's session compacts (preserves count on record).
78
+ this.emit("subagents:compacted", {
79
+ id: record.id,
80
+ type: record.type,
81
+ description: record.description,
82
+ reason: info.reason,
83
+ tokensBefore: info.tokensBefore,
84
+ compactionCount: record.compactionCount,
85
+ });
86
+ }
87
+
88
+ onSubagentCreated(record: Subagent): void {
89
+ // Emit created event for background agents (before limiter admission).
90
+ this.emit("subagents:created", {
91
+ id: record.id,
92
+ type: record.type,
93
+ description: record.description,
94
+ isBackground: true,
95
+ });
96
+ }
97
+ }