@nklisch/pi-enhanced 0.2.6 → 0.3.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 (70) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-x64-gnu.node +0 -0
  3. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  4. package/node_modules/@nklisch/pi-plugins/README.md +4 -2
  5. package/node_modules/@nklisch/pi-plugins/dist/hooks.js +81 -8
  6. package/node_modules/@nklisch/pi-plugins/dist/hooks.js.map +1 -1
  7. package/node_modules/@nklisch/pi-plugins/dist/mcp.js +32 -1
  8. package/node_modules/@nklisch/pi-plugins/dist/mcp.js.map +1 -1
  9. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +10 -4
  10. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +144 -10
  11. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -1
  12. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +40 -14
  13. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
  14. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +25 -0
  15. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +75 -47
  16. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/public.d.ts +171 -140
  17. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/settings.d.ts +2 -2
  18. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/FORK-MAINTENANCE.md +8 -7
  19. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/VISION.md +12 -10
  20. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/architecture.md +112 -105
  21. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/comparison-with-upstream.md +3 -3
  22. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0004-reconsider-ui-direction.md +5 -0
  23. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
  24. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/custom-agents.ts +31 -4
  25. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/default-agents.ts +1 -1
  26. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/invocation-config.ts +7 -4
  27. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/index.ts +18 -9
  28. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/layered-settings.ts +2 -2
  29. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +10 -63
  30. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +122 -76
  31. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +3 -5
  32. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +4 -3
  33. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +2 -23
  34. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +177 -252
  35. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +90 -280
  36. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-state.ts +153 -223
  37. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +569 -683
  38. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace.ts +1 -1
  39. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/notification.ts +78 -119
  40. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/renderer.ts +15 -42
  41. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +44 -95
  42. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service-adapter.ts +157 -90
  43. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service.ts +60 -68
  44. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/conversation.ts +3 -3
  45. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query-source.ts +28 -0
  46. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query.ts +327 -0
  47. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/settings.ts +1 -1
  48. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/agent-tool.ts +171 -249
  49. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-tool.ts +51 -92
  50. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/helpers.ts +9 -6
  51. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/list-tool.ts +46 -0
  52. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/parent-tool-registry.ts +17 -0
  53. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/query-session-tool.ts +330 -0
  54. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/result-renderer.ts +27 -111
  55. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/resume-tool.ts +105 -0
  56. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/spawn-config.ts +26 -8
  57. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/steer-tool.ts +27 -111
  58. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/stop-tool.ts +53 -0
  59. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/types.ts +11 -5
  60. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +33 -27
  61. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/display.ts +6 -3
  62. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigation.ts +121 -10
  63. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +430 -112
  64. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/subagents-settings.ts +1 -1
  65. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/widget-renderer.ts +8 -9
  66. package/node_modules/@nklisch/pi-plugins/package.json +2 -2
  67. package/package.json +1 -1
  68. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/background-spawner.ts +0 -81
  69. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +0 -149
  70. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-report.ts +0 -77
@@ -1,25 +1,25 @@
1
1
  /**
2
- * subagent.ts Subagent class: identity, lifecycle status, and per-subagent behavior.
2
+ * The authoritative subagent record and its run leases.
3
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.
4
+ * A record remains active until the current lease's settlement promise resolves.
5
+ * In particular, requesting a stop never fabricates terminal state while child
6
+ * code, teardown, or an admission callback can still mutate the workspace.
7
7
  */
8
8
 
9
- import { randomUUID } from "node:crypto";
10
9
  import type { Model } from "@earendil-works/pi-ai";
11
10
  import type { AgentSessionEvent, ToolDefinition } from "@earendil-works/pi-coding-agent";
12
11
  import { debugLog, runDetached, runSafely } from "#src/debug";
12
+ import type { AdmissionHandle } from "#src/lifecycle/concurrency-limiter";
13
13
  import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
14
- import {
15
- type LifecycleInterceptorRegistry,
16
- type SubagentLifecycleExecutionPath,
17
- type SubagentTurnLifecycle,
14
+ import type {
15
+ LifecycleInterceptorRegistry,
16
+ SubagentLifecycleExecutionPath,
17
+ SubagentTurnLifecycle,
18
18
  } from "#src/lifecycle/lifecycle-interceptor";
19
19
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
20
20
  import { RunListeners } from "#src/lifecycle/run-listeners";
21
21
  import type { SubagentSession, TurnLoopResult } from "#src/lifecycle/subagent-session";
22
- import { SubagentState, type SubagentStatus } from "#src/lifecycle/subagent-state";
22
+ import { SubagentState, type SubagentStatus, type SubagentStopReason, type SubagentTerminalReason } from "#src/lifecycle/subagent-state";
23
23
  import type { LifetimeUsage } from "#src/lifecycle/usage";
24
24
  import type { WorkspaceProvider } from "#src/lifecycle/workspace";
25
25
  import { WorkspaceBracket } from "#src/lifecycle/workspace-bracket";
@@ -27,695 +27,581 @@ import { subscribeSubagentObserver } from "#src/observation/record-observer";
27
27
  import type { RunConfig } from "#src/runtime";
28
28
  import { formatModelLabel } from "#src/session/model-label";
29
29
  import { resolveEffectiveThinkingLevel } from "#src/session/thinking-level";
30
- import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SessionMessage, SubagentType, ThinkingLevel } from "#src/types";
30
+ import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SessionMessage, SubagentMode, SubagentType, ThinkingLevel } from "#src/types";
31
31
 
32
- /** Per-subagent lifecycle observer — created by SubagentManager for each spawn. */
33
32
  export interface SubagentLifecycleObserver {
34
- /** Fires when the subagent transitions to running (inside run(), after markRunning). */
35
- onStarted?(agent: Subagent): void;
36
- /** Fires once the session is created — the subagent's subagentSession is now available. */
37
- onSessionCreated?(agent: Subagent): void;
38
- /** Fires once when the initial run completes or fails (for concurrency drain). */
39
- onRunFinished?(agent: Subagent): void;
40
- /** Fires once when a resumed turn reaches a terminal state. */
41
- onResumedFinished?(agent: Subagent): void;
42
- /** Fires on compaction events during the run. */
43
- onCompacted?(agent: Subagent, info: CompactionInfo): void;
33
+ onStarted?(agent: Subagent): void;
34
+ onSessionCreated?(agent: Subagent): void;
35
+ onRunFinished?(agent: Subagent): void;
36
+ onResumedStarted?(agent: Subagent): void;
37
+ onResumedFinished?(agent: Subagent): void;
38
+ onCompacted?(agent: Subagent, info: CompactionInfo): void;
44
39
  }
45
40
 
46
- export type { SubagentStatus } from "#src/lifecycle/subagent-state";
47
-
48
- /**
49
- * The result of a steer attempt. `Subagent.steer` owns the non-running
50
- * rejection rule and reports it here, so coordinators switch on the outcome
51
- * instead of pre-checking status (tell by id, with outcomes).
52
- */
53
41
  export type SteerOutcome =
54
- | { kind: "delivered" }
55
- | { kind: "buffered" }
56
- | { kind: "rejected"; status: SubagentStatus };
57
-
58
- /** A second prompt was requested while this record already owned an execution. */
59
- export class SubagentBusyError extends Error {
60
- constructor(agentId: string) {
61
- super(`Subagent "${agentId}" is still processing a turn. Wait for it to settle before resuming.`);
62
- this.name = "SubagentBusyError";
63
- }
64
- }
42
+ | { kind: "delivered"; runId: number }
43
+ | { kind: "buffered"; runId: number }
44
+ | { kind: "rejected"; runId: number; status: SubagentStatus };
65
45
 
66
- /**
67
- * The execution machinery a Subagent needs to run. A single mandatory
68
- * collaborator: production (SubagentManager.spawn) always supplies it, so run()
69
- * needs no "not configured" guards. The genuinely-optional behavior knobs stay
70
- * optional; the four inputs run() cannot proceed without are required.
71
- */
72
46
  export interface SubagentExecution {
73
- /** Assembly factory that produces a born-complete SubagentSession. */
74
- createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
75
- /** Immutable spawn-time parent snapshot handed to the session factory. */
76
- snapshot: ParentSnapshot;
77
- /** Initial prompt for the turn loop. */
78
- prompt: string;
79
- /** Parent working directory handed to a workspace provider's prepare(). */
80
- baseCwd: string;
81
- observer?: SubagentLifecycleObserver;
82
- getRunConfig?: () => RunConfig;
83
- /** Resolves the registered workspace provider (if any) at run-start. */
84
- getWorkspaceProvider?: () => WorkspaceProvider | undefined;
85
- model?: Model<any>;
86
- maxTurns?: number;
87
- thinkingLevel?: ThinkingLevel;
88
- parentSession?: ParentSessionInfo;
89
- /** Service-origin identity for lifecycle callbacks; never changes child setup. */
90
- lifecycleParentSession?: ParentSessionInfo;
91
- signal?: AbortSignal;
92
- /** Owned by the manager; only a callback bridge reaches the child session. */
93
- lifecycleInterceptors?: LifecycleInterceptorRegistry;
94
- /** Spawn-path facts remain stable when the same child session resumes. */
95
- executionPath?: SubagentLifecycleExecutionPath;
47
+ createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
48
+ snapshot: ParentSnapshot;
49
+ prompt: string;
50
+ baseCwd: string;
51
+ mode: SubagentMode;
52
+ timeoutSeconds?: number;
53
+ observer?: SubagentLifecycleObserver;
54
+ getRunConfig?: () => RunConfig;
55
+ getWorkspaceProvider?: () => WorkspaceProvider | undefined;
56
+ model?: Model<any>;
57
+ maxTurns?: number;
58
+ thinkingLevel?: ThinkingLevel;
59
+ parentSession?: ParentSessionInfo;
60
+ lifecycleParentSession?: ParentSessionInfo;
61
+ signal?: AbortSignal;
62
+ lifecycleInterceptors?: LifecycleInterceptorRegistry;
63
+ executionPath?: Omit<SubagentLifecycleExecutionPath, "phase" | "mode"> & { admission?: "immediate" | "queued" };
96
64
  }
97
65
 
98
66
  export interface SubagentInit {
99
- // Identity
100
- id: string;
101
- type: SubagentType;
102
- description: string;
103
- invocation?: AgentInvocation;
67
+ id: string;
68
+ type: SubagentType;
69
+ description: string;
70
+ invocation?: AgentInvocation;
71
+ execution: SubagentExecution;
72
+ state?: SubagentState;
73
+ }
104
74
 
105
- /** Execution machinery — always supplied; construct-complete, no test fallbacks. */
106
- execution: SubagentExecution;
75
+ interface RunLease {
76
+ readonly runId: number;
77
+ readonly phase: "initial" | "resume";
78
+ readonly mode: SubagentMode;
79
+ readonly prompt: string;
80
+ readonly timeoutSeconds?: number;
81
+ readonly executionController: AbortController;
82
+ readonly settlement: Promise<void>;
83
+ readonly resolveSettlement: () => void;
84
+ admission?: AdmissionHandle;
85
+ admitted: boolean;
86
+ settled: boolean;
87
+ admissionPath: "immediate" | "queued";
88
+ startedAt?: number;
89
+ runtimeTimer?: ReturnType<typeof setTimeout>;
90
+ parentSignalCleanup?: () => void;
91
+ stopRequest?: SubagentStopReason;
92
+ }
107
93
 
108
- /** Lifecycle status and metrics. Defaults to a fresh queued state. */
109
- state?: SubagentState;
94
+ function deferred(): { promise: Promise<void>; resolve: () => void } {
95
+ let resolve!: () => void;
96
+ const promise = new Promise<void>((res) => { resolve = res; });
97
+ return { promise, resolve };
110
98
  }
111
99
 
112
100
  export class Subagent {
113
- // Identity — set once at construction
114
- readonly id: string;
115
- readonly type: SubagentType;
116
- readonly description: string;
117
- readonly invocation?: AgentInvocation;
118
-
119
- // Lifecycle status and metrics — owned by a private value object; getters and
120
- // mutation methods below delegate to it one line.
121
- private readonly state: SubagentState;
122
- get status(): SubagentStatus { return this.state.status; }
123
- get result(): string | undefined { return this.state.result; }
124
- get error(): string | undefined { return this.state.error; }
125
- get startedAt(): number { return this.state.startedAt; }
126
- get completedAt(): number | undefined { return this.state.completedAt; }
127
- get stoppedWhileQueued(): boolean { return this.state.stoppedWhileQueued; }
128
- get consumedAt(): number | undefined { return this.state.consumedAt; }
129
- get consumed(): boolean { return this.state.consumed; }
130
- get toolUses(): number { return this.state.toolUses; }
131
- get lifetimeUsage(): Readonly<LifetimeUsage> { return this.state.lifetimeUsage; }
132
- get compactionCount(): number { return this.state.compactionCount; }
133
- get turnCount(): number { return this.state.turnCount; }
134
- get activeTools(): ReadonlyMap<string, string> { return this.state.activeTools; }
135
- get responseText(): string { return this.state.responseText; }
136
- get maxTurns(): number | undefined { return this.execution.maxTurns; }
137
- /** Exact effective model label used by every operator-facing status surface. */
138
- get modelLabel(): string { return this._modelLabel; }
139
- /** Exact effective thinking level used by every operator-facing status surface. */
140
- get effectiveThinkingLevel(): ThinkingLevel { return this._effectiveThinkingLevel; }
141
-
142
- abortController: AbortController;
143
- private _promise?: Promise<void>;
144
- get promise(): Promise<void> | undefined { return this._promise; }
145
-
146
- private readonly execution: SubagentExecution;
147
- private _modelLabel: string;
148
- private _effectiveThinkingLevel: ThinkingLevel;
149
- private readonly listeners = new RunListeners();
150
- private readonly workspaceBracket: WorkspaceBracket;
151
- /** True while run()/runResume() owns the child prompt boundary. */
152
- private executionInFlight = false;
153
- /** Synchronous admission lease spanning wind-down, Pi idle, and resume. */
154
- private resumeReserved = false;
155
- private pendingResumeAbort?: AbortController;
156
-
157
- subagentSession?: SubagentSession;
158
- private releasedOutputFile?: string;
159
- private _sessionReleased = false;
160
- get sessionReleased(): boolean { return this._sessionReleased; }
161
-
162
- // Steer buffer messages queued before the session is ready
163
- private _pendingSteers: string[] = [];
164
- /** Number of steer messages waiting to be delivered. */
165
- get pendingSteerCount(): number { return this._pendingSteers.length; }
166
-
167
- /** Path to the agent's session JSONL file, or undefined if not yet available. */
168
- get outputFile(): string | undefined {
169
- return this.subagentSession?.outputFile ?? this.releasedOutputFile;
170
- }
171
-
172
- /** The tool call ID that spawned this background agent, if any. */
173
- get toolCallId(): string | undefined {
174
- return this.execution.parentSession?.toolCallId;
175
- }
176
-
177
- /** Returns true when a SubagentSession is available (session is ready). */
178
- isSessionReady(): boolean {
179
- return this.subagentSession != null;
180
- }
181
-
182
- isActive(): boolean {
183
- return this.status === "queued" || this.status === "running" || this.resumeReserved;
184
- }
185
-
186
- isRunning(): boolean {
187
- return this.status === "running";
188
- }
189
-
190
- /**
191
- * Steer a running agent, owning the non-running rejection rule.
192
- * Returns a `rejected` outcome (with the observed status) when the agent is
193
- * not running, a `buffered` outcome when the session is not yet ready, or a
194
- * `delivered` outcome once the message reaches the session.
195
- */
196
- async steer(message: string): Promise<SteerOutcome> {
197
- if (this.status !== "running") {
198
- return { kind: "rejected", status: this.status };
199
- }
200
- if (!this.subagentSession) {
201
- this.queueSteer(message);
202
- return { kind: "buffered" };
203
- }
204
- await this.subagentSession.steer(message);
205
- return { kind: "delivered" };
206
- }
207
-
208
- /** Return the session conversation as formatted text, or undefined if no session. */
209
- getConversation(): string | undefined {
210
- return this.subagentSession?.getConversation();
211
- }
212
-
213
- /** Return the session context window utilization (0-100), or null if unavailable. */
214
- getContextPercent(): number | null {
215
- return this.subagentSession?.getContextPercent() ?? null;
216
- }
217
-
218
- /**
219
- * Subscribe to session events for live updates (e.g., conversation viewer).
220
- * Returns an unsubscribe function, or undefined if no session is available.
221
- */
222
- subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined {
223
- return this.subagentSession?.subscribe(fn);
224
- }
225
-
226
- /** The session's message history, or an empty array if no session. */
227
- get messages(): readonly unknown[] {
228
- return this.subagentSession?.messages ?? [];
229
- }
230
-
231
- /** The session's message history typed for Pi's session-rendering machinery, or empty if no session. */
232
- get agentMessages(): readonly SessionMessage[] {
233
- return this.subagentSession?.agentMessages ?? [];
234
- }
235
-
236
- /** Resolve a registered tool definition by name, or undefined if no session. */
237
- getToolDefinition(name: string): ToolDefinition | undefined {
238
- return this.subagentSession?.getToolDefinition(name);
239
- }
240
-
241
- constructor(init: SubagentInit) {
242
- // Identity
243
- this.id = init.id;
244
- this.type = init.type;
245
- this.description = init.description;
246
- this.invocation = init.invocation;
247
-
248
- // Lifecycle status and metrics — fresh queued state unless one is supplied
249
- this.state = init.state ?? new SubagentState();
250
-
251
- // Abort controller — always created, never injected
252
- this.abortController = new AbortController();
253
-
254
- // Execution machinery — a single mandatory collaborator
255
- this.execution = init.execution;
256
- this._modelLabel = formatModelLabel(this.execution.model ?? this.execution.snapshot.model);
257
- this._effectiveThinkingLevel = resolveEffectiveThinkingLevel(
258
- this.execution.model ?? this.execution.snapshot.model,
259
- this.execution.thinkingLevel,
260
- this.execution.snapshot.thinkingLevel,
261
- );
262
-
263
- // Per-run lifecycle collaborators
264
- this.workspaceBracket = new WorkspaceBracket(
265
- this.execution.getWorkspaceProvider ?? (() => undefined),
266
- );
267
- }
268
-
269
- /**
270
- * Execute the full agent lifecycle: workspace preparation, session creation
271
- * via the factory, observer wiring, the turn loop, workspace disposal, and
272
- * status transitions.
273
- *
274
- * Execution is supplied at construction (mandatory), so run() needs no
275
- * "not configured" guards. The returned promise always resolves (errors are
276
- * captured internally).
277
- */
278
- async run(): Promise<void> {
279
- this.executionInFlight = true;
280
- this.markRunning(Date.now());
281
- try {
282
- // Observer callbacks are extension-owned sinks, not part of the agent's
283
- // work. A stale UI/context callback must not leave a background record
284
- // running with a slot held and no terminal notification.
285
- runSafely("subagent onStarted observer", () => this.execution.observer?.onStarted?.(this));
286
- this.listeners.wireSignal(this.execution.signal, () => this.abort());
287
-
288
- // Guard the await so the no-provider path stays synchronous, preserving
289
- // the original run() timing: the factory is called in the same turn as
290
- // spawn() when no workspace provider is registered.
291
- let cwd: string | undefined;
292
- if (this.workspaceBracket.hasProvider()) {
293
- cwd = await this.workspaceBracket.prepare({
294
- agentId: this.id,
295
- agentType: this.type,
296
- baseCwd: this.execution.baseCwd,
297
- invocation: this.invocation,
298
- });
299
- }
300
-
301
- this.subagentSession = await this.execution.createSubagentSession({
302
- snapshot: this.execution.snapshot,
303
- type: this.type,
304
- cwd,
305
- parentSession: this.execution.parentSession,
306
- model: this.execution.model,
307
- thinkingLevel: this.execution.thinkingLevel,
308
- });
309
-
310
- // The SDK session is authoritative after creation: it has applied its
311
- // defaults and model-capability clamp. Keep the record as the one source
312
- // consumed by every operator-facing status surface.
313
- this._modelLabel = formatModelLabel(this.subagentSession.model ?? this.execution.model ?? this.execution.snapshot.model);
314
- this._effectiveThinkingLevel = this.subagentSession.thinkingLevel ?? this._effectiveThinkingLevel;
315
-
316
- this.flushPendingSteers();
317
- this.listeners.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
318
- onCompact: (info) => runSafely(
319
- "subagent onCompacted observer",
320
- () => this.execution.observer?.onCompacted?.(this, info),
321
- ),
322
- }));
323
- runSafely("subagent onSessionCreated observer", () => this.execution.observer?.onSessionCreated?.(this));
324
-
325
- const runConfig = this.execution.getRunConfig?.();
326
- const lifecycle = this.createTurnLifecycle("initial");
327
- const result = await this.subagentSession.runTurnLoop(this.execution.prompt, {
328
- maxTurns: this.execution.maxTurns,
329
- defaultMaxTurns: runConfig?.defaultMaxTurns,
330
- graceTurns: runConfig?.graceTurns,
331
- signal: lifecycle?.signal ?? this.abortController.signal,
332
- ...(lifecycle ? { lifecycle } : {}),
333
- });
334
- this.completeRun(result);
335
- } catch (err) {
336
- // One outer failure path guarantees terminal state, listener release,
337
- // workspace cleanup, and the manager's completion funnel.
338
- this.failRun(err);
339
- } finally {
340
- this.executionInFlight = false;
341
- }
342
- }
343
-
344
- /**
345
- * Start execution immediately (foreground / bypassQueue paths).
346
- * Stores the run promise so it is awaitable via the `promise` getter.
347
- */
348
- start(): void {
349
- this._promise = this.guardedRun();
350
- }
351
-
352
- /**
353
- * Schedule execution through an external concurrency scheduler (the limiter).
354
- * Captures the scheduler's promise eagerly, so a still-queued agent is
355
- * awaitable via the `promise` getter from spawn — not only once its slot opens.
356
- * The guard in guardedRun() makes an abort-while-queued run a no-op when the
357
- * slot finally frees.
358
- */
359
- scheduleVia(schedule: (thunk: () => Promise<void>) => Promise<void>): void {
360
- this._promise = schedule(() => this.guardedRun());
361
- }
362
-
363
- /**
364
- * Run unless the agent left the active set before its slot opened
365
- * (e.g. abort-while-queued): a non-queued, non-running status resolves
366
- * immediately without running.
367
- */
368
- private guardedRun(): Promise<void> {
369
- if (this.status !== "queued" && this.status !== "running") return Promise.resolve();
370
- return this.run();
371
- }
372
-
373
- /**
374
- * Resume an existing session with a new prompt, managing the observer
375
- * subscription lifecycle internally (same wiring as run()).
376
- *
377
- * Requires an existing SubagentSession (set when the original run created it).
378
- * The returned promise always resolves (errors are captured internally).
379
- * Parent cancellation and manager abort both stop the resumed turn.
380
- */
381
- resume(prompt: string, signal?: AbortSignal): Promise<void> {
382
- const subagentSession = this.subagentSession;
383
- if (!subagentSession) {
384
- return Promise.reject(new Error(
385
- this.sessionReleased
386
- ? "Subagent session was released and can no longer be resumed"
387
- : "Subagent not configured for resume — missing session",
388
- ));
389
- }
390
- if (this.status === "queued" || this.status === "running" || this.resumeReserved) {
391
- return Promise.reject(new SubagentBusyError(this.id));
392
- }
393
-
394
- // Reserve synchronously before the first await. This closes both the
395
- // concurrent-resume race and retention's release-while-waiting race.
396
- this.resumeReserved = true;
397
- const previousExecution = this.executionInFlight ? this._promise : undefined;
398
- const resumeAbort = new AbortController();
399
- this.pendingResumeAbort = resumeAbort;
400
- const resumed = this.resumeWhenReady(
401
- subagentSession,
402
- previousExecution,
403
- prompt,
404
- resumeAbort,
405
- signal,
406
- );
407
- this._promise = resumed;
408
- return resumed;
409
- }
410
-
411
- private async resumeWhenReady(
412
- subagentSession: SubagentSession,
413
- previousExecution: Promise<void> | undefined,
414
- prompt: string,
415
- resumeAbort: AbortController,
416
- signal?: AbortSignal,
417
- ): Promise<void> {
418
- const waitSignal = signal
419
- ? AbortSignal.any([resumeAbort.signal, signal])
420
- : resumeAbort.signal;
421
- let started = false;
422
- try {
423
- // abort() marks a record stopped before AgentSession.prompt() necessarily
424
- // settles. Wait for both the record-owned invocation and Pi's stronger
425
- // idle boundary instead of racing a new prompt into the old one.
426
- if (previousExecution) await previousExecution;
427
- waitSignal.throwIfAborted();
428
- await subagentSession.waitUntilIdle(waitSignal);
429
- waitSignal.throwIfAborted();
430
- if (this.subagentSession !== subagentSession) {
431
- throw new Error("Subagent session was released while waiting to resume");
432
- }
433
-
434
- started = true;
435
- this.executionInFlight = true;
436
- this.resetForResume(Date.now(), resumeAbort);
437
- await this.runResume(subagentSession, prompt, signal);
438
- } catch (error) {
439
- if (waitSignal.aborted) this.markStopped();
440
- else this.markError(error);
441
- if (!started) {
442
- runSafely("subagent onResumedFinished observer", () => this.execution.observer?.onResumedFinished?.(this));
443
- }
444
- } finally {
445
- if (started) this.executionInFlight = false;
446
- if (this.pendingResumeAbort === resumeAbort) this.pendingResumeAbort = undefined;
447
- this.resumeReserved = false;
448
- }
449
- }
450
-
451
- private async runResume(
452
- subagentSession: SubagentSession,
453
- prompt: string,
454
- signal?: AbortSignal,
455
- ): Promise<void> {
456
- const executionSignal = signal
457
- ? AbortSignal.any([this.abortController.signal, signal])
458
- : this.abortController.signal;
459
- try {
460
- this.listeners.attachObserver(subscribeSubagentObserver(subagentSession, this.state, {
461
- onCompact: (info) => runSafely(
462
- "subagent onCompacted observer",
463
- () => this.execution.observer?.onCompacted?.(this, info),
464
- ),
465
- }));
466
-
467
- const lifecycle = this.createTurnLifecycle("resume", executionSignal);
468
- if (lifecycle) {
469
- const result = await subagentSession.resumeLifecycleTurnLoop(
470
- prompt,
471
- lifecycle.signal,
472
- lifecycle,
473
- );
474
- if (result.failure) this.markFailed(result.failure, result.responseText);
475
- else if (result.aborted) this.markAborted(result.responseText);
476
- else if (result.steered) this.markSteered(result.responseText);
477
- else this.markCompleted(result.responseText);
478
- } else {
479
- const resumed = await subagentSession.resumeTurnLoop(prompt, executionSignal);
480
- const outcome = typeof resumed === "string" ? { text: resumed } : resumed;
481
- if (outcome.failure) this.markFailed(outcome.failure, outcome.text);
482
- else this.markCompleted(outcome.text);
483
- }
484
- } catch (err) {
485
- this.markError(err);
486
- } finally {
487
- this.listeners.release();
488
- runSafely("subagent onResumedFinished observer", () => this.execution.observer?.onResumedFinished?.(this));
489
- }
490
- }
491
-
492
- /** Wait for the current queued, running, or resumed execution without cancelling it. */
493
- async waitUntilSettled(signal: AbortSignal): Promise<void> {
494
- const run = this._promise;
495
- if (!run || (!this.executionInFlight && !this.resumeReserved) || signal.aborted) return;
496
- await settleOrAbort(run, signal);
497
- }
498
-
499
- /** Build a callback-only lifecycle bridge after the immutable child session exists. */
500
- private createTurnLifecycle(
501
- phase: "initial" | "resume",
502
- executionSignal: AbortSignal | undefined = this.abortController.signal,
503
- ): SubagentTurnLifecycle | undefined {
504
- const registry = this.execution.lifecycleInterceptors;
505
- const session = this.subagentSession;
506
- if (!registry?.hasInterceptors() || !session) return undefined;
507
- const initialPath = this.execution.executionPath ?? {
508
- phase: "initial" as const,
509
- origin: "service" as const,
510
- mode: "foreground" as const,
511
- admission: "immediate" as const,
512
- };
513
- const lifecycleParent = this.execution.lifecycleParentSession ?? this.execution.parentSession;
514
- return registry.createTurnLifecycle({
515
- identity: {
516
- agentId: this.id,
517
- sessionId: session.sessionId,
518
- runId: randomUUID(),
519
- agentType: this.type,
520
- ...(lifecycleParent?.parentSessionId
521
- ? { parentSessionId: lifecycleParent.parentSessionId }
522
- : {}),
523
- },
524
- execution: { ...initialPath, phase },
525
- signal: executionSignal === this.abortController.signal
526
- ? executionSignal
527
- : AbortSignal.any([this.abortController.signal, executionSignal]),
528
- });
529
- }
530
-
531
- /** Increment tool use count. Called by record-observer on tool_execution_end. */
532
- incrementToolUses(): void {
533
- this.state.incrementToolUses();
534
- }
535
-
536
- /** Accumulate a usage delta into lifetimeUsage. Called by record-observer on message_end. */
537
- addUsage(delta: { input: number; output: number; cacheWrite: number }): void {
538
- this.state.addUsage(delta);
539
- }
540
-
541
- /** Increment compaction count. Called by record-observer on compaction_end. */
542
- incrementCompactions(): void {
543
- this.state.incrementCompactions();
544
- }
545
-
546
- /** Transition to running state. Sets status and startedAt. */
547
- markRunning(startedAt: number): void {
548
- this.state.markRunning(startedAt);
549
- }
550
-
551
- /**
552
- * Transition to completed state.
553
- * Always sets result and completedAt (??=). Only changes status if not stopped.
554
- */
555
- markCompleted(result: string, completedAt?: number): void {
556
- this.state.markCompleted(result, completedAt);
557
- }
558
-
559
- /**
560
- * Transition to aborted state.
561
- * Always sets result and completedAt (??=). Only changes status if not stopped.
562
- */
563
- markAborted(result: string, completedAt?: number): void {
564
- this.state.markAborted(result, completedAt);
565
- }
566
-
567
- /**
568
- * Transition to steered state.
569
- * Always sets result and completedAt (??=). Only changes status if not stopped.
570
- */
571
- markSteered(result: string, completedAt?: number): void {
572
- this.state.markSteered(result, completedAt);
573
- }
574
-
575
- /**
576
- * Transition to error state.
577
- * Always sets error (formatted) and completedAt (??=). Only changes status if not stopped.
578
- */
579
- markError(error: unknown, completedAt?: number): void {
580
- this.state.markError(error, completedAt);
581
- }
582
-
583
- /** Record a provider failure and preserve any partial output. */
584
- markFailed(error: unknown, partialResult?: string, completedAt?: number): void {
585
- this.state.markFailed(error, partialResult, completedAt);
586
- }
587
-
588
- /** Transition to stopped state. Always valid — no guard. */
589
- markStopped(completedAt?: number): void {
590
- this.state.markStopped(completedAt);
591
- }
592
-
593
- markConsumed(at?: number): void {
594
- this.state.markConsumed(at);
595
- }
596
-
597
- /** Stop a queued agent through the same terminal observer funnel as a run. */
598
- stopQueued(): void {
599
- this.state.stopQueued();
600
- runSafely("subagent onRunFinished observer", () => this.execution.observer?.onRunFinished?.(this));
601
- }
602
-
603
- /**
604
- * Abort a running agent: fire AbortController and transition to stopped.
605
- * Returns false if the agent is not running.
606
- * A still-queued agent is stopped by SubagentManager; its scheduled thunk
607
- * then no-ops on the queued-status guard.
608
- */
609
- abort(): boolean {
610
- if (this.status !== "running" && !this.resumeReserved) return false;
611
- this.abortController.abort();
612
- this.pendingResumeAbort?.abort();
613
- this.markStopped();
614
- return true;
615
- }
616
-
617
- /**
618
- * Buffer a steer message for delivery once the session is ready.
619
- * Called internally from steer() before the session is ready.
620
- */
621
- private queueSteer(message: string): void {
622
- this._pendingSteers.push(message);
623
- }
624
-
625
- /**
626
- * Flush all buffered steer messages to the session and clear the buffer.
627
- * Called once the session is available (inside run()).
628
- */
629
- private flushPendingSteers(): void {
630
- for (const msg of this._pendingSteers) {
631
- runDetached("subagent buffered steer", () => this.subagentSession?.steer(msg));
632
- }
633
- this._pendingSteers = [];
634
- }
635
-
636
- /** Reset for resume: running status, new startedAt, clear completedAt/result/error/listeners. */
637
- resetForResume(startedAt: number, controller = new AbortController()): void {
638
- this.abortController = controller;
639
- this.state.resetForResume(startedAt);
640
- this.listeners.release();
641
- }
642
-
643
- /** Complete a run: release listeners, dispose the workspace, status transition, notify observer. */
644
- completeRun(result: TurnLoopResult): void {
645
- this.listeners.release();
646
-
647
- const finalStatus: SubagentStatus = result.failure
648
- ? "error"
649
- : result.aborted
650
- ? "aborted"
651
- : result.steered
652
- ? "steered"
653
- : "completed";
654
- let finalResult = result.responseText;
655
- try {
656
- finalResult += this.workspaceBracket.dispose({ status: finalStatus, description: this.description });
657
- } catch (error) {
658
- // Workspace teardown belongs to the run's terminal boundary. If it
659
- // fails, report an error once rather than re-entering disposal from the
660
- // outer catch path.
661
- this.failRun(error);
662
- return;
663
- }
664
-
665
- if (result.failure) this.markFailed(result.failure, finalResult);
666
- else if (result.aborted) this.markAborted(finalResult);
667
- else if (result.steered) this.markSteered(finalResult);
668
- else this.markCompleted(finalResult);
669
-
670
- runSafely("subagent onRunFinished observer", () => this.execution.observer?.onRunFinished?.(this));
671
- }
672
-
673
- /** Dispose the wrapped session, firing the `disposed` lifecycle event. */
674
- async disposeSession(): Promise<void> {
675
- const session = this.subagentSession;
676
- if (!session) return;
677
- // Detach first: callers must not admit a resume while asynchronous
678
- // extension shutdown is in progress.
679
- this.subagentSession = undefined;
680
- await session.dispose();
681
- }
682
-
683
- /** Release heavy session state while preserving the transcript pointer and record. */
684
- async releaseSession(): Promise<void> {
685
- const session = this.subagentSession;
686
- if (!session) return;
687
- this.releasedOutputFile = session.outputFile;
688
- // The record becomes non-resumable atomically at release admission, not
689
- // after extension shutdown finishes.
690
- this.subagentSession = undefined;
691
- this._sessionReleased = true;
692
- await session.dispose();
693
- }
694
-
695
- /** Fail a run: mark error, release listeners, best-effort workspace dispose, notify observer. */
696
- failRun(err: unknown): void {
697
- this.markError(err);
698
- this.listeners.release();
699
-
700
- try {
701
- this.workspaceBracket.dispose({ status: "error", description: this.description });
702
- } catch (cleanupErr) { debugLog("workspace dispose on agent error", cleanupErr); }
703
-
704
- runSafely("subagent onRunFinished observer", () => this.execution.observer?.onRunFinished?.(this));
705
- }
101
+ readonly id: string;
102
+ readonly type: SubagentType;
103
+ readonly description: string;
104
+ readonly invocation?: AgentInvocation;
105
+
106
+ private readonly state: SubagentState;
107
+ private readonly execution: SubagentExecution;
108
+ private readonly listeners = new RunListeners();
109
+ private readonly workspaceBracket: WorkspaceBracket;
110
+ private currentLease: RunLease;
111
+ private nextRunId = 1;
112
+ private _modelLabel: string;
113
+ private _effectiveThinkingLevel: ThinkingLevel;
114
+ private _sessionReleased = false;
115
+ private releasedOutputFile?: string;
116
+ private pendingResumeAbort?: AbortController;
117
+ private pendingSteers: string[] = [];
118
+ private readonly recordUpdateListeners = new Set<() => void>();
119
+ private runtimeMs = 0;
120
+
121
+ subagentSession?: SubagentSession;
122
+
123
+ get status(): SubagentStatus { return this.state.status; }
124
+ get result(): string | undefined { return this.state.result; }
125
+ get error(): string | undefined { return this.state.error; }
126
+ get startedAt(): number { return this.state.startedAt; }
127
+ get completedAt(): number | undefined { return this.state.completedAt; }
128
+ get consumedAt(): number | undefined { return this.state.consumedAt; }
129
+ get consumed(): boolean { return this.state.consumed; }
130
+ get toolUses(): number { return this.state.toolUses; }
131
+ get lifetimeUsage(): Readonly<LifetimeUsage> { return this.state.lifetimeUsage; }
132
+ get compactionCount(): number { return this.state.compactionCount; }
133
+ get turnCount(): number { return this.state.turnCount; }
134
+ get activeTools(): ReadonlyMap<string, string> { return this.state.activeTools; }
135
+ get responseText(): string { return this.state.responseText; }
136
+ get maxTurns(): number | undefined { return this.execution.maxTurns; }
137
+ get modelLabel(): string { return this._modelLabel; }
138
+ get effectiveThinkingLevel(): ThinkingLevel { return this._effectiveThinkingLevel; }
139
+ get runId(): number { return this.currentLease.runId; }
140
+ get mode(): SubagentMode { return this.currentLease.mode; }
141
+ get stopRequested(): boolean { return this.currentLease.stopRequest !== undefined; }
142
+ get stopReason(): SubagentStopReason | undefined { return this.currentLease.stopRequest; }
143
+ get stateTerminalReason(): SubagentTerminalReason | undefined { return this.state.terminalReason; }
144
+ get activeRuntimeMs(): number {
145
+ if (this.currentLease.admitted && !this.currentLease.settled && this.currentLease.startedAt != null) {
146
+ return Date.now() - this.currentLease.startedAt;
147
+ }
148
+ return this.currentLease.settled ? this.runtimeMs : 0;
149
+ }
150
+ get sessionReleased(): boolean { return this._sessionReleased; }
151
+ get pendingSteerCount(): number { return this.pendingSteers.length; }
152
+ get outputFile(): string | undefined { return this.subagentSession?.outputFile ?? this.releasedOutputFile; }
153
+ get toolCallId(): string | undefined { return this.execution.parentSession?.toolCallId; }
154
+ get settlement(): Promise<void> { return this.currentLease.settlement; }
155
+ get isSettled(): boolean { return this.currentLease.settled; }
156
+
157
+ constructor(init: SubagentInit) {
158
+ this.id = init.id;
159
+ this.type = init.type;
160
+ this.description = init.description;
161
+ this.invocation = init.invocation;
162
+ this.state = init.state ?? new SubagentState();
163
+ this.execution = init.execution;
164
+ this._modelLabel = formatModelLabel(this.execution.model ?? this.execution.snapshot.model);
165
+ this._effectiveThinkingLevel = resolveEffectiveThinkingLevel(
166
+ this.execution.model ?? this.execution.snapshot.model,
167
+ this.execution.thinkingLevel,
168
+ this.execution.snapshot.thinkingLevel,
169
+ );
170
+ this.workspaceBracket = new WorkspaceBracket(this.execution.getWorkspaceProvider ?? (() => undefined));
171
+ this.currentLease = this.createLease(1, "initial", this.execution.mode, this.execution.prompt, this.execution.timeoutSeconds);
172
+ // A terminal state can be supplied when a record is reconstructed for a
173
+ // read-only projection (for example, a service or renderer test). Keep
174
+ // the lease invariant aligned with that state instead of claiming that a
175
+ // terminal record still has work in flight. Manager-created records start
176
+ // queued and take the normal admission path below.
177
+ if (this.state.status === "completed" || this.state.status === "stopped" || this.state.status === "error") {
178
+ this.currentLease.settled = true;
179
+ this.currentLease.resolveSettlement();
180
+ } else if (this.state.status === "running") {
181
+ this.currentLease.admitted = true;
182
+ this.currentLease.startedAt = this.state.startedAt;
183
+ }
184
+ }
185
+
186
+ isSessionReady(): boolean { return this.subagentSession != null; }
187
+ isActive(): boolean { return !this.currentLease.settled; }
188
+ isRunning(): boolean { return this.currentLease.admitted && !this.currentLease.settled; }
189
+
190
+ /** Schedule this lease through the one shared FIFO limiter. */
191
+ scheduleVia(schedule: (task: () => Promise<void>) => AdmissionHandle): AdmissionHandle {
192
+ const lease = this.currentLease;
193
+ const handle = schedule(() => this.admitAndRun(lease));
194
+ lease.admission = handle;
195
+ if (lease.stopRequest && !handle.admitted) handle.cancel();
196
+ return handle;
197
+ }
198
+
199
+ /** Arm cancellation before admission; detached callers intentionally pass no signal. */
200
+ armParentSignal(signal: AbortSignal | undefined): void {
201
+ if (!signal) return;
202
+ const lease = this.currentLease;
203
+ const onAbort = (): void => { this.requestStop("parent_cancelled"); };
204
+ signal.addEventListener("abort", onAbort, { once: true });
205
+ lease.parentSignalCleanup = () => signal.removeEventListener("abort", onAbort);
206
+ if (signal.aborted) onAbort();
207
+ }
208
+
209
+ async steer(message: string): Promise<SteerOutcome> {
210
+ const lease = this.currentLease;
211
+ if (!this.isRunning() || lease.stopRequest) return { kind: "rejected", runId: lease.runId, status: this.status };
212
+ if (!this.subagentSession) {
213
+ this.pendingSteers.push(message);
214
+ return { kind: "buffered", runId: lease.runId };
215
+ }
216
+ await this.subagentSession.steer(message);
217
+ return { kind: "delivered", runId: lease.runId };
218
+ }
219
+
220
+ getConversation(): string | undefined { return this.subagentSession?.getConversation(); }
221
+ getContextPercent(): number | null { return this.subagentSession?.getContextPercent() ?? null; }
222
+ subscribeToUpdates(fn: (event: AgentSessionEvent) => void): (() => void) | undefined { return this.subagentSession?.subscribe(fn); }
223
+ /** Read-only lifecycle notification for adapters that bridge retention release. */
224
+ subscribeToRecordUpdates(fn: () => void): () => void {
225
+ this.recordUpdateListeners.add(fn);
226
+ return () => { this.recordUpdateListeners.delete(fn); };
227
+ }
228
+ get messages(): readonly unknown[] { return this.subagentSession?.messages ?? []; }
229
+ get agentMessages(): readonly SessionMessage[] { return this.subagentSession?.agentMessages ?? []; }
230
+ getToolDefinition(name: string): ToolDefinition | undefined { return this.subagentSession?.getToolDefinition(name); }
231
+
232
+ /** Request cooperative cancellation. The first reason owns this lease. */
233
+ requestStop(reason: SubagentStopReason): boolean {
234
+ const lease = this.currentLease;
235
+ if (lease.settled) return false;
236
+ lease.stopRequest ??= reason;
237
+ lease.executionController.abort(lease.stopRequest);
238
+ this.pendingResumeAbort?.abort(lease.stopRequest);
239
+ lease.admission?.cancel();
240
+ if (!lease.admitted) void this.finalizePendingStop(lease);
241
+ return true;
242
+ }
243
+
244
+ /** Reserve a retained session for resume before any asynchronous wait. */
245
+ reserveResume(
246
+ prompt: string,
247
+ mode: SubagentMode,
248
+ timeoutSeconds: number | undefined,
249
+ schedule: (task: () => Promise<void>) => AdmissionHandle,
250
+ signal?: AbortSignal,
251
+ admissionPath: "immediate" | "queued" = "immediate",
252
+ ): { accepted: true; runId: number } | { accepted: false; kind: "not_found" | "wrong_state"; status?: SubagentStatus } {
253
+ if (!this.subagentSession || this._sessionReleased) return { accepted: false, kind: "wrong_state", status: this.status };
254
+ if (this.isActive()) return { accepted: false, kind: "wrong_state", status: this.status };
255
+
256
+ const previous = this.currentLease;
257
+ const runId = ++this.nextRunId;
258
+ const lease = this.createLease(runId, "resume", mode, prompt, timeoutSeconds, admissionPath);
259
+ this.currentLease = lease;
260
+ this.state.resetForResume();
261
+ const resumeAbort = new AbortController();
262
+ this.pendingResumeAbort = resumeAbort;
263
+ // A detached resume is independent of the caller even while waiting for
264
+ // the prior turn to become idle. The private controller remains available
265
+ // for an explicit stop request.
266
+ const waitSignal = mode === "joined" && signal
267
+ ? AbortSignal.any([resumeAbort.signal, signal])
268
+ : resumeAbort.signal;
269
+ if (mode === "joined") this.armParentSignal(signal);
270
+
271
+ void this.waitAndScheduleResume(lease, previous, waitSignal, schedule);
272
+ return { accepted: true, runId };
273
+ }
274
+
275
+ private async waitAndScheduleResume(
276
+ lease: RunLease,
277
+ previous: RunLease,
278
+ signal: AbortSignal,
279
+ schedule: (task: () => Promise<void>) => AdmissionHandle,
280
+ ): Promise<void> {
281
+ try {
282
+ await previous.settlement;
283
+ await this.subagentSession?.waitUntilIdle(signal);
284
+ signal.throwIfAborted();
285
+ if (this.currentLease !== lease || lease.settled) return;
286
+ // Resume only enters the limiter after Pi's idle boundary. Queue time is
287
+ // therefore excluded from both the deadline and the active runtime.
288
+ lease.admission = schedule(() => this.admitAndRun(lease));
289
+ if (lease.stopRequest && !lease.admission.admitted) lease.admission.cancel();
290
+ } catch (error) {
291
+ if (this.currentLease !== lease || lease.settled) return;
292
+ if (lease.stopRequest || signal.aborted) {
293
+ lease.stopRequest ??= "parent_cancelled";
294
+ await this.finalizePendingStop(lease);
295
+ } else {
296
+ await this.finalizeError(lease, error, "execution_failure");
297
+ }
298
+ } finally {
299
+ if (this.currentLease === lease) this.pendingResumeAbort = undefined;
300
+ }
301
+ }
302
+
303
+ private async admitAndRun(lease: RunLease): Promise<void> {
304
+ if (this.currentLease !== lease || lease.settled) return;
305
+ if (lease.stopRequest) {
306
+ await this.finalizePendingStop(lease);
307
+ return;
308
+ }
309
+ lease.admitted = true;
310
+ lease.startedAt = Date.now();
311
+ this.runtimeMs = 0;
312
+ this.state.markRunning(lease.startedAt);
313
+ if (lease.timeoutSeconds != null) {
314
+ lease.runtimeTimer = setTimeout(() => this.requestStop("runtime_timeout"), lease.timeoutSeconds * 1000);
315
+ }
316
+ if (lease.phase === "resume") {
317
+ runSafely("subagent onResumedStarted observer", () => this.execution.observer?.onResumedStarted?.(this));
318
+ } else {
319
+ runSafely("subagent onStarted observer", () => this.execution.observer?.onStarted?.(this));
320
+ }
321
+ await this.executeLease(lease);
322
+ }
323
+
324
+ private async executeLease(lease: RunLease): Promise<void> {
325
+ try {
326
+ if (lease.phase === "resume") {
327
+ await this.executeResume(lease);
328
+ } else {
329
+ await this.executeInitial(lease);
330
+ }
331
+ } catch (error) {
332
+ // A stop request aborts both provider work and lifecycle callbacks. Those
333
+ // callbacks reject through the same cooperative signal, so this is a
334
+ // normal stopped run rather than an execution failure. Keep genuine
335
+ // failures classified below when no stop owns the lease.
336
+ if (lease.stopRequest && isCooperativeAbort(error, lease.executionController.signal)) {
337
+ await this.finalizeResult(lease, { responseText: "" }, lease.phase === "initial");
338
+ } else {
339
+ await this.finalizeError(lease, error, "execution_failure");
340
+ }
341
+ }
342
+ }
343
+
344
+ private async executeInitial(lease: RunLease): Promise<void> {
345
+ let cwd: string | undefined;
346
+ if (this.workspaceBracket.hasProvider()) {
347
+ cwd = await this.workspaceBracket.prepare({
348
+ agentId: this.id,
349
+ agentType: this.type,
350
+ baseCwd: this.execution.baseCwd,
351
+ invocation: this.invocation,
352
+ });
353
+ }
354
+ if (lease.stopRequest) {
355
+ // Admission has already happened, so finalize through the full run
356
+ // boundary. In particular, a stop arriving while workspace preparation
357
+ // is pending must dispose the prepared workspace exactly once rather
358
+ // than taking the queued-only fast path.
359
+ await this.finalizeResult(lease, { responseText: "" }, true);
360
+ return;
361
+ }
362
+
363
+ const session = await this.execution.createSubagentSession({
364
+ snapshot: this.execution.snapshot,
365
+ type: this.type,
366
+ cwd,
367
+ parentSession: this.execution.parentSession,
368
+ model: this.execution.model,
369
+ thinkingLevel: this.execution.thinkingLevel,
370
+ });
371
+ this.subagentSession = session;
372
+ this._modelLabel = formatModelLabel(session.model ?? this.execution.model ?? this.execution.snapshot.model);
373
+ this._effectiveThinkingLevel = session.thinkingLevel ?? this._effectiveThinkingLevel;
374
+
375
+ if (lease.stopRequest) {
376
+ this.releasedOutputFile = session.outputFile;
377
+ this.subagentSession = undefined;
378
+ await session.dispose();
379
+ return this.finalizeResult(lease, { responseText: "" }, true);
380
+ }
381
+
382
+ this.flushPendingSteers();
383
+ this.listeners.attachObserver(subscribeSubagentObserver(session, this.state, {
384
+ onCompact: (info) => runSafely("subagent onCompacted observer", () => this.execution.observer?.onCompacted?.(this, info)),
385
+ }));
386
+ runSafely("subagent onSessionCreated observer", () => this.execution.observer?.onSessionCreated?.(this));
387
+
388
+ const runConfig = this.execution.getRunConfig?.();
389
+ const lifecycle = this.createTurnLifecycle(lease);
390
+ const result = await session.runTurnLoop(lease.prompt, {
391
+ maxTurns: this.execution.maxTurns,
392
+ defaultMaxTurns: runConfig?.defaultMaxTurns,
393
+ graceTurns: runConfig?.graceTurns,
394
+ signal: lease.executionController.signal,
395
+ ...(lifecycle ? { lifecycle } : {}),
396
+ });
397
+ await this.finalizeResult(lease, result, true);
398
+ }
399
+
400
+ private async executeResume(lease: RunLease): Promise<void> {
401
+ const session = this.subagentSession;
402
+ if (!session) return this.finalizeError(lease, new Error("Subagent session was released while resuming"), "execution_failure");
403
+ this.listeners.attachObserver(subscribeSubagentObserver(session, this.state, {
404
+ onCompact: (info) => runSafely("subagent onCompacted observer", () => this.execution.observer?.onCompacted?.(this, info)),
405
+ }));
406
+ const lifecycle = this.createTurnLifecycle(lease);
407
+ if (lifecycle) {
408
+ const result = await session.resumeLifecycleTurnLoop(lease.prompt, lease.executionController.signal, lifecycle);
409
+ await this.finalizeResult(lease, result, false);
410
+ } else {
411
+ const result = await session.resumeTurnLoop(lease.prompt, lease.executionController.signal);
412
+ await this.finalizeResult(lease, {
413
+ responseText: result.text,
414
+ ...(result.failure ? { failure: result.failure } : {}),
415
+ ...(result.terminalReason ? { terminalReason: result.terminalReason } : {}),
416
+ }, false);
417
+ }
418
+ }
419
+
420
+ private createTurnLifecycle(lease: RunLease): SubagentTurnLifecycle | undefined {
421
+ const registry = this.execution.lifecycleInterceptors;
422
+ const session = this.subagentSession;
423
+ if (!registry?.hasInterceptors() || !session) return undefined;
424
+ const basePath = this.execution.executionPath ?? { origin: "service" as const, admission: "immediate" as const };
425
+ const execution: SubagentLifecycleExecutionPath = {
426
+ phase: lease.phase,
427
+ origin: basePath.origin,
428
+ mode: lease.mode,
429
+ admission: lease.admissionPath,
430
+ };
431
+ const lifecycleParent = this.execution.lifecycleParentSession ?? this.execution.parentSession;
432
+ return registry.createTurnLifecycle({
433
+ identity: {
434
+ agentId: this.id,
435
+ sessionId: session.sessionId,
436
+ runId: lease.runId,
437
+ agentType: this.type,
438
+ ...(lifecycleParent?.parentSessionId ? { parentSessionId: lifecycleParent.parentSessionId } : {}),
439
+ },
440
+ execution,
441
+ signal: lease.executionController.signal,
442
+ });
443
+ }
444
+
445
+ private async finalizeResult(lease: RunLease, result: TurnLoopResult, disposeWorkspace: boolean): Promise<void> {
446
+ if (this.currentLease !== lease || lease.settled) return;
447
+ this.listeners.release();
448
+ let finalResult = result.responseText;
449
+ if (disposeWorkspace) {
450
+ const terminalStatus: "completed" | "stopped" | "error" = result.failure
451
+ ? "error"
452
+ : lease.stopRequest || result.terminalReason === "turn_limit_hard" || result.terminalReason === "lifecycle_abort"
453
+ ? "stopped"
454
+ : "completed";
455
+ try {
456
+ finalResult += this.workspaceBracket.dispose({ status: terminalStatus, description: this.description });
457
+ } catch (error) {
458
+ return this.finalizeError(lease, error, "workspace_teardown_failure", finalResult);
459
+ }
460
+ }
461
+
462
+ if (result.failure) {
463
+ await this.finalizeTerminal(lease, "error", "provider_failure", finalResult, result.failure);
464
+ } else if (lease.stopRequest) {
465
+ await this.finalizeTerminal(lease, "stopped", lease.stopRequest, finalResult);
466
+ } else if (result.terminalReason === "lifecycle_abort") {
467
+ await this.finalizeTerminal(lease, "stopped", "lifecycle_abort", finalResult);
468
+ } else if (result.terminalReason === "turn_limit_hard") {
469
+ await this.finalizeTerminal(lease, "stopped", "turn_limit_hard", finalResult);
470
+ } else {
471
+ await this.finalizeTerminal(lease, "completed", result.terminalReason ?? "completed", finalResult);
472
+ }
473
+ }
474
+
475
+ private async finalizeError(
476
+ lease: RunLease,
477
+ error: unknown,
478
+ reason: "provider_failure" | "execution_failure" | "workspace_teardown_failure",
479
+ partialResult?: string,
480
+ ): Promise<void> {
481
+ if (this.currentLease !== lease || lease.settled) return;
482
+ this.listeners.release();
483
+ let finalResult = partialResult;
484
+ if (reason !== "workspace_teardown_failure") {
485
+ try {
486
+ finalResult = (finalResult ?? "") + this.workspaceBracket.dispose({ status: lease.stopRequest ? "stopped" : "error", description: this.description });
487
+ } catch (cleanupError) {
488
+ debugLog("workspace dispose on agent error", cleanupError);
489
+ reason = "workspace_teardown_failure";
490
+ error = cleanupError;
491
+ }
492
+ }
493
+ // An actual execution failure outranks a cancellation request. A normal
494
+ // cooperative abort resolves through finalizeResult and therefore still
495
+ // reports the lease's first stop reason; this branch is reserved for a
496
+ // genuine thrown failure while running or tearing down the child.
497
+ await this.finalizeTerminal(lease, "error", reason, finalResult, error);
498
+ }
499
+
500
+ private async finalizePendingStop(lease: RunLease): Promise<void> {
501
+ if (this.currentLease !== lease || lease.settled || lease.admitted) return;
502
+ await this.finalizeTerminal(lease, "stopped", lease.stopRequest ?? "explicit_stop");
503
+ }
504
+
505
+ private async finalizeTerminal(
506
+ lease: RunLease,
507
+ status: SubagentStatus,
508
+ reason: SubagentTerminalReason,
509
+ result?: string,
510
+ error?: unknown,
511
+ ): Promise<void> {
512
+ if (this.currentLease !== lease || lease.settled) return;
513
+ lease.settled = true;
514
+ lease.parentSignalCleanup?.();
515
+ lease.parentSignalCleanup = undefined;
516
+ if (lease.runtimeTimer) clearTimeout(lease.runtimeTimer);
517
+ this.runtimeMs = lease.admitted && lease.startedAt != null ? Math.max(0, Date.now() - lease.startedAt) : 0;
518
+
519
+ if (status === "completed") this.state.markCompleted(result ?? "", reason as "completed" | "turn_limit_graceful");
520
+ else if (status === "stopped") this.state.markStopped(result, reason as SubagentStopReason | "turn_limit_hard" | "lifecycle_abort");
521
+ else this.state.markError(error ?? result ?? "subagent execution failed", reason as "provider_failure" | "execution_failure" | "workspace_teardown_failure", result);
522
+
523
+ if (lease.phase === "resume") {
524
+ runSafely("subagent onResumedFinished observer", () => this.execution.observer?.onResumedFinished?.(this));
525
+ } else {
526
+ runSafely("subagent onRunFinished observer", () => this.execution.observer?.onRunFinished?.(this));
527
+ }
528
+ lease.resolveSettlement();
529
+ }
530
+
531
+ private createLease(
532
+ runId: number,
533
+ phase: "initial" | "resume",
534
+ mode: SubagentMode,
535
+ prompt: string,
536
+ timeoutSeconds?: number,
537
+ admissionPath: "immediate" | "queued" = "immediate",
538
+ ): RunLease {
539
+ const d = deferred();
540
+ return {
541
+ runId,
542
+ phase,
543
+ mode,
544
+ prompt,
545
+ timeoutSeconds,
546
+ executionController: new AbortController(),
547
+ settlement: d.promise,
548
+ resolveSettlement: d.resolve,
549
+ admitted: false,
550
+ settled: false,
551
+ admissionPath,
552
+ };
553
+ }
554
+
555
+ private flushPendingSteers(): void {
556
+ for (const message of this.pendingSteers) runDetached("subagent buffered steer", () => this.subagentSession?.steer(message));
557
+ this.pendingSteers = [];
558
+ }
559
+
560
+ incrementToolUses(): void { this.state.incrementToolUses(); }
561
+ addUsage(delta: { input: number; output: number; cacheWrite: number }): void { this.state.addUsage(delta); }
562
+ incrementCompactions(): void { this.state.incrementCompactions(); }
563
+ markConsumed(at?: number): void { this.state.markConsumed(at); }
564
+
565
+ async waitForSettlement(signal?: AbortSignal): Promise<void> {
566
+ if (!signal) return this.currentLease.settlement;
567
+ await settleOrAbort(this.currentLease.settlement, signal);
568
+ }
569
+
570
+ async disposeSession(): Promise<void> {
571
+ const session = this.subagentSession;
572
+ if (!session) return;
573
+ this.subagentSession = undefined;
574
+ await session.dispose();
575
+ }
576
+
577
+ async releaseSession(): Promise<void> {
578
+ const session = this.subagentSession;
579
+ if (!session) return;
580
+ this.releasedOutputFile = session.outputFile;
581
+ this.subagentSession = undefined;
582
+ this._sessionReleased = true;
583
+ for (const listener of this.recordUpdateListeners) runSafely("subagent release update", listener);
584
+ await session.dispose();
585
+ }
586
+ }
587
+
588
+ function isCooperativeAbort(error: unknown, signal: AbortSignal): boolean {
589
+ if (!signal.aborted) return false;
590
+ if (error === signal.reason) return true;
591
+ return error instanceof Error && /abort|cancel/i.test(error.message);
706
592
  }
707
593
 
708
- /** Resolve when either the run settles or the caller's wait is interrupted. */
709
594
  async function settleOrAbort(run: Promise<void>, signal: AbortSignal): Promise<void> {
710
- await new Promise<void>((resolve) => {
711
- let done = false;
712
- const finish = (): void => {
713
- if (done) return;
714
- done = true;
715
- signal.removeEventListener("abort", finish);
716
- resolve();
717
- };
718
- signal.addEventListener("abort", finish, { once: true });
719
- void run.then(finish, finish);
720
- });
595
+ if (signal.aborted) return;
596
+ await new Promise<void>((resolve) => {
597
+ let done = false;
598
+ const finish = (): void => {
599
+ if (done) return;
600
+ done = true;
601
+ signal.removeEventListener("abort", finish);
602
+ resolve();
603
+ };
604
+ signal.addEventListener("abort", finish, { once: true });
605
+ void run.then(finish, finish);
606
+ });
721
607
  }