@jopqior/pi-subagents 1.0.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +2705 -0
  2. package/LICENSE +21 -0
  3. package/README.md +503 -0
  4. package/dist/public.d.ts +331 -0
  5. package/dist/settings.d.ts +82 -0
  6. package/docs/architecture/architecture.md +1566 -0
  7. package/docs/architecture/client-server-opportunities.md +127 -0
  8. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  9. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  10. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  11. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  12. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  13. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  14. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  15. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  16. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  17. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  18. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  19. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  20. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  21. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  22. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  23. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  24. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  25. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  26. package/docs/architecture/history/phase-8-testability.md +103 -0
  27. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  28. package/docs/comparison-with-upstream.md +77 -0
  29. package/docs/configuration.md +364 -0
  30. package/docs/decisions/0001-deferred-patches.md +80 -0
  31. package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
  32. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  33. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  34. package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
  35. package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
  36. package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
  37. package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
  38. package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
  39. package/package.json +91 -0
  40. package/src/config/agent-types.ts +135 -0
  41. package/src/config/custom-agents.ts +151 -0
  42. package/src/config/default-agents.ts +121 -0
  43. package/src/config/invocation-config.ts +167 -0
  44. package/src/config/thinking-level.ts +58 -0
  45. package/src/debug.ts +14 -0
  46. package/src/handlers/index.ts +3 -0
  47. package/src/handlers/interrupt.ts +58 -0
  48. package/src/handlers/lifecycle.ts +71 -0
  49. package/src/handlers/widget-events.ts +49 -0
  50. package/src/index.ts +292 -0
  51. package/src/layered-settings.ts +105 -0
  52. package/src/lifecycle/child-lifecycle.ts +115 -0
  53. package/src/lifecycle/child-shutdown.ts +105 -0
  54. package/src/lifecycle/concurrency-limiter.ts +55 -0
  55. package/src/lifecycle/create-subagent-session.ts +335 -0
  56. package/src/lifecycle/parent-snapshot.ts +119 -0
  57. package/src/lifecycle/run-listeners.ts +37 -0
  58. package/src/lifecycle/selection-scope.ts +116 -0
  59. package/src/lifecycle/spawn-selection.ts +259 -0
  60. package/src/lifecycle/subagent-manager.ts +546 -0
  61. package/src/lifecycle/subagent-session.ts +347 -0
  62. package/src/lifecycle/subagent-state.ts +404 -0
  63. package/src/lifecycle/subagent.ts +885 -0
  64. package/src/lifecycle/turn-limits.ts +13 -0
  65. package/src/lifecycle/usage.ts +60 -0
  66. package/src/lifecycle/workspace-bracket.ts +76 -0
  67. package/src/lifecycle/workspace.ts +46 -0
  68. package/src/observation/composite-subagent-observer.ts +74 -0
  69. package/src/observation/notification.ts +430 -0
  70. package/src/observation/outcome-delivery.ts +239 -0
  71. package/src/observation/record-observer.ts +78 -0
  72. package/src/observation/renderer.ts +161 -0
  73. package/src/observation/subagent-events-observer.ts +148 -0
  74. package/src/runtime.ts +137 -0
  75. package/src/service/service-adapter.ts +201 -0
  76. package/src/service/service.ts +246 -0
  77. package/src/session/ask-parent-tool.ts +69 -0
  78. package/src/session/content-items.ts +53 -0
  79. package/src/session/context.ts +80 -0
  80. package/src/session/conversation.ts +49 -0
  81. package/src/session/env.ts +40 -0
  82. package/src/session/model-resolver.ts +126 -0
  83. package/src/session/notify-parent-tool.ts +83 -0
  84. package/src/session/package-exclusions.ts +75 -0
  85. package/src/session/prompts.ts +231 -0
  86. package/src/session/provider-inheritance.ts +56 -0
  87. package/src/session/selection-catalogue.ts +143 -0
  88. package/src/session/session-config.ts +202 -0
  89. package/src/session/session-dir.ts +38 -0
  90. package/src/settings.ts +447 -0
  91. package/src/tools/agent-tool.ts +305 -0
  92. package/src/tools/background-spawner.ts +83 -0
  93. package/src/tools/foreground-runner.ts +159 -0
  94. package/src/tools/get-result-renderer.ts +119 -0
  95. package/src/tools/get-result-report.ts +84 -0
  96. package/src/tools/get-result-tool.ts +192 -0
  97. package/src/tools/helpers.ts +118 -0
  98. package/src/tools/result-renderer.ts +153 -0
  99. package/src/tools/spawn-config.ts +192 -0
  100. package/src/tools/steer-tool.ts +109 -0
  101. package/src/types.ts +143 -0
  102. package/src/ui/agent-widget.ts +333 -0
  103. package/src/ui/bounded-lines.ts +45 -0
  104. package/src/ui/display.ts +180 -0
  105. package/src/ui/glyphs.ts +62 -0
  106. package/src/ui/session-navigation.ts +150 -0
  107. package/src/ui/session-navigator.ts +255 -0
  108. package/src/ui/subagents-settings.ts +179 -0
  109. package/src/ui/transcript-content.ts +374 -0
  110. package/src/ui/widget-renderer.ts +301 -0
@@ -0,0 +1,885 @@
1
+ /**
2
+ * subagent.ts — Subagent class: identity, lifecycle status, and per-subagent behavior.
3
+ *
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.
7
+ */
8
+
9
+ import type { Model } from "@earendil-works/pi-ai";
10
+ import type { AgentSessionEvent, ToolDefinition } from "@earendil-works/pi-coding-agent";
11
+ import { debugLog } from "#src/debug";
12
+ import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
13
+ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
14
+ import { RunListeners } from "#src/lifecycle/run-listeners";
15
+ import type { SelectionScopeHandle } from "#src/lifecycle/selection-scope";
16
+ import {
17
+ isSelectionCancellation,
18
+ SelectionCancelledError,
19
+ } from "#src/lifecycle/spawn-selection";
20
+ import type { SubagentSession, TurnLoopResult } from "#src/lifecycle/subagent-session";
21
+ import { SubagentState, type SubagentStatus } from "#src/lifecycle/subagent-state";
22
+ import type { LifetimeUsage } from "#src/lifecycle/usage";
23
+ import type { WorkspaceProvider } from "#src/lifecycle/workspace";
24
+ import { WorkspaceBracket } from "#src/lifecycle/workspace-bracket";
25
+ import { subscribeSubagentObserver } from "#src/observation/record-observer";
26
+ import type { RunConfig } from "#src/runtime";
27
+ import type { SpawnSelectionProvider } from "#src/service/service";
28
+ import {
29
+ readSelectionChoices,
30
+ type ValidatedSpawnSelection,
31
+ validateSpawnSelection,
32
+ } from "#src/session/selection-catalogue";
33
+ import type { CompactionInfo, ParentSessionInfo, SessionMessage, SubagentType, ThinkingLevel } from "#src/types";
34
+
35
+ /** Per-subagent lifecycle observer — created by SubagentManager for each spawn. */
36
+ export interface SubagentLifecycleObserver {
37
+ /** Fires when the subagent transitions to running (inside run(), after markRunning). */
38
+ onStarted?(agent: Subagent): void;
39
+ /** Fires once the session is created — the subagent's subagentSession is now available. */
40
+ onSessionCreated?(agent: Subagent): void;
41
+ /** Fires once when the run completes or fails (for concurrency drain). */
42
+ onRunFinished?(agent: Subagent): void;
43
+ /**
44
+ * Fires once a resumed run is under way — after the record is rewound, so a
45
+ * subscriber reading it sees the run that just started rather than the
46
+ * outcome of the one it replaced.
47
+ */
48
+ onResumeStarted?(agent: Subagent): void;
49
+ /** Fires once when a resumed run reaches a terminal state. */
50
+ onResumeFinished?(agent: Subagent): void;
51
+ /** Fires when the running agent sends its parent a mid-run message. */
52
+ onUpdateSent?(agent: Subagent, message: string): void;
53
+ /**
54
+ * Fires when a teardown after the agent's result was delivered reported where
55
+ * its work went. Not fired for a failed run or resume: those reach a terminal
56
+ * notification of their own, which carries the notice.
57
+ */
58
+ onWorkspaceNotice?(agent: Subagent, notice: string): void;
59
+ /** Fires on compaction events during the run. */
60
+ onCompacted?(agent: Subagent, info: CompactionInfo): void;
61
+ }
62
+
63
+ export type { SubagentStatus } from "#src/lifecycle/subagent-state";
64
+
65
+ /**
66
+ * Why a resume of an agent would be refused.
67
+ *
68
+ * One vocabulary for a fact three record-level conditions used to answer
69
+ * separately: the resume door decided it from `isSessionReady()`,
70
+ * `sessionReleased`, and `workspaceDisposed`, while the result carriers never
71
+ * consulted any of them and advertised the resume regardless.
72
+ *
73
+ * `still-running` is the one transient member: it is a refusal of *now* rather
74
+ * than of ever, and the carriers word it accordingly.
75
+ */
76
+ export type ResumeRefusal =
77
+ | "still-running"
78
+ | "no-session"
79
+ | "session-released"
80
+ | "workspace-disposed";
81
+
82
+ /**
83
+ * The result of a steer attempt. `Subagent.steer` owns the non-running
84
+ * rejection rule and reports it here, so coordinators switch on the outcome
85
+ * instead of pre-checking status (tell by id, with outcomes).
86
+ */
87
+ export type SteerOutcome =
88
+ | { kind: "delivered" }
89
+ | { kind: "buffered" }
90
+ | { kind: "rejected"; status: SubagentStatus };
91
+
92
+ /**
93
+ * The execution machinery a Subagent needs to run. A single mandatory
94
+ * collaborator: production (SubagentManager.spawn) always supplies it, so run()
95
+ * needs no "not configured" guards. The genuinely-optional behavior knobs stay
96
+ * optional; the four inputs run() cannot proceed without are required.
97
+ */
98
+ export interface SubagentExecution {
99
+ /** Assembly factory that produces a born-complete SubagentSession. */
100
+ createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
101
+ /** Immutable spawn-time parent snapshot handed to the session factory. */
102
+ snapshot: ParentSnapshot;
103
+ /** Initial prompt for the turn loop. */
104
+ prompt: string;
105
+ /** Parent working directory handed to a workspace provider's prepare(). */
106
+ baseCwd: string;
107
+ observer?: SubagentLifecycleObserver;
108
+ getRunConfig?: () => RunConfig;
109
+ /** Resolves the registered workspace provider (if any) at run-start. */
110
+ getWorkspaceProvider?: () => WorkspaceProvider | undefined;
111
+ /**
112
+ * The spawning session's retained selection scope. Supplied in production
113
+ * for every spawn; a run consults it only while the root lease is active.
114
+ */
115
+ selectionScope?: SelectionScopeHandle;
116
+ model?: Model<any>;
117
+ maxTurns?: number;
118
+ thinkingLevel?: ThinkingLevel;
119
+ parentSession?: ParentSessionInfo;
120
+ signal?: AbortSignal;
121
+ }
122
+
123
+ export interface SubagentInit {
124
+ // Identity
125
+ id: string;
126
+ type: SubagentType;
127
+ description: string;
128
+ /** The mode SubagentManager resolved for this spawn; drives scheduling and announcement. */
129
+ isBackground: boolean;
130
+
131
+ /** Execution machinery — always supplied; construct-complete, no test fallbacks. */
132
+ execution: SubagentExecution;
133
+
134
+ /** Lifecycle status and metrics. Defaults to a fresh queued state. */
135
+ state?: SubagentState;
136
+ }
137
+
138
+ export class Subagent {
139
+ // Identity — set once at construction
140
+ readonly id: string;
141
+ readonly type: SubagentType;
142
+ readonly description: string;
143
+ /**
144
+ * Whether this agent runs in the background. Resolved once at the manager
145
+ * choke point, so a consumer asks the record rather than re-deriving it from
146
+ * a per-call display snapshot only the tool door ever built (#724).
147
+ */
148
+ readonly isBackground: boolean;
149
+
150
+ // Lifecycle status and metrics — owned by a private value object; getters and
151
+ // mutation methods below delegate to it one line.
152
+ private readonly state: SubagentState;
153
+ get status(): SubagentStatus { return this.state.status; }
154
+ get result(): string | undefined { return this.state.result; }
155
+ get error(): string | undefined { return this.state.error; }
156
+ get stoppedWhileQueued(): boolean { return this.state.stoppedWhileQueued; }
157
+ get startedAt(): number { return this.state.startedAt; }
158
+ get completedAt(): number | undefined { return this.state.completedAt; }
159
+ get consumedAt(): number | undefined { return this.state.consumedAt; }
160
+ get consumed(): boolean { return this.state.consumed; }
161
+ get claimed(): boolean { return this.state.claimed; }
162
+ get pendingQuestion(): string | undefined { return this.state.pendingQuestion; }
163
+ get runUpdates(): readonly string[] { return this.state.runUpdates; }
164
+ /**
165
+ * What the workspace reported at a teardown with no result text to fold it
166
+ * into — the provider's own wording for where the child's work ended up.
167
+ *
168
+ * Undefined for a run that completed normally: there the addendum rides the
169
+ * result, and duplicating it here would have every carrier report it twice.
170
+ */
171
+ get workspaceNotice(): string | undefined { return this.state.workspaceNotice; }
172
+ get toolUses(): number { return this.state.toolUses; }
173
+ get lifetimeUsage(): Readonly<LifetimeUsage> { return this.state.lifetimeUsage; }
174
+ get compactionCount(): number { return this.state.compactionCount; }
175
+ get turnCount(): number { return this.state.turnCount; }
176
+ get activeTools(): ReadonlyMap<string, string> { return this.state.activeTools; }
177
+ get responseText(): string { return this.state.responseText; }
178
+ /** True while this run is waiting for a human model/thinking selection. */
179
+ get awaitingSelection(): boolean { return this.state.awaitingSelection; }
180
+ isActive(): boolean { return this.state.isActive(); }
181
+ isTerminalError(): boolean { return this.state.isTerminalError(); }
182
+ isRunning(): boolean { return this.state.isRunning(); }
183
+ canBeSteered(): boolean { return this.state.canBeSteered(); }
184
+ get maxTurns(): number | undefined { return this.execution.maxTurns; }
185
+
186
+ readonly abortController: AbortController;
187
+ private _promise?: Promise<void>;
188
+ /** Handle on the agent's current run — the initial run, or the live resume that replaced it. */
189
+ get promise(): Promise<void> | undefined { return this._promise; }
190
+
191
+ private readonly execution: SubagentExecution;
192
+ private readonly listeners = new RunListeners();
193
+ private readonly workspaceBracket: WorkspaceBracket;
194
+
195
+ subagentSession?: SubagentSession;
196
+
197
+ // Retained after releaseSession() disposes the heavy session, so outputFile
198
+ // (transcript pointer) survives and the resume path can tell "released" from
199
+ // "never had a session."
200
+ private _releasedOutputFile?: string;
201
+ private _sessionReleased = false;
202
+ /** True once releaseSession() has freed a live session (distinct from never having had one). */
203
+ get sessionReleased(): boolean { return this._sessionReleased; }
204
+
205
+ /**
206
+ * True once this agent's provider-supplied workspace has been torn down.
207
+ * False for an agent that never had one, so it names the resume the session
208
+ * would re-enter a removed directory for — not merely a session with a
209
+ * workspace provider registered.
210
+ */
211
+ get workspaceDisposed(): boolean { return this.workspaceBracket.wasDisposed(); }
212
+
213
+ // Steer buffer — messages queued before the session is ready
214
+ private _pendingSteers: string[] = [];
215
+ /** Number of steer messages waiting to be delivered. */
216
+ get pendingSteerCount(): number { return this._pendingSteers.length; }
217
+
218
+ /**
219
+ * Path to the agent's session JSONL file, or undefined if not yet available.
220
+ * Falls back to the path captured at releaseSession() once the live session is gone.
221
+ */
222
+ get outputFile(): string | undefined {
223
+ return this.subagentSession?.outputFile ?? this._releasedOutputFile;
224
+ }
225
+
226
+ /** The tool call ID that spawned this background agent, if any. */
227
+ get toolCallId(): string | undefined {
228
+ return this.execution.parentSession?.toolCallId;
229
+ }
230
+
231
+ /** Returns true when a SubagentSession is available (session is ready). */
232
+ isSessionReady(): boolean {
233
+ return this.subagentSession != null;
234
+ }
235
+
236
+ /**
237
+ * Why a resume of this agent would be refused, or undefined when one would be
238
+ * accepted.
239
+ *
240
+ * The conditions are checked in the order the resume door checks them, so a
241
+ * record whose session was released *and* whose workspace is gone reports the
242
+ * session — the door's message for it names the retention window, which is
243
+ * the fact that explains both. A live run outranks all of them: nothing about
244
+ * a settled record is decided yet.
245
+ *
246
+ * A getter rather than a predicate method because the result carriers read it
247
+ * as a field: `OutcomeAddenda` and `AgentReport` both declare it, and a live
248
+ * record satisfies them structurally only if it is a property.
249
+ */
250
+ get resumeRefusal(): ResumeRefusal | undefined {
251
+ // Before the session check: a run transitions to running before it creates
252
+ // its session, and "still running" describes that record better than "no
253
+ // session" does. A queued agent is not running and keeps the no-session
254
+ // answer, which is the truth about it.
255
+ if (this.isRunning()) return "still-running";
256
+ if (!this.isSessionReady()) return this._sessionReleased ? "session-released" : "no-session";
257
+ if (this.workspaceDisposed) return "workspace-disposed";
258
+ return undefined;
259
+ }
260
+
261
+ /**
262
+ * Steer a running agent, owning the non-running rejection rule.
263
+ * Returns a `rejected` outcome (with the observed status) when the agent is
264
+ * not running, a `buffered` outcome when the session is not yet ready, or a
265
+ * `delivered` outcome once the message reaches the session.
266
+ */
267
+ async steer(message: string): Promise<SteerOutcome> {
268
+ if (!this.canBeSteered()) {
269
+ return { kind: "rejected", status: this.status };
270
+ }
271
+ if (!this.subagentSession) {
272
+ this.queueSteer(message);
273
+ return { kind: "buffered" };
274
+ }
275
+ await this.subagentSession.steer(message);
276
+ return { kind: "delivered" };
277
+ }
278
+
279
+ /** Return the session conversation as formatted text, or undefined if no session. */
280
+ getConversation(): string | undefined {
281
+ return this.subagentSession?.getConversation();
282
+ }
283
+
284
+ /** Return the session context window utilization (0-100), or null if unavailable. */
285
+ getContextPercent(): number | null {
286
+ return this.subagentSession?.getContextPercent() ?? null;
287
+ }
288
+
289
+ /**
290
+ * Subscribe to session events for live updates (e.g., conversation viewer).
291
+ * Returns an unsubscribe function, or undefined if no session is available.
292
+ */
293
+ subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined {
294
+ return this.subagentSession?.subscribe(fn);
295
+ }
296
+
297
+ /** The session's message history, or an empty array if no session. */
298
+ get messages(): readonly unknown[] {
299
+ return this.subagentSession?.messages ?? [];
300
+ }
301
+
302
+ /** The session's message history typed for Pi's session-rendering machinery, or empty if no session. */
303
+ get agentMessages(): readonly SessionMessage[] {
304
+ return this.subagentSession?.agentMessages ?? [];
305
+ }
306
+
307
+ /** Resolve a registered tool definition by name, or undefined if no session. */
308
+ getToolDefinition(name: string): ToolDefinition | undefined {
309
+ return this.subagentSession?.getToolDefinition(name);
310
+ }
311
+
312
+ constructor(init: SubagentInit) {
313
+ // Identity
314
+ this.id = init.id;
315
+ this.type = init.type;
316
+ this.description = init.description;
317
+ this.isBackground = init.isBackground;
318
+
319
+ // Lifecycle status and metrics — fresh queued state unless one is supplied
320
+ this.state = init.state ?? new SubagentState();
321
+
322
+ // Abort controller — always created, never injected
323
+ this.abortController = new AbortController();
324
+
325
+ // Execution machinery — a single mandatory collaborator
326
+ this.execution = init.execution;
327
+
328
+ // Per-run lifecycle collaborators
329
+ this.workspaceBracket = new WorkspaceBracket(
330
+ this.execution.getWorkspaceProvider ?? (() => undefined),
331
+ );
332
+ }
333
+
334
+ /**
335
+ * Execute the full agent lifecycle: workspace preparation, session creation
336
+ * via the factory, observer wiring, the turn loop, workspace disposal, and
337
+ * status transitions.
338
+ *
339
+ * Execution is supplied at construction (mandatory), so run() needs no
340
+ * "not configured" guards. The returned promise always resolves (errors are
341
+ * captured internally).
342
+ */
343
+ async run(): Promise<void> {
344
+ this.markRunning(Date.now());
345
+ this.execution.observer?.onStarted?.(this);
346
+ this.listeners.wireSignal(this.execution.signal, () => this.abort());
347
+
348
+ const runConfig = this.execution.getRunConfig?.();
349
+ try {
350
+ this.subagentSession = await this.prepareSession(runConfig);
351
+ } catch (err) {
352
+ // A cancelled selection stops the record (the user declined, the run
353
+ // aborted, or the lease closed); every other prepare failure is an error.
354
+ // Either way the terminal funnel owns the cleanup.
355
+ if (isSelectionCancellation(err)) this.stopRunForCancelledSelection();
356
+ else this.failRun(err);
357
+ return;
358
+ }
359
+
360
+ this.flushPendingSteers();
361
+ this.listeners.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
362
+ onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
363
+ }));
364
+ this.execution.observer?.onSessionCreated?.(this);
365
+
366
+ try {
367
+ const result = await this.subagentSession.runTurnLoop(this.execution.prompt, {
368
+ maxTurns: this.execution.maxTurns,
369
+ defaultMaxTurns: runConfig?.defaultMaxTurns,
370
+ graceTurns: runConfig?.graceTurns,
371
+ signal: this.abortController.signal,
372
+ });
373
+ this.completeRun(result);
374
+ } catch (err) {
375
+ this.failRun(err);
376
+ }
377
+ }
378
+
379
+ /** The selection gate this run passes through, or undefined on the ordinary path. */
380
+ private openSelectionGate(): { provider: SpawnSelectionProvider; signal: AbortSignal } | undefined {
381
+ const scope = this.execution.selectionScope;
382
+ if (!scope) return undefined;
383
+ const provider = scope.activeSelectionProvider();
384
+ if (!provider) return undefined;
385
+ return {
386
+ provider,
387
+ // The run's own abort and the handle's closure (its shutdown or the
388
+ // root's revocation) invalidate the selection together.
389
+ signal: AbortSignal.any([this.abortController.signal, scope.closureSignal]),
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Ask the scope's provider for the pair this run will use, and validate its
395
+ * answer against the spawning session's authenticated catalogue.
396
+ *
397
+ * `undefined` from the provider is user cancellation — a stop, not an error.
398
+ * Catalogue and validation failures throw and fail the run: a gated run
399
+ * never falls back to the resolved or inherited pair.
400
+ */
401
+ private async obtainSelection(gate: {
402
+ provider: SpawnSelectionProvider;
403
+ signal: AbortSignal;
404
+ }): Promise<ValidatedSpawnSelection> {
405
+ const registry = this.execution.snapshot.modelRegistry;
406
+ const choices = readSelectionChoices(registry);
407
+ const outcome = await gate.provider.select(
408
+ {
409
+ agentId: this.id,
410
+ agentType: this.type,
411
+ description: this.description,
412
+ availableModels: choices,
413
+ },
414
+ gate.signal,
415
+ );
416
+ if (outcome === undefined) {
417
+ throw new SelectionCancelledError();
418
+ }
419
+ return validateSpawnSelection(outcome, choices, registry);
420
+ }
421
+
422
+ /** Recheck after an await: an aborted run or closed lease must not reach the factory. */
423
+ private assertSelectionLive(signal: AbortSignal): void {
424
+ if (signal.aborted) throw new SelectionCancelledError();
425
+ }
426
+
427
+ /**
428
+ * Prepare the run's child session: the selection gate (when the root lease
429
+ * is active), workspace preparation (provider path only), and the
430
+ * assembly-factory call — every side effect before the child session
431
+ * exists, in order. Returns the born-complete session; a failure throws
432
+ * after no partial state survives (a throwing prepare leaves no workspace
433
+ * bracketed, a throwing factory disposes its own session).
434
+ *
435
+ * The hasProvider() guard keeps the no-provider path synchronous, preserving
436
+ * the original run() timing: the factory is called in the same turn as
437
+ * spawn() when no workspace provider is registered. The gate is likewise
438
+ * absent unless the scope holds an active provider, so an unconfigured or
439
+ * revoked lease changes nothing on this path.
440
+ */
441
+ private async prepareSession(runConfig: RunConfig | undefined): Promise<SubagentSession> {
442
+ const gate = this.openSelectionGate();
443
+ let selected: ValidatedSpawnSelection | undefined;
444
+ if (gate) {
445
+ this.state.markAwaitingSelection();
446
+ try {
447
+ selected = await this.obtainSelection(gate);
448
+ } finally {
449
+ this.state.clearAwaitingSelection();
450
+ }
451
+ // The chooser finished, but the run may have aborted or the lease
452
+ // closed while it was open.
453
+ this.assertSelectionLive(gate.signal);
454
+ }
455
+ let cwd: string | undefined;
456
+ if (this.workspaceBracket.hasProvider()) {
457
+ cwd = await this.workspaceBracket.prepare({
458
+ agentId: this.id,
459
+ agentType: this.type,
460
+ baseCwd: this.execution.baseCwd,
461
+ });
462
+ }
463
+ // Immediately before the factory call: the lease may have closed while
464
+ // the workspace was being prepared.
465
+ if (gate) this.assertSelectionLive(gate.signal);
466
+ return this.execution.createSubagentSession({
467
+ snapshot: this.execution.snapshot,
468
+ type: this.type,
469
+ cwd,
470
+ parentSession: this.execution.parentSession,
471
+ model: selected?.model ?? this.execution.model,
472
+ thinkingLevel: selected?.thinkingLevel ?? this.execution.thinkingLevel,
473
+ ...(gate ? { selectionSignal: gate.signal } : {}),
474
+ askParent: (question) => { this.state.setPendingQuestion(question); },
475
+ notifyParent: this.canSendUpdates(runConfig)
476
+ ? (message) => { this.announceUpdate(message); }
477
+ : undefined,
478
+ });
479
+ }
480
+
481
+ /**
482
+ * Whether this run gets the mid-run update channel.
483
+ *
484
+ * The operator's setting is the whole gate: where an update lands is decided
485
+ * per message by announceUpdate(), not per child at session creation, so no
486
+ * child has to be refused the tool for a condition that can change mid-run.
487
+ * Defaults to on when no run config is supplied, matching the setting.
488
+ */
489
+ private canSendUpdates(runConfig: RunConfig | undefined): boolean {
490
+ return runConfig?.midRunUpdates ?? true;
491
+ }
492
+
493
+ /**
494
+ * Record an update the child sent, then offer it to the announcement channel.
495
+ *
496
+ * Every update joins the run's ledger, whoever ends up delivering it: this
497
+ * side cannot know whether an announcement will reach the parent in time, or
498
+ * at all, so it records unconditionally and lets the channel that delivers
499
+ * mark what it took. What the ledger still owes is what an outcome carrier
500
+ * renders alongside the result.
501
+ *
502
+ * The observer is told either way: an update is a fact about the run, like
503
+ * the terminal transitions, so the lifecycle event fires regardless of which
504
+ * carrier delivers it.
505
+ */
506
+ private announceUpdate(message: string): void {
507
+ this.state.recordUpdate(message);
508
+ this.execution.observer?.onUpdateSent?.(this, message);
509
+ }
510
+
511
+ /**
512
+ * Start execution immediately (foreground / bypassQueue paths).
513
+ * Stores the run promise so it is awaitable via the `promise` getter.
514
+ */
515
+ start(): void {
516
+ this._promise = this.guardedRun();
517
+ }
518
+
519
+ /**
520
+ * Schedule execution through an external concurrency scheduler (the limiter).
521
+ * Captures the scheduler's promise eagerly, so a still-queued agent is
522
+ * awaitable via the `promise` getter from spawn — not only once its slot opens.
523
+ * The guard in guardedRun() makes an abort-while-queued run a no-op when the
524
+ * slot finally frees.
525
+ */
526
+ scheduleVia(schedule: (thunk: () => Promise<void>) => Promise<void>): void {
527
+ this._promise = schedule(() => this.guardedRun());
528
+ }
529
+
530
+ /**
531
+ * Run unless the agent left the active set before its slot opened
532
+ * (e.g. abort-while-queued): a non-queued, non-running status resolves
533
+ * immediately without running.
534
+ */
535
+ private guardedRun(): Promise<void> {
536
+ if (!this.isActive()) return Promise.resolve();
537
+ return this.run();
538
+ }
539
+
540
+ /**
541
+ * Wait until this agent's current run settles.
542
+ * Resolves immediately when the agent is no longer active or has no run
543
+ * handle. A queued agent is awaitable because scheduleVia() captures the
544
+ * limiter promise at spawn, so the wait spans both the queue slot and the
545
+ * run that follows it.
546
+ *
547
+ * When `signal` fires the wait ends early and the agent keeps running: this
548
+ * is a query, so interrupting it must not cancel the work. Cancelling the
549
+ * work on a parent interrupt is InterruptHandler's separate decision.
550
+ */
551
+ async waitUntilSettled(signal: AbortSignal): Promise<void> {
552
+ const run = this._promise;
553
+ if (!run || !this.isActive()) return;
554
+ await settleOrAbort(run, signal);
555
+ }
556
+
557
+ /**
558
+ * Resume an existing session with a new prompt, managing the observer
559
+ * subscription lifecycle internally (same wiring as run()).
560
+ *
561
+ * Requires an existing SubagentSession (set when the original run created it).
562
+ * The returned promise always resolves (errors are captured internally) and is
563
+ * published as the `promise` getter, so waiters track the resume rather than
564
+ * the settled handle of the original run.
565
+ * The parent signal flows straight through to resumeTurnLoop — resume does not
566
+ * route through this.abortController.
567
+ */
568
+ resume(prompt: string, signal?: AbortSignal): Promise<void> {
569
+ const subagentSession = this.subagentSession;
570
+ if (!subagentSession) {
571
+ // Rejection, not a throw: this method is not async, and a synchronous
572
+ // throw would escape a caller's `.rejects` assertion.
573
+ return Promise.reject(new Error("Subagent not configured for resume — missing session"));
574
+ }
575
+
576
+ this._promise = this.runResume(subagentSession, prompt, signal);
577
+ return this._promise;
578
+ }
579
+
580
+ /** The resume body. Always resolves — errors terminate through failResume(). */
581
+ private async runResume(subagentSession: SubagentSession, prompt: string, signal?: AbortSignal): Promise<void> {
582
+ this.resetForResume(Date.now());
583
+ this.execution.observer?.onResumeStarted?.(this);
584
+ this.listeners.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
585
+ onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
586
+ }));
587
+
588
+ try {
589
+ this.completeResume(await subagentSession.resumeTurnLoop(prompt, signal));
590
+ } catch (err) {
591
+ this.failResume(err);
592
+ }
593
+ }
594
+
595
+ /** Terminate a resume as completed: mark, dispose or hold the workspace, release listeners, notify observer. */
596
+ completeResume(result: string): void {
597
+ // A child answering one question may need to ask another, which holds the
598
+ // workspace for the next resume the same way the original run did.
599
+ const finalResult = this.pendingQuestion !== undefined
600
+ ? result
601
+ : result + this.workspaceBracket.dispose({ status: "completed", description: this.description });
602
+ this.markCompleted(finalResult);
603
+ this.listeners.release();
604
+ this.execution.observer?.onResumeFinished?.(this);
605
+ }
606
+
607
+ /** Terminate a resume as errored: mark, release listeners, best-effort workspace dispose, notify observer. */
608
+ failResume(err: unknown): void {
609
+ this.markError(err);
610
+ this.clearPendingQuestion();
611
+ this.listeners.release();
612
+ this.disposeWorkspaceQuietly("error");
613
+ this.execution.observer?.onResumeFinished?.(this);
614
+ }
615
+
616
+ /** Transition to running state. Sets status and startedAt. */
617
+ markRunning(startedAt: number): void {
618
+ this.state.markRunning(startedAt);
619
+ }
620
+
621
+ /**
622
+ * Transition to completed state.
623
+ * Always sets result and completedAt (??=). Only changes status if not stopped.
624
+ */
625
+ markCompleted(result: string, completedAt?: number): void {
626
+ this.state.markCompleted(result, completedAt);
627
+ }
628
+
629
+ /**
630
+ * Transition to aborted state.
631
+ * Always sets result and completedAt (??=). Only changes status if not stopped.
632
+ */
633
+ markAborted(result: string, completedAt?: number): void {
634
+ this.state.markAborted(result, completedAt);
635
+ }
636
+
637
+ /**
638
+ * Transition to steered state.
639
+ * Always sets result and completedAt (??=). Only changes status if not stopped.
640
+ */
641
+ markSteered(result: string, completedAt?: number): void {
642
+ this.state.markSteered(result, completedAt);
643
+ }
644
+
645
+ /**
646
+ * Transition to error state.
647
+ * Always sets error (formatted) and completedAt (??=). Only changes status if not stopped.
648
+ */
649
+ markError(error: unknown, completedAt?: number): void {
650
+ this.state.markError(error, completedAt);
651
+ }
652
+
653
+ /** Transition to stopped state. Always valid — no guard. */
654
+ markStopped(completedAt?: number): void {
655
+ this.state.markStopped(completedAt);
656
+ }
657
+
658
+ /** Record the parent collected this agent's outcome. Idempotent. */
659
+ markConsumed(at?: number): void {
660
+ this.state.markConsumed(at);
661
+ }
662
+
663
+ /** The announcement channel delivered this update; no outcome carrier repeats it. */
664
+ markUpdateAnnounced(message: string): void {
665
+ this.state.markUpdateAnnounced(message);
666
+ }
667
+
668
+ /** A carrier has committed to delivering this outcome; nothing else announces it. */
669
+ claim(): void {
670
+ this.state.claim();
671
+ }
672
+
673
+ /** The carrier abandoned its commitment; announcing is owed again. */
674
+ // Called on the `Subagent` returned by `getRecord()` from get-result-tool.ts
675
+ // and agent-tool.ts, both of which declare it through their own structural
676
+ // interface — fallow cannot trace through interfaces, and reaches this only
677
+ // through the release-then-announce test.
678
+ release(): void {
679
+ this.state.release();
680
+ }
681
+
682
+ /**
683
+ * Stop an agent that never started, then notify like every other terminal
684
+ * transition. No listener release: nothing is wired before run().
685
+ * The record leaves the active set here, so the thunk the limiter runs when
686
+ * the slot finally frees no-ops on guardedRun()'s guard — one notification.
687
+ */
688
+ stopQueued(): void {
689
+ this.state.stopQueued();
690
+ this.execution.observer?.onRunFinished?.(this);
691
+ }
692
+
693
+ /**
694
+ * Abort a running agent: fire AbortController and transition to stopped.
695
+ * Returns false if the agent is not running.
696
+ * A still-queued agent is stopped via stopQueued(); its scheduled thunk
697
+ * then no-ops on the queued-status guard.
698
+ */
699
+ abort(): boolean {
700
+ if (!this.isRunning()) return false;
701
+ this.abortController.abort();
702
+ this.markStopped();
703
+ return true;
704
+ }
705
+
706
+ /**
707
+ * Buffer a steer message for delivery once the session is ready.
708
+ * Called internally from steer() before the session is ready.
709
+ */
710
+ private queueSteer(message: string): void {
711
+ this._pendingSteers.push(message);
712
+ }
713
+
714
+ /**
715
+ * Flush all buffered steer messages to the session and clear the buffer.
716
+ * Called once the session is available (inside run()).
717
+ */
718
+ private flushPendingSteers(): void {
719
+ for (const msg of this._pendingSteers) {
720
+ this.subagentSession?.steer(msg).catch(() => {});
721
+ }
722
+ this._pendingSteers = [];
723
+ }
724
+
725
+ /** Reset for resume: running status, new startedAt, clear completedAt/result/error/consumedAt/listeners. */
726
+ resetForResume(startedAt: number): void {
727
+ this.state.resetForResume(startedAt);
728
+ this.listeners.release();
729
+ }
730
+
731
+ /** Complete a run: release listeners, dispose the workspace, status transition, notify observer. */
732
+ completeRun(result: TurnLoopResult): void {
733
+ this.listeners.release();
734
+
735
+ const finalStatus: SubagentStatus = result.aborted
736
+ ? "aborted"
737
+ : result.steered
738
+ ? "steered"
739
+ : "completed";
740
+ // A completed child that declared a question is inviting a resume, so its
741
+ // workspace stays live for the resume to re-enter. Every other outcome ends
742
+ // the run for good and tears it down here. The question was recorded by
743
+ // ask_parent during the run, so it is already on the record here.
744
+ const holdForResume = finalStatus === "completed" && this.pendingQuestion !== undefined;
745
+ const finalResult = holdForResume
746
+ ? result.responseText
747
+ : result.responseText +
748
+ this.workspaceBracket.dispose({ status: finalStatus, description: this.description });
749
+
750
+ if (result.aborted) this.markAborted(finalResult);
751
+ else if (result.steered) this.markSteered(finalResult);
752
+ else this.markCompleted(finalResult);
753
+
754
+ this.execution.observer?.onRunFinished?.(this);
755
+ }
756
+
757
+ /**
758
+ * Dispose the wrapped session, firing the `disposed` lifecycle event.
759
+ * Resolves once the child's extensions have shut down; a failing teardown is
760
+ * swallowed so the caller's remaining cleanup still runs.
761
+ */
762
+ async disposeSession(): Promise<void> {
763
+ this.disposeHeldWorkspace();
764
+ await disposeQuietly(this.subagentSession, "child session dispose");
765
+ }
766
+
767
+ /**
768
+ * Release the heavy session while keeping the record: capture the transcript
769
+ * pointer, dispose the session (firing `disposed`), clear it, and mark released.
770
+ * A no-op once the session is gone — the retention sweep may call it repeatedly.
771
+ *
772
+ * The record's own state is updated before the teardown is awaited, so a sweep
773
+ * tick arriving mid-teardown sees a released record rather than starting a
774
+ * second one.
775
+ */
776
+ async releaseSession(): Promise<void> {
777
+ const session = this.subagentSession;
778
+ if (!session) return;
779
+ this.disposeHeldWorkspace();
780
+ this._releasedOutputFile = session.outputFile;
781
+ this.subagentSession = undefined;
782
+ this._sessionReleased = true;
783
+ await disposeQuietly(session, "child session release");
784
+ }
785
+
786
+ /** Fail a run: mark error, release listeners, best-effort workspace dispose, notify observer. */
787
+ failRun(err: unknown): void {
788
+ this.markError(err);
789
+ this.clearPendingQuestion();
790
+ this.listeners.release();
791
+ this.disposeWorkspaceQuietly("error");
792
+ this.execution.observer?.onRunFinished?.(this);
793
+ }
794
+
795
+ /**
796
+ * Terminate a run whose selection was cancelled — the user closed the
797
+ * dialog, the run aborted, or the lease closed. A cancellation is a stop,
798
+ * not an error: the record ends `stopped`, a prepared workspace (if the
799
+ * cancellation landed after preparation) is torn down, and the terminal
800
+ * observer fires exactly once, like every other funnel.
801
+ */
802
+ private stopRunForCancelledSelection(): void {
803
+ this.abortController.abort();
804
+ this.markStopped();
805
+ this.listeners.release();
806
+ this.disposeWorkspaceQuietly("stopped");
807
+ this.execution.observer?.onRunFinished?.(this);
808
+ }
809
+
810
+ /**
811
+ * Drop a question the child recorded before the run failed.
812
+ *
813
+ * Every carrier renders a pending question as "answer by resuming me", which
814
+ * is not the right next action after a failure — and the failure text already
815
+ * tells the parent to look. An aborted or steered run keeps its question:
816
+ * those reached a terminal transition with an outcome to report.
817
+ */
818
+ private clearPendingQuestion(): void {
819
+ this.state.setPendingQuestion(undefined);
820
+ }
821
+
822
+ /**
823
+ * Tear down a workspace still held once the agent's run is over — the child
824
+ * asked a question nobody answered, and its session is now going away.
825
+ *
826
+ * A no-op while the agent is active: an in-flight run's own terminal
827
+ * transition owns disposal, and pulling the directory out from under a live
828
+ * child is not this path's business.
829
+ */
830
+ private disposeHeldWorkspace(): void {
831
+ if (this.isActive()) return;
832
+ // Announce what *this* disposal produced, not what the record holds: both
833
+ // release and teardown reach here, and the second finds nothing to dispose.
834
+ const notice = this.disposeWorkspaceQuietly(this.status);
835
+ if (notice) this.execution.observer?.onWorkspaceNotice?.(this, notice);
836
+ }
837
+
838
+ /**
839
+ * Dispose the workspace without letting a provider failure escape, recording
840
+ * what it reported and handing that back.
841
+ *
842
+ * These are the paths with no result text left to fold the addendum into, so
843
+ * it is kept on the record for the carriers to report instead. The value is
844
+ * returned as well as stored, so a caller can tell an addendum this call
845
+ * produced from one an earlier disposal already recorded.
846
+ */
847
+ private disposeWorkspaceQuietly(status: SubagentStatus): string {
848
+ try {
849
+ const notice = this.workspaceBracket.dispose({ status, description: this.description });
850
+ if (notice) this.state.setWorkspaceNotice(notice);
851
+ return notice;
852
+ } catch (err) { debugLog(`workspace dispose (${status})`, err); return ""; }
853
+ }
854
+ }
855
+
856
+ /**
857
+ * Tear a child session down without letting its failure escape.
858
+ * Both teardown paths are cleanup: a child that will not shut down cleanly must
859
+ * not stop the caller from finishing the rest of its own cleanup.
860
+ */
861
+ async function disposeQuietly(
862
+ session: SubagentSession | undefined,
863
+ context: string,
864
+ ): Promise<void> {
865
+ try {
866
+ await session?.dispose();
867
+ } catch (err) {
868
+ debugLog(context, err);
869
+ }
870
+ }
871
+
872
+ /**
873
+ * Settle with `run`, or early when `signal` fires — whichever comes first.
874
+ * The inner controller is the listener-cleanup channel: it detaches the abort
875
+ * listener whichever branch wins, so repeated waits within one parent turn do
876
+ * not accumulate listeners on that turn's signal.
877
+ */
878
+ function settleOrAbort(run: Promise<void>, signal: AbortSignal): Promise<void> {
879
+ if (signal.aborted) return Promise.resolve();
880
+ const detach = new AbortController();
881
+ const interrupted = new Promise<void>((resolve) => {
882
+ signal.addEventListener("abort", () => { resolve(); }, { once: true, signal: detach.signal });
883
+ });
884
+ return Promise.race([run, interrupted]).finally(() => { detach.abort(); });
885
+ }