@yefengr/pi-subagents 0.0.0-stage → 0.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.
Files changed (103) hide show
  1. package/CHANGELOG.md +2411 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -2
  4. package/UPSTREAM.md +48 -0
  5. package/dist/public.d.ts +201 -0
  6. package/dist/settings.d.ts +82 -0
  7. package/docs/architecture/architecture.md +833 -0
  8. package/docs/architecture/client-server-opportunities.md +127 -0
  9. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  10. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  11. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  12. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  13. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  14. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  15. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  16. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  17. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  18. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  19. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  20. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  21. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  22. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  23. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  24. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  25. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  26. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  27. package/docs/architecture/history/phase-8-testability.md +103 -0
  28. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  29. package/docs/comparison-with-upstream.md +79 -0
  30. package/docs/configuration.md +215 -0
  31. package/docs/decisions/0001-deferred-patches.md +80 -0
  32. package/docs/decisions/0002-extensions-on-a-minimal-core.md +114 -0
  33. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  34. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  35. package/index.ts +1 -0
  36. package/package.json +91 -4
  37. package/src/config/agent-enablement.ts +132 -0
  38. package/src/config/agent-types.ts +144 -0
  39. package/src/config/custom-agents.ts +114 -0
  40. package/src/config/default-agents.ts +120 -0
  41. package/src/config/invocation-config.ts +39 -0
  42. package/src/config/resource-limits.ts +26 -0
  43. package/src/config/settings-file.ts +174 -0
  44. package/src/debug.ts +14 -0
  45. package/src/handlers/index.ts +3 -0
  46. package/src/handlers/interrupt.ts +58 -0
  47. package/src/handlers/lifecycle.ts +65 -0
  48. package/src/handlers/tool-start.ts +32 -0
  49. package/src/index.ts +257 -0
  50. package/src/layered-settings.ts +105 -0
  51. package/src/lifecycle/child-lifecycle.ts +89 -0
  52. package/src/lifecycle/child-shutdown.ts +105 -0
  53. package/src/lifecycle/concurrency-limiter.ts +123 -0
  54. package/src/lifecycle/create-subagent-session.ts +265 -0
  55. package/src/lifecycle/invocation-control.ts +109 -0
  56. package/src/lifecycle/lifecycle-record.ts +159 -0
  57. package/src/lifecycle/parent-snapshot.ts +46 -0
  58. package/src/lifecycle/run-listeners.ts +41 -0
  59. package/src/lifecycle/subagent-manager.ts +392 -0
  60. package/src/lifecycle/subagent-session.ts +287 -0
  61. package/src/lifecycle/subagent-state.ts +307 -0
  62. package/src/lifecycle/subagent.ts +854 -0
  63. package/src/lifecycle/usage.ts +60 -0
  64. package/src/lifecycle/workspace-bracket.ts +59 -0
  65. package/src/lifecycle/workspace.ts +47 -0
  66. package/src/observation/composite-subagent-observer.ts +53 -0
  67. package/src/observation/notification.ts +233 -0
  68. package/src/observation/record-observer.ts +78 -0
  69. package/src/observation/renderer.ts +106 -0
  70. package/src/observation/subagent-events-observer.ts +75 -0
  71. package/src/runtime.ts +78 -0
  72. package/src/service/service-adapter.ts +159 -0
  73. package/src/service/service.ts +147 -0
  74. package/src/session/content-items.ts +53 -0
  75. package/src/session/context.ts +80 -0
  76. package/src/session/conversation.ts +49 -0
  77. package/src/session/env.ts +40 -0
  78. package/src/session/model-resolver.ts +135 -0
  79. package/src/session/package-exclusions.ts +75 -0
  80. package/src/session/prompts.ts +145 -0
  81. package/src/session/provider-inheritance.ts +38 -0
  82. package/src/session/session-config.ts +177 -0
  83. package/src/session/session-dir.ts +38 -0
  84. package/src/settings.ts +436 -0
  85. package/src/tools/agent-tool.ts +289 -0
  86. package/src/tools/background-spawner.ts +73 -0
  87. package/src/tools/foreground-runner.ts +135 -0
  88. package/src/tools/get-result-report.ts +73 -0
  89. package/src/tools/get-result-tool.ts +109 -0
  90. package/src/tools/helpers.ts +134 -0
  91. package/src/tools/result-renderer.ts +121 -0
  92. package/src/tools/spawn-config.ts +182 -0
  93. package/src/tools/steer-tool.ts +120 -0
  94. package/src/types.ts +145 -0
  95. package/src/ui/agent-widget.ts +313 -0
  96. package/src/ui/display.ts +214 -0
  97. package/src/ui/glyphs.ts +62 -0
  98. package/src/ui/session-navigation.ts +152 -0
  99. package/src/ui/session-navigator.ts +257 -0
  100. package/src/ui/subagents-agents.ts +116 -0
  101. package/src/ui/subagents-settings.ts +207 -0
  102. package/src/ui/transcript-content.ts +374 -0
  103. package/src/ui/widget-renderer.ts +289 -0
@@ -0,0 +1,854 @@
1
+ /**
2
+ * subagent.ts — Subagent class: identity, lifecycle phase/outcome, and per-subagent behavior.
3
+ *
4
+ * Phase/outcome and 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 {
14
+ InvocationControl,
15
+ isInvocationCancelled,
16
+ isInvocationTimeout,
17
+ } from "#src/lifecycle/invocation-control";
18
+ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
19
+ import { RunListeners } from "#src/lifecycle/run-listeners";
20
+ import type { SubagentSession, TurnLoopResult } from "#src/lifecycle/subagent-session";
21
+ import {
22
+ type SubagentOutcome,
23
+ type SubagentPhase,
24
+ SubagentState,
25
+ } from "#src/lifecycle/subagent-state";
26
+ import type { LifetimeUsage } from "#src/lifecycle/usage";
27
+ import type { WorkspaceProvider } from "#src/lifecycle/workspace";
28
+ import { WorkspaceBracket } from "#src/lifecycle/workspace-bracket";
29
+ import { subscribeSubagentObserver } from "#src/observation/record-observer";
30
+ import type { RunConfig } from "#src/runtime";
31
+ import type {
32
+ AgentInvocation,
33
+ CompactionInfo,
34
+ ParentSessionInfo,
35
+ SessionMessage,
36
+ SubagentInvocationIdentity,
37
+ SubagentSessionIdentity,
38
+ SubagentType,
39
+ ThinkingLevel,
40
+ } from "#src/types";
41
+
42
+ /** Per-subagent lifecycle observer — created by SubagentManager for each spawn. */
43
+ export interface SubagentLifecycleObserver {
44
+ /** Fires when the subagent transitions to running (inside run(), after markRunning). */
45
+ onStarted?(agent: Subagent): void;
46
+ /** Fires once the session is created — the subagent's subagentSession is now available. */
47
+ onSessionCreated?(agent: Subagent): void;
48
+ /** Fires once when the run completes or fails (for concurrency drain). */
49
+ onRunFinished?(agent: Subagent): void;
50
+ /** Fires once when a resumed run reaches a terminal state. */
51
+ onResumeFinished?(agent: Subagent): void;
52
+ /** Fires on compaction events during the run. */
53
+ onCompacted?(agent: Subagent, info: CompactionInfo): void;
54
+ }
55
+
56
+ export type { SubagentOutcome, SubagentPhase } from "#src/lifecycle/subagent-state";
57
+
58
+ /**
59
+ * The result of a steer attempt. `Subagent.steer` owns the non-running
60
+ * rejection rule and reports it here, so coordinators switch on the outcome
61
+ * instead of pre-checking phase (tell by id, with outcomes).
62
+ */
63
+ export type SteerOutcome =
64
+ | { kind: "delivered" }
65
+ | { kind: "buffered" }
66
+ | { kind: "rejected"; phase: SubagentPhase; outcome?: SubagentOutcome };
67
+
68
+ /**
69
+ * The execution machinery a Subagent needs to run. A single mandatory
70
+ * collaborator: production (SubagentManager.spawn) always supplies it, so run()
71
+ * needs no "not configured" guards. The genuinely-optional behavior knobs stay
72
+ * optional; the four inputs run() cannot proceed without are required.
73
+ */
74
+ export interface SubagentExecution {
75
+ /** Assembly factory that produces a born-complete SubagentSession. */
76
+ createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
77
+ /** Immutable spawn-time parent snapshot handed to the session factory. */
78
+ snapshot: ParentSnapshot;
79
+ /** Initial prompt for the turn loop. */
80
+ prompt: string;
81
+ /** Parent working directory handed to a workspace provider's prepare(). */
82
+ baseCwd: string;
83
+ observer?: SubagentLifecycleObserver;
84
+ getRunConfig?: () => RunConfig;
85
+ /** Resolves the registered workspace provider (if any) at run-start. */
86
+ getWorkspaceProvider?: () => WorkspaceProvider | undefined;
87
+ model?: Model<any>;
88
+ maxTurns?: number;
89
+ wrapUpTurns?: number;
90
+ maxDurationSeconds?: number;
91
+ thinkingLevel?: ThinkingLevel;
92
+ parentSession?: ParentSessionInfo;
93
+ signal?: AbortSignal;
94
+ }
95
+
96
+ export interface InvocationLimitOverrides {
97
+ maxTurns?: number;
98
+ wrapUpTurns?: number;
99
+ maxDurationSeconds?: number;
100
+ }
101
+
102
+ export interface ResolvedInvocationLimits {
103
+ maxTurns: number;
104
+ wrapUpTurns: number;
105
+ maxDurationSeconds: number;
106
+ abortGraceSeconds: number;
107
+ }
108
+
109
+ export interface SubagentInit {
110
+ // Identity
111
+ id: string;
112
+ type: SubagentType;
113
+ description: string;
114
+ invocation?: AgentInvocation;
115
+ /** Whether the fresh invocation was dispatched asynchronously. */
116
+ isBackground?: boolean;
117
+ /** Lifecycle identity for the fresh invocation. */
118
+ lifecycleInvocation?: SubagentInvocationIdentity;
119
+ /** Optional stable session identity for hydrated records and test fixtures. */
120
+ sessionIdentity?: SubagentSessionIdentity;
121
+
122
+ /** Execution machinery — always supplied; construct-complete, no test fallbacks. */
123
+ execution: SubagentExecution;
124
+
125
+ /** Lifecycle phase/outcome and metrics. Defaults to a fresh queued state. */
126
+ state?: SubagentState;
127
+ }
128
+
129
+ export class Subagent {
130
+ // Identity — set once at construction
131
+ readonly id: string;
132
+ readonly type: SubagentType;
133
+ readonly description: string;
134
+ readonly invocation?: AgentInvocation;
135
+ private _isBackground: boolean;
136
+ get isBackground(): boolean { return this._isBackground; }
137
+ private _lifecycleInvocation: SubagentInvocationIdentity;
138
+ private _invocationIndex = 1;
139
+ private _sessionIdentity?: SubagentSessionIdentity;
140
+ get lifecycleInvocation(): Readonly<SubagentInvocationIdentity> { return this._lifecycleInvocation; }
141
+ get invocationIndex(): number { return this._invocationIndex; }
142
+ get sessionId(): string | undefined {
143
+ return this._sessionIdentity?.sessionId ?? this.subagentSession?.sessionId;
144
+ }
145
+ get sessionState(): "created" | "not-created" {
146
+ return this.sessionId ? "created" : "not-created";
147
+ }
148
+
149
+ // Lifecycle phase/outcome and metrics — owned by a private value object; getters and
150
+ // mutation methods below delegate to it one line.
151
+ private readonly state: SubagentState;
152
+ get phase(): SubagentPhase { return this.state.phase; }
153
+ get outcome(): SubagentOutcome | undefined { return this.state.outcome; }
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 toolUses(): number { return this.state.toolUses; }
162
+ get lifetimeUsage(): Readonly<LifetimeUsage> { return this.state.lifetimeUsage; }
163
+ get invocationStartedAt(): number { return this.state.invocationStartedAt; }
164
+ get invocationToolUses(): number { return this.state.invocationToolUses; }
165
+ get invocationUsage(): Readonly<LifetimeUsage> { return this.state.invocationUsage; }
166
+ get compactionCount(): number { return this.state.compactionCount; }
167
+ get invocationTurnCount(): number { return this.state.invocationTurnCount; }
168
+ get lifetimeTurnCount(): number { return this.state.lifetimeTurnCount; }
169
+ get activeTools(): ReadonlyMap<string, string> { return this.state.activeTools; }
170
+ get responseText(): string { return this.state.responseText; }
171
+ isActive(): boolean { return this.state.isActive(); }
172
+ isTerminalError(): boolean { return this.state.isTerminalError(); }
173
+ isRunning(): boolean { return this.state.isRunning(); }
174
+ canBeSteered(): boolean { return this.state.canBeSteered(); }
175
+ get maxTurns(): number { return this.currentLimits?.maxTurns ?? this.resolveLimits().maxTurns; }
176
+ get wrapUpTurns(): number { return this.currentLimits?.wrapUpTurns ?? this.resolveLimits().wrapUpTurns; }
177
+ get maxDurationSeconds(): number {
178
+ return this.currentLimits?.maxDurationSeconds ?? this.resolveLimits().maxDurationSeconds;
179
+ }
180
+
181
+ private abortController = new AbortController();
182
+ private currentLimits?: ResolvedInvocationLimits;
183
+ /** True until the current invocation has released its caller, including abort grace. */
184
+ private invocationInFlight = false;
185
+ /** True until the underlying child turn loop settles, including any late settlement after grace. */
186
+ private turnLoopInFlight = false;
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
+ // Stable session identity is retained after releaseSession() disposes the
198
+ // heavy session so transcript attribution never depends on a live SDK object.
199
+ private _sessionReleased = false;
200
+ /** True once releaseSession() has freed a live session (distinct from never having had one). */
201
+ get sessionReleased(): boolean { return this._sessionReleased; }
202
+
203
+ // Steer buffer — messages queued before the session is ready
204
+ private _pendingSteers: string[] = [];
205
+ /** Number of steer messages waiting to be delivered. */
206
+ get pendingSteerCount(): number { return this._pendingSteers.length; }
207
+
208
+ /**
209
+ * Path to the agent's session JSONL file, or undefined if not yet available.
210
+ * Falls back to the path captured at releaseSession() once the live session is gone.
211
+ */
212
+ get outputFile(): string | undefined {
213
+ return this.subagentSession?.outputFile ?? this._sessionIdentity?.outputFile;
214
+ }
215
+
216
+ /** The parent tool call driving the current fresh/resume invocation. */
217
+ get toolCallId(): string | undefined {
218
+ return this._lifecycleInvocation.toolCallId;
219
+ }
220
+
221
+ /** Returns true when a SubagentSession is available (session is ready). */
222
+ isSessionReady(): boolean {
223
+ return this.subagentSession != null;
224
+ }
225
+
226
+ /**
227
+ * Steer a running agent, owning the non-running rejection rule.
228
+ * Returns a `rejected` outcome (with the observed phase/outcome) when the agent is
229
+ * not running, a `buffered` outcome when the session is not yet ready, or a
230
+ * `delivered` outcome once the message reaches the session.
231
+ */
232
+ async steer(message: string): Promise<SteerOutcome> {
233
+ if (!this.canBeSteered()) {
234
+ return {
235
+ kind: "rejected",
236
+ phase: this.phase,
237
+ ...(this.outcome ? { outcome: this.outcome } : {}),
238
+ };
239
+ }
240
+ if (!this.subagentSession) {
241
+ this.queueSteer(message);
242
+ return { kind: "buffered" };
243
+ }
244
+ await this.subagentSession.steer(message);
245
+ return { kind: "delivered" };
246
+ }
247
+
248
+ /** Return the session conversation as formatted text, or undefined if no session. */
249
+ getConversation(): string | undefined {
250
+ return this.subagentSession?.getConversation();
251
+ }
252
+
253
+ /** Return the session context window utilization (0-100), or null if unavailable. */
254
+ getContextPercent(): number | null {
255
+ return this.subagentSession?.getContextPercent() ?? null;
256
+ }
257
+
258
+ /**
259
+ * Subscribe to session events for live updates (e.g., conversation viewer).
260
+ * Returns an unsubscribe function, or undefined if no session is available.
261
+ */
262
+ subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined {
263
+ return this.subagentSession?.subscribe(fn);
264
+ }
265
+
266
+ /** The session's message history, or an empty array if no session. */
267
+ get messages(): readonly unknown[] {
268
+ return this.subagentSession?.messages ?? [];
269
+ }
270
+
271
+ /** The session's message history typed for Pi's session-rendering machinery, or empty if no session. */
272
+ get agentMessages(): readonly SessionMessage[] {
273
+ return this.subagentSession?.agentMessages ?? [];
274
+ }
275
+
276
+ /** Resolve a registered tool definition by name, or undefined if no session. */
277
+ getToolDefinition(name: string): ToolDefinition | undefined {
278
+ return this.subagentSession?.getToolDefinition(name);
279
+ }
280
+
281
+ constructor(init: SubagentInit) {
282
+ // Identity
283
+ this.id = init.id;
284
+ this.type = init.type;
285
+ this.description = init.description;
286
+ this.invocation = init.invocation;
287
+ this._isBackground = init.isBackground ?? false;
288
+ this._lifecycleInvocation = normalizeInvocationIdentity(
289
+ init.lifecycleInvocation ?? freshInvocationIdentity(init.execution.parentSession?.toolCallId),
290
+ "fresh",
291
+ );
292
+ const parentToolCallId = init.execution.parentSession?.toolCallId;
293
+ if (parentToolCallId != null && this.toolCallId !== parentToolCallId.trim()) {
294
+ throw new Error("Fresh lifecycle toolCallId contradicts parentSession.toolCallId");
295
+ }
296
+ if (init.sessionIdentity) this.captureSessionIdentity(init.sessionIdentity);
297
+
298
+ // Lifecycle phase/outcome and metrics — fresh queued state unless one is supplied
299
+ this.state = init.state ?? new SubagentState();
300
+
301
+ // Execution machinery — a single mandatory collaborator
302
+ this.execution = init.execution;
303
+
304
+ // Per-run lifecycle collaborators
305
+ this.workspaceBracket = new WorkspaceBracket(
306
+ this.execution.getWorkspaceProvider ?? (() => undefined),
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Execute the full agent lifecycle: workspace preparation, session creation
312
+ * via the factory, observer wiring, the turn loop, workspace disposal, and
313
+ * phase/outcome transitions.
314
+ *
315
+ * Execution is supplied at construction (mandatory), so run() needs no
316
+ * "not configured" guards. The returned promise always resolves (errors are
317
+ * captured internally).
318
+ */
319
+ async run(): Promise<void> {
320
+ this.invocationInFlight = true;
321
+ this.abortController = new AbortController();
322
+ this.currentLimits = this.resolveLimits();
323
+ this.markRunning(Date.now());
324
+ this.execution.observer?.onStarted?.(this);
325
+ this.listeners.wireSignal(this.execution.signal, () => this.abort());
326
+
327
+ const control = this.createInvocationControl(this.currentLimits);
328
+ let preparePromise: Promise<string | undefined> | undefined;
329
+ let createPromise: Promise<SubagentSession> | undefined;
330
+ let stage: "prepare" | "create" | "prompt" = "prepare";
331
+
332
+ try {
333
+ let cwd: string | undefined;
334
+ if (this.workspaceBracket.hasProvider()) {
335
+ preparePromise = this.workspaceBracket.prepare({
336
+ agentId: this.id,
337
+ agentType: this.type,
338
+ baseCwd: this.execution.baseCwd,
339
+ invocation: this.invocation,
340
+ });
341
+ cwd = await control.waitFor(preparePromise);
342
+ }
343
+
344
+ stage = "create";
345
+ createPromise = this.execution.createSubagentSession({
346
+ snapshot: this.execution.snapshot,
347
+ type: this.type,
348
+ cwd,
349
+ parentSession: this.execution.parentSession,
350
+ onSessionCreated: (identity) => this.captureSessionIdentity(identity),
351
+ model: this.execution.model,
352
+ thinkingLevel: this.execution.thinkingLevel,
353
+ });
354
+ this.subagentSession = await control.waitFor(createPromise);
355
+ this.captureSessionIdentity({
356
+ sessionId: this.subagentSession.sessionId,
357
+ outputFile: this.subagentSession.outputFile,
358
+ });
359
+
360
+ stage = "prompt";
361
+ this.flushPendingSteers();
362
+ this.listeners.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
363
+ onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
364
+ }));
365
+ this.execution.observer?.onSessionCreated?.(this);
366
+
367
+ const result = await control.waitFor(this.trackTurnLoop(
368
+ this.subagentSession.runTurnLoop(
369
+ this.execution.prompt,
370
+ this.turnLoopOptions(control),
371
+ ),
372
+ ));
373
+ this.completeRun(result);
374
+ } catch (err) {
375
+ this.handleInvocationFailure(err, false);
376
+ if (stage === "prepare" && preparePromise) this.disposeLateWorkspace(preparePromise);
377
+ if (stage === "create" && createPromise && !this.subagentSession) {
378
+ this.disposeLateSession(createPromise);
379
+ }
380
+ } finally {
381
+ control.dispose();
382
+ this.invocationInFlight = false;
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Start execution immediately (foreground / bypassQueue paths).
388
+ * Stores the run promise so it is awaitable via the `promise` getter.
389
+ */
390
+ start(): void {
391
+ this._promise = this.guardedRun();
392
+ }
393
+
394
+ /**
395
+ * Schedule execution through an external concurrency scheduler (the limiter).
396
+ * Captures the scheduler's promise eagerly, so a still-queued agent is
397
+ * awaitable via the `promise` getter from spawn — not only once its slot opens.
398
+ * The guard in guardedRun() makes an abort-while-queued run a no-op when the
399
+ * slot finally frees.
400
+ */
401
+ scheduleVia(schedule: (thunk: () => Promise<void>) => Promise<void>): void {
402
+ this._promise = schedule(() => this.guardedRun());
403
+ }
404
+
405
+ /**
406
+ * Run unless the agent left the active set before its slot opened
407
+ * (e.g. abort-while-queued): a non-queued, non-running phase resolves
408
+ * immediately without running.
409
+ */
410
+ private guardedRun(): Promise<void> {
411
+ if (!this.isActive()) return Promise.resolve();
412
+ return this.run();
413
+ }
414
+
415
+ /**
416
+ * Wait until this agent's current run settles.
417
+ * Resolves immediately when the agent is no longer active or has no run
418
+ * handle. A queued agent is awaitable because scheduleVia() captures the
419
+ * limiter promise at spawn, so the wait spans both the queue slot and the
420
+ * run that follows it.
421
+ *
422
+ * When `signal` fires the wait ends early and the agent keeps running: this
423
+ * is a query, so interrupting it must not cancel the work. Cancelling the
424
+ * work on a parent interrupt is InterruptHandler's separate decision.
425
+ */
426
+ async waitUntilSettled(signal: AbortSignal): Promise<void> {
427
+ const run = this._promise;
428
+ if (!run || !this.isActive()) return;
429
+ await settleOrAbort(run, signal);
430
+ }
431
+
432
+ /**
433
+ * Resume an existing session with a new prompt, managing the observer
434
+ * subscription lifecycle internally (same wiring as run()).
435
+ *
436
+ * Requires an existing SubagentSession (set when the original run created it).
437
+ * The returned promise always resolves (errors are captured internally) and is
438
+ * published as the `promise` getter, so waiters track the resume rather than
439
+ * the settled handle of the original run.
440
+ * The parent signal flows straight through to resumeTurnLoop — resume does not
441
+ * route through this.abortController.
442
+ */
443
+ resume(
444
+ prompt: string,
445
+ invocation: SubagentInvocationIdentity,
446
+ limits?: InvocationLimitOverrides,
447
+ signal?: AbortSignal,
448
+ ): Promise<void> {
449
+ const subagentSession = this.subagentSession;
450
+ if (!subagentSession) {
451
+ // Rejection, not a throw: this method is not async, and a synchronous
452
+ // throw would escape a caller's `.rejects` assertion.
453
+ return Promise.reject(new Error("Subagent not configured for resume — missing session"));
454
+ }
455
+
456
+ if (this.isActive()) {
457
+ return Promise.reject(new Error(`Cannot resume active subagent ${this.id}`));
458
+ }
459
+ if (this.invocationInFlight || this.turnLoopInFlight) {
460
+ return Promise.reject(
461
+ new Error(`Cannot resume subagent ${this.id}: previous invocation is still settling`),
462
+ );
463
+ }
464
+ let normalizedInvocation: SubagentInvocationIdentity;
465
+ try {
466
+ normalizedInvocation = normalizeInvocationIdentity(invocation, "resume");
467
+ } catch (err) {
468
+ return Promise.reject(err);
469
+ }
470
+ this._lifecycleInvocation = normalizedInvocation;
471
+ this._isBackground = false;
472
+ this._invocationIndex++;
473
+ this._promise = this.runResume(subagentSession, prompt, limits, signal);
474
+ return this._promise;
475
+ }
476
+
477
+ /** The resume body. Always resolves — errors become explicit terminal outcomes. */
478
+ private async runResume(
479
+ subagentSession: SubagentSession,
480
+ prompt: string,
481
+ limits?: InvocationLimitOverrides,
482
+ signal?: AbortSignal,
483
+ ): Promise<void> {
484
+ this.invocationInFlight = true;
485
+ this.abortController = new AbortController();
486
+ this.currentLimits = this.resolveLimits(limits);
487
+ this.resetForResume(Date.now());
488
+ this.listeners.wireSignal(signal, () => this.abort());
489
+ this.listeners.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
490
+ onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
491
+ }));
492
+ const control = this.createInvocationControl(this.currentLimits);
493
+
494
+ try {
495
+ const result = await control.waitFor(
496
+ this.trackTurnLoop(subagentSession.resumeTurnLoop(prompt, this.turnLoopOptions(control))),
497
+ );
498
+ this.completeResume(result);
499
+ } catch (err) {
500
+ this.handleInvocationFailure(err, true);
501
+ } finally {
502
+ control.dispose();
503
+ this.invocationInFlight = false;
504
+ }
505
+ }
506
+
507
+ /** Terminate a resume and publish the current invocation outcome. */
508
+ completeResume(result: TurnLoopResult): void {
509
+ this.applyTurnLoopResult(result, false);
510
+ this.listeners.release();
511
+ this.execution.observer?.onResumeFinished?.(this);
512
+ }
513
+
514
+ /** Terminate a resume as errored. */
515
+ failResume(err: unknown): void {
516
+ this.markError(err);
517
+ this.listeners.release();
518
+ this.execution.observer?.onResumeFinished?.(this);
519
+ }
520
+
521
+ /** Transition to running phase and set startedAt. */
522
+ markRunning(startedAt: number): void {
523
+ this.state.markRunning(startedAt);
524
+ }
525
+
526
+ /**
527
+ * Transition to completed state.
528
+ * Always sets result and completedAt (??=). Only transitions an active phase.
529
+ */
530
+ markCompleted(result: string, completedAt?: number): void {
531
+ this.state.markCompleted(result, completedAt);
532
+ }
533
+
534
+ markTurnLimited(result: string, completedAt?: number): void {
535
+ this.state.markTurnLimited(result, completedAt);
536
+ }
537
+
538
+ markTimedOut(error: unknown, completedAt?: number): void {
539
+ this.state.markTimedOut(error, completedAt);
540
+ }
541
+
542
+ markQueueTimedOut(error: unknown, completedAt?: number): void {
543
+ this.state.markQueueTimedOut(error, completedAt);
544
+ }
545
+
546
+ markCancelled(completedAt?: number): void {
547
+ this.state.markCancelled(completedAt);
548
+ }
549
+
550
+ markError(error: unknown, completedAt?: number): void {
551
+ this.state.markError(error, completedAt);
552
+ }
553
+
554
+ /** Record the parent collected this agent's outcome. Idempotent. */
555
+ markConsumed(at?: number): void {
556
+ this.state.markConsumed(at);
557
+ }
558
+
559
+ /**
560
+ * Stop an agent that never started, then notify like every other terminal
561
+ * transition. No listener release: nothing is wired before run().
562
+ * The record leaves the active set here, so the thunk the limiter runs when
563
+ * the slot finally frees no-ops on guardedRun()'s guard — one notification.
564
+ */
565
+ stopQueued(): void {
566
+ this.state.markCancelled();
567
+ this.execution.observer?.onRunFinished?.(this);
568
+ }
569
+
570
+ queueTimedOut(timeoutMs: number): void {
571
+ // The limiter cannot remove one pending entry. A queued cancellation may
572
+ // therefore leave its timer armed; ignore that later timeout so it cannot
573
+ // publish a second terminal notification.
574
+ if (this.phase !== "queued") return;
575
+ this.state.markQueueTimedOut(
576
+ `Subagent queue wait timed out after ${Math.ceil(timeoutMs / 1000)} seconds`,
577
+ );
578
+ this.execution.observer?.onRunFinished?.(this);
579
+ }
580
+
581
+ /** Cancel the current invocation and release any non-cooperative waiter. */
582
+ abort(): boolean {
583
+ if (!this.isRunning()) return false;
584
+ this.markCancelled();
585
+ this.abortController.abort();
586
+ void this.subagentSession?.abort();
587
+ return true;
588
+ }
589
+
590
+ /**
591
+ * Buffer a steer message for delivery once the session is ready.
592
+ * Called internally from steer() before the session is ready.
593
+ */
594
+ private queueSteer(message: string): void {
595
+ this._pendingSteers.push(message);
596
+ }
597
+
598
+ /**
599
+ * Flush all buffered steer messages to the session and clear the buffer.
600
+ * Called once the session is available (inside run()).
601
+ */
602
+ private flushPendingSteers(): void {
603
+ for (const msg of this._pendingSteers) {
604
+ this.subagentSession?.steer(msg).catch(() => {});
605
+ }
606
+ this._pendingSteers = [];
607
+ }
608
+
609
+ /** Reset for resume: running phase, new invocation state, and fresh invocation metrics. */
610
+ resetForResume(startedAt: number): void {
611
+ this.state.resetForResume(startedAt);
612
+ this.listeners.release();
613
+ }
614
+
615
+ /** Complete a fresh run, including its workspace bracket. */
616
+ completeRun(result: TurnLoopResult): void {
617
+ this.listeners.release();
618
+ this.applyTurnLoopResult(result, true);
619
+ this.execution.observer?.onRunFinished?.(this);
620
+ }
621
+
622
+ private applyTurnLoopResult(result: TurnLoopResult, disposeWorkspace: boolean): void {
623
+ const workspaceResult = disposeWorkspace
624
+ ? this.workspaceBracket.dispose({ outcome: result.outcome, description: this.description })
625
+ : "";
626
+ const finalResult = result.responseText + workspaceResult;
627
+
628
+ if (result.outcome === "completed") this.markCompleted(finalResult);
629
+ else if (result.outcome === "turn_limit") this.markTurnLimited(finalResult);
630
+ else if (result.outcome === "timeout") this.markTimedOut("Subagent invocation timed out");
631
+ else this.markCancelled();
632
+ }
633
+
634
+ /**
635
+ * Dispose the wrapped session, firing the `disposed` lifecycle event.
636
+ * Resolves once the child's extensions have shut down; a failing teardown is
637
+ * swallowed so the caller's remaining cleanup still runs.
638
+ */
639
+ async disposeSession(): Promise<void> {
640
+ await disposeQuietly(this.subagentSession, "child session dispose");
641
+ }
642
+
643
+ /**
644
+ * Release the heavy session while keeping the record: capture the transcript
645
+ * pointer, dispose the session (firing `disposed`), clear it, and mark released.
646
+ * A no-op once the session is gone — the retention sweep may call it repeatedly.
647
+ *
648
+ * The record's own state is updated before the teardown is awaited, so a sweep
649
+ * tick arriving mid-teardown sees a released record rather than starting a
650
+ * second one.
651
+ */
652
+ async releaseSession(): Promise<void> {
653
+ const session = this.subagentSession;
654
+ if (!session) return;
655
+ this.captureSessionIdentity({ sessionId: session.sessionId, outputFile: session.outputFile });
656
+ this.subagentSession = undefined;
657
+ this._sessionReleased = true;
658
+ await disposeQuietly(session, "child session release");
659
+ }
660
+
661
+ private createInvocationControl(limits: ResolvedInvocationLimits): InvocationControl {
662
+ return new InvocationControl({
663
+ maxDurationMs: limits.maxDurationSeconds * 1000,
664
+ abortGraceMs: limits.abortGraceSeconds * 1000,
665
+ cancelSignal: this.abortController.signal,
666
+ onTimeout: () => {
667
+ this.markTimedOut(
668
+ `Subagent invocation timed out after ${limits.maxDurationSeconds} seconds`,
669
+ );
670
+ void this.subagentSession?.abort();
671
+ },
672
+ });
673
+ }
674
+
675
+ /** Keep a late non-cooperative turn loop from being resumed concurrently. */
676
+ private trackTurnLoop<T>(operation: Promise<T>): Promise<T> {
677
+ this.turnLoopInFlight = true;
678
+ void operation.then(
679
+ () => { this.turnLoopInFlight = false; },
680
+ () => { this.turnLoopInFlight = false; },
681
+ );
682
+ return operation;
683
+ }
684
+
685
+ private turnLoopOptions(control: InvocationControl) {
686
+ const limits = this.currentLimits ?? this.resolveLimits();
687
+ return {
688
+ maxTurns: limits.maxTurns,
689
+ wrapUpTurns: limits.wrapUpTurns,
690
+ signal: control.signal,
691
+ };
692
+ }
693
+
694
+ private resolveLimits(overrides: InvocationLimitOverrides = {}): ResolvedInvocationLimits {
695
+ const runtime = this.execution.getRunConfig?.() ?? DEFAULT_RUN_CONFIG;
696
+ const maxTurns = minPositive(runtime.maxTurns, this.execution.maxTurns, overrides.maxTurns);
697
+ const maxDurationSeconds = minPositive(
698
+ runtime.maxDurationSeconds,
699
+ this.execution.maxDurationSeconds,
700
+ overrides.maxDurationSeconds,
701
+ );
702
+ const requestedWrapUp = overrides.wrapUpTurns ?? this.execution.wrapUpTurns ?? runtime.wrapUpTurns;
703
+ return {
704
+ maxTurns,
705
+ wrapUpTurns: Math.min(Math.max(0, Math.floor(requestedWrapUp)), maxTurns - 1),
706
+ maxDurationSeconds,
707
+ abortGraceSeconds: runtime.abortGraceSeconds,
708
+ };
709
+ }
710
+
711
+ private handleInvocationFailure(error: unknown, resumed: boolean): void {
712
+ if (isInvocationTimeout(error) || this.outcome === "timeout") {
713
+ this.markTimedOut(error);
714
+ this.finishExceptionalInvocation("timeout", resumed);
715
+ void this.releaseSession();
716
+ return;
717
+ }
718
+ if (isInvocationCancelled(error) || this.outcome === "cancelled") {
719
+ this.markCancelled();
720
+ this.finishExceptionalInvocation("cancelled", resumed);
721
+ return;
722
+ }
723
+ if (resumed) this.failResume(error);
724
+ else this.failRun(error);
725
+ }
726
+
727
+ private finishExceptionalInvocation(status: "timeout" | "cancelled", resumed: boolean): void {
728
+ this.listeners.release();
729
+ if (!resumed) {
730
+ try {
731
+ this.workspaceBracket.dispose({ outcome: status, description: this.description });
732
+ } catch (cleanupErr) {
733
+ debugLog(`workspace dispose on agent ${status}`, cleanupErr);
734
+ }
735
+ }
736
+ if (resumed) this.execution.observer?.onResumeFinished?.(this);
737
+ else this.execution.observer?.onRunFinished?.(this);
738
+ }
739
+
740
+ private disposeLateWorkspace(prepare: Promise<string | undefined>): void {
741
+ void prepare.then(
742
+ () => {
743
+ try {
744
+ this.workspaceBracket.dispose({ outcome: this.outcome ?? "error", description: this.description });
745
+ } catch (error) {
746
+ debugLog("late workspace dispose", error);
747
+ }
748
+ },
749
+ (error) => debugLog("late workspace prepare", error),
750
+ );
751
+ }
752
+
753
+ private disposeLateSession(create: Promise<SubagentSession>): void {
754
+ void create.then(
755
+ (session) => disposeQuietly(session, "late child session dispose"),
756
+ (error) => debugLog("late child session creation", error),
757
+ );
758
+ }
759
+
760
+ private captureSessionIdentity(identity: SubagentSessionIdentity): void {
761
+ if (!identity.sessionId.trim()) {
762
+ throw new Error("Subagent session identity requires a non-empty sessionId");
763
+ }
764
+ if (this._sessionIdentity && this._sessionIdentity.sessionId !== identity.sessionId) {
765
+ throw new Error(
766
+ `Subagent session identity changed from ${this._sessionIdentity.sessionId} to ${identity.sessionId}`,
767
+ );
768
+ }
769
+ this._sessionIdentity = { ...identity };
770
+ }
771
+
772
+ /** Fail a run: mark error, release listeners, best-effort workspace dispose, notify observer. */
773
+ failRun(err: unknown): void {
774
+ this.markError(err);
775
+ this.listeners.release();
776
+
777
+ try {
778
+ this.workspaceBracket.dispose({ outcome: "error", description: this.description });
779
+ } catch (cleanupErr) { debugLog("workspace dispose on agent error", cleanupErr); }
780
+
781
+ this.execution.observer?.onRunFinished?.(this);
782
+ }
783
+ }
784
+
785
+ const DEFAULT_RUN_CONFIG: RunConfig = {
786
+ maxTurns: 30,
787
+ wrapUpTurns: 2,
788
+ maxDurationSeconds: 1800,
789
+ maxQueueWaitSeconds: 900,
790
+ abortGraceSeconds: 5,
791
+ };
792
+
793
+ function minPositive(...values: Array<number | undefined>): number {
794
+ const normalized = values
795
+ .filter((value): value is number => value != null && Number.isFinite(value) && value > 0)
796
+ .map((value) => Math.floor(value));
797
+ return Math.min(...normalized);
798
+ }
799
+
800
+ function freshInvocationIdentity(toolCallId: string | undefined): SubagentInvocationIdentity {
801
+ return toolCallId == null
802
+ ? { kind: "fresh", source: "service" }
803
+ : { kind: "fresh", source: "tool", toolCallId };
804
+ }
805
+
806
+ function normalizeInvocationIdentity(
807
+ identity: SubagentInvocationIdentity,
808
+ expectedKind: SubagentInvocationIdentity["kind"],
809
+ ): SubagentInvocationIdentity {
810
+ if (identity.kind !== expectedKind) {
811
+ throw new Error(`Expected ${expectedKind} invocation identity, received ${identity.kind}`);
812
+ }
813
+ if (identity.source === "tool") {
814
+ if (!identity.toolCallId?.trim()) {
815
+ throw new Error("Tool-driven subagent invocation requires a non-empty toolCallId");
816
+ }
817
+ return { ...identity, toolCallId: identity.toolCallId.trim() };
818
+ }
819
+ if (identity.toolCallId != null) {
820
+ throw new Error("Service-driven subagent invocation must not include toolCallId");
821
+ }
822
+ return { kind: identity.kind, source: "service" };
823
+ }
824
+
825
+ /**
826
+ * Tear a child session down without letting its failure escape.
827
+ * Both teardown paths are cleanup: a child that will not shut down cleanly must
828
+ * not stop the caller from finishing the rest of its own cleanup.
829
+ */
830
+ async function disposeQuietly(
831
+ session: SubagentSession | undefined,
832
+ context: string,
833
+ ): Promise<void> {
834
+ try {
835
+ await session?.dispose();
836
+ } catch (err) {
837
+ debugLog(context, err);
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Settle with `run`, or early when `signal` fires — whichever comes first.
843
+ * The inner controller is the listener-cleanup channel: it detaches the abort
844
+ * listener whichever branch wins, so repeated waits within one parent turn do
845
+ * not accumulate listeners on that turn's signal.
846
+ */
847
+ function settleOrAbort(run: Promise<void>, signal: AbortSignal): Promise<void> {
848
+ if (signal.aborted) return Promise.resolve();
849
+ const detach = new AbortController();
850
+ const interrupted = new Promise<void>((resolve) => {
851
+ signal.addEventListener("abort", () => { resolve(); }, { once: true, signal: detach.signal });
852
+ });
853
+ return Promise.race([run, interrupted]).finally(() => { detach.abort(); });
854
+ }