@nklisch/pi-enhanced 0.2.7 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +27 -0
  15. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +76 -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 +118 -104
  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 -239
  37. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +569 -700
  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 -122
  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 +488 -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 -94
  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 +412 -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,52 +1,39 @@
1
- /**
2
- * subagent-manager.ts - Tracks subagents, background execution, resume support.
3
- *
4
- * Background agents are subject to a configurable concurrency limit (default: 4).
5
- * Excess agents are scheduled on a ConcurrencyLimiter and auto-started as running
6
- * agents complete. Foreground agents bypass the limiter (they block the parent anyway).
7
- */
1
+ /** Central parent-only registry for child run admission and lifecycle ownership. */
8
2
 
9
3
  import { randomUUID } from "node:crypto";
10
4
  import type { Model } from "@earendil-works/pi-ai";
11
5
  import { debugLog, runDetached, runSafely } from "#src/debug";
12
- import type { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
6
+ import type { AdmissionHandle, ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
13
7
  import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
14
- import {
8
+ import type {
15
9
  LifecycleInterceptorRegistry,
16
- type SubagentExecutionAdmission,
17
- type SubagentExecutionOrigin,
18
- type SubagentLifecycleInterceptor,
19
- type SubagentLifecycleRegistration,
10
+ SubagentExecutionAdmission,
11
+ SubagentExecutionOrigin,
12
+ SubagentLifecycleInterceptor,
13
+ SubagentLifecycleRegistration,
20
14
  } from "#src/lifecycle/lifecycle-interceptor";
15
+ import { LifecycleInterceptorRegistry as InterceptorRegistry } from "#src/lifecycle/lifecycle-interceptor";
21
16
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
22
17
  import { Subagent, type SubagentLifecycleObserver } from "#src/lifecycle/subagent";
23
18
  import type { SubagentSession } from "#src/lifecycle/subagent-session";
24
- import { SubagentState, type SubagentStatus } from "#src/lifecycle/subagent-state";
19
+ import { SubagentState, type SubagentStatus, type SubagentStopReason } from "#src/lifecycle/subagent-state";
25
20
  import type { WorkspaceProvider } from "#src/lifecycle/workspace";
26
-
27
21
  import type { RunConfig } from "#src/runtime";
28
- import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
22
+ import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentMode, SubagentType, ThinkingLevel } from "#src/types";
29
23
 
30
- /** Observer interface for agent lifecycle notifications. */
31
24
  export interface SubagentManagerObserver {
32
25
  onSubagentStarted(record: Subagent): void;
33
26
  onSubagentCompleted(record: Subagent): void;
34
- /** Fires when a retained session begins a resumed turn. */
35
27
  onSubagentResumedStarted?(record: Subagent): void;
36
28
  onSubagentResumed?(record: Subagent): void;
37
- /** Fires when clearCompleted removes a terminal record from the parent session. */
38
29
  onSubagentCleared?(record: Subagent): void;
39
30
  onSubagentCompacted(record: Subagent, info: CompactionInfo): void;
40
- /** Fires synchronously after a background agent record is created (before run). */
41
31
  onSubagentCreated(record: Subagent): void;
42
32
  }
43
33
 
44
34
  export interface SubagentManagerOptions {
45
- /** Assembly factory that produces a born-complete SubagentSession per spawn. */
46
35
  createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
47
- /** Concurrency limiter — schedules background run thunks FIFO against the limit. */
48
36
  limiter: ConcurrencyLimiter;
49
- /** Base working directory handed to a workspace provider (the parent cwd). */
50
37
  baseCwd: string;
51
38
  getRunConfig?: () => RunConfig;
52
39
  observer?: SubagentManagerObserver;
@@ -58,329 +45,225 @@ export interface AgentSpawnConfig {
58
45
  maxTurns?: number;
59
46
  inheritContext?: boolean;
60
47
  thinkingLevel?: ThinkingLevel;
61
- isBackground?: boolean;
62
- /**
63
- * Skip the maxConcurrent queue check for this spawn - start immediately even
64
- * if the configured concurrency limit would otherwise queue it. Useful for
65
- * callers (e.g. cross-extension RPC) that must not be deferred by the queue.
66
- */
67
- bypassQueue?: boolean;
68
- /** Resolved invocation snapshot captured for UI display. */
48
+ mode: SubagentMode;
49
+ timeoutSeconds?: number;
69
50
  invocation?: AgentInvocation;
70
- /** Parent abort signal - when aborted, the subagent is also stopped. */
71
51
  signal?: AbortSignal;
72
- /** Per-subagent lifecycle observer — replaces onSessionCreated callback. */
73
52
  observer?: SubagentLifecycleObserver;
74
- /** Parent session identity - grouped fields that travel together from the tool boundary. */
75
53
  parentSession?: ParentSessionInfo;
76
- /** Identity known to a service caller without changing its existing session setup. */
77
54
  lifecycleParentSession?: ParentSessionInfo;
78
- /** Which supported public entry path created this execution. */
79
55
  origin?: SubagentExecutionOrigin;
56
+ /** Internal hook used by joined tool delivery to stream after record creation. */
57
+ onCreated?: (record: Subagent) => void;
80
58
  }
81
59
 
60
+ export type DeliveryOutcome =
61
+ | { kind: "detached"; agentId: string; runId: number }
62
+ | { kind: "joined"; record: Subagent };
63
+
64
+ export type ResumeOutcome = DeliveryOutcome | { kind: "not_found"; agentId: string } | { kind: "wrong_state"; agentId: string; status: SubagentStatus };
65
+
66
+ export type StopOutcome =
67
+ | { kind: "stopped"; agentId: string; runId: number; reason: SubagentStopReason; record: Subagent }
68
+ | { kind: "stop_pending"; agentId: string; runId: number; reason: SubagentStopReason; record: Subagent }
69
+ | { kind: "already_terminal"; agentId: string; runId: number; record: Subagent }
70
+ | { kind: "not_found"; agentId: string };
71
+
72
+ export type ManagerSteerOutcome =
73
+ | { kind: "not_found"; agentId: string }
74
+ | ({ kind: "rejected"; status: SubagentStatus; runId: number })
75
+ | ({ kind: "delivered" | "buffered"; runId: number });
76
+
82
77
  export class SubagentManager {
83
- private agents = new Map<string, Subagent>();
84
- private cleanupInterval: ReturnType<typeof setInterval>;
78
+ private readonly agents = new Map<string, Subagent>();
79
+ private readonly cleanupInterval: ReturnType<typeof setInterval>;
85
80
  private readonly observer?: SubagentManagerObserver;
86
81
  private readonly createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
87
82
  private readonly limiter: ConcurrencyLimiter;
88
83
  private readonly baseCwd: string;
89
- private getRunConfig?: () => RunConfig;
90
- private _workspaceProvider?: WorkspaceProvider;
91
- private readonly lifecycleInterceptors = new LifecycleInterceptorRegistry();
84
+ private readonly getRunConfig?: () => RunConfig;
85
+ private workspace?: WorkspaceProvider;
86
+ private readonly lifecycleInterceptors: LifecycleInterceptorRegistry = new InterceptorRegistry();
92
87
  private disposalPromise?: Promise<void>;
93
88
 
94
- /** The registered workspace provider, or undefined when none is registered. */
95
- get workspaceProvider(): WorkspaceProvider | undefined {
96
- return this._workspaceProvider;
97
- }
98
-
99
89
  constructor(options: SubagentManagerOptions) {
100
90
  this.createSubagentSession = options.createSubagentSession;
101
91
  this.limiter = options.limiter;
102
92
  this.baseCwd = options.baseCwd;
103
93
  this.observer = options.observer;
104
94
  this.getRunConfig = options.getRunConfig;
105
- // Periodically release heavy terminal sessions according to retention policy.
106
- // Timer callbacks sit outside Pi's extension runner, so a malformed setting
107
- // or disposal failure must remain diagnostic rather than escape into Node.
108
- this.cleanupInterval = setInterval(() => {
109
- runDetached("retention cleanup", () => this.cleanup());
110
- }, 60_000);
95
+ this.cleanupInterval = setInterval(() => runDetached("retention cleanup", () => this.cleanup()), 60_000);
111
96
  this.cleanupInterval.unref();
112
97
  }
113
98
 
114
- /**
115
- * Register the single workspace provider. Throws if one is already
116
- * registered (chaining is out of scope — see ADR 0002). Returns a disposer
117
- * that clears the slot only if this provider is still the active one.
118
- */
99
+ get workspaceProvider(): WorkspaceProvider | undefined { return this.workspace; }
100
+
119
101
  registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
120
- if (this._workspaceProvider) {
121
- throw new Error(
122
- "A WorkspaceProvider is already registered; only one is supported.",
123
- );
124
- }
125
- this._workspaceProvider = provider;
126
- return () => {
127
- if (this._workspaceProvider === provider) this._workspaceProvider = undefined;
128
- };
102
+ if (this.workspace) throw new Error("A WorkspaceProvider is already registered; only one is supported.");
103
+ this.workspace = provider;
104
+ return () => { if (this.workspace === provider) this.workspace = undefined; };
129
105
  }
130
106
 
131
- /** Register a generative lifecycle provider without exposing manager internals. */
132
- registerLifecycleInterceptor(
133
- interceptor: SubagentLifecycleInterceptor,
134
- ): SubagentLifecycleRegistration {
107
+ registerLifecycleInterceptor(interceptor: SubagentLifecycleInterceptor): SubagentLifecycleRegistration {
135
108
  return this.lifecycleInterceptors.register(interceptor);
136
109
  }
137
110
 
138
- /** Compose a per-agent lifecycle observer from manager and spawn-config concerns. */
139
111
  private buildObserver(options: AgentSpawnConfig): SubagentLifecycleObserver {
140
112
  return {
141
- onStarted: (agent) => runSafely(
142
- "onSubagentStarted observer",
143
- () => this.observer?.onSubagentStarted(agent),
144
- ),
113
+ onStarted: (record) => runSafely("onSubagentStarted observer", () => this.observer?.onSubagentStarted(record)),
145
114
  onSessionCreated: options.observer?.onSessionCreated
146
- ? (agent) => runSafely(
147
- "onSessionCreated observer",
148
- () => options.observer!.onSessionCreated!(agent),
149
- )
115
+ ? (record) => runSafely("onSessionCreated observer", () => options.observer!.onSessionCreated!(record))
150
116
  : undefined,
151
- onRunFinished: (agent) => {
152
- if (options.isBackground) {
153
- runSafely("onSubagentCompleted observer", () => this.observer?.onSubagentCompleted(agent));
154
- }
155
- },
156
- onResumedFinished: (agent) => runSafely(
157
- "onSubagentResumed observer",
158
- () => this.observer?.onSubagentResumed?.(agent),
159
- ),
160
- onCompacted: (agent, info) => runSafely(
161
- "onSubagentCompacted observer",
162
- () => this.observer?.onSubagentCompacted(agent, info),
163
- ),
117
+ onRunFinished: (record) => runSafely("onSubagentCompleted observer", () => this.observer?.onSubagentCompleted(record)),
118
+ onResumedStarted: (record) => runSafely("onSubagentResumedStarted observer", () => this.observer?.onSubagentResumedStarted?.(record)),
119
+ onResumedFinished: (record) => runSafely("onSubagentResumed observer", () => this.observer?.onSubagentResumed?.(record)),
120
+ onCompacted: (record, info) => runSafely("onSubagentCompacted observer", () => this.observer?.onSubagentCompacted(record, info)),
164
121
  };
165
122
  }
166
123
 
167
- /**
168
- * Spawn an agent and return its ID immediately (for background use).
169
- * If the concurrency limit is reached, the agent is queued.
170
- */
171
- spawn(
172
- snapshot: ParentSnapshot,
173
- type: SubagentType,
174
- prompt: string,
175
- options: AgentSpawnConfig,
176
- ): string {
124
+ /** Synchronous creation plus shared FIFO admission. */
125
+ spawn(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: AgentSpawnConfig): Subagent {
177
126
  const id = randomUUID().slice(0, 17);
178
- const admission: SubagentExecutionAdmission = options.isBackground && !options.bypassQueue && this.limiter.isSaturated()
179
- ? "queued"
180
- : "immediate";
127
+ const admission: SubagentExecutionAdmission = this.limiter.isSaturated() ? "queued" : "immediate";
181
128
  const record = new Subagent({
182
129
  id,
183
130
  type,
184
131
  description: options.description,
185
132
  invocation: options.invocation,
186
- state: new SubagentState({
187
- status: options.isBackground ? "queued" : "running",
188
- startedAt: Date.now(),
189
- }),
133
+ state: new SubagentState(),
190
134
  execution: {
191
135
  createSubagentSession: this.createSubagentSession,
192
136
  snapshot,
193
137
  prompt,
194
138
  baseCwd: this.baseCwd,
139
+ mode: options.mode,
140
+ timeoutSeconds: options.timeoutSeconds,
195
141
  observer: this.buildObserver(options),
196
142
  getRunConfig: this.getRunConfig,
197
- getWorkspaceProvider: () => this._workspaceProvider,
143
+ getWorkspaceProvider: () => this.workspace,
198
144
  model: options.model,
199
145
  maxTurns: options.maxTurns,
200
146
  thinkingLevel: options.thinkingLevel,
201
147
  parentSession: options.parentSession,
202
148
  lifecycleParentSession: options.lifecycleParentSession,
203
- signal: options.signal,
204
149
  lifecycleInterceptors: this.lifecycleInterceptors,
205
150
  executionPath: {
206
- phase: "initial",
207
151
  origin: options.origin ?? "service",
208
- mode: options.isBackground ? "background" : "foreground",
209
152
  admission,
210
153
  },
211
154
  },
212
155
  });
213
156
  this.agents.set(id, record);
157
+ runSafely("onSubagentCreated hook", () => options.onCreated?.(record));
158
+ runSafely("onSubagentCreated observer", () => this.observer?.onSubagentCreated(record));
214
159
 
215
- if (options.isBackground) {
216
- runSafely("onSubagentCreated observer", () => this.observer?.onSubagentCreated(record));
217
- }
218
-
219
- if (options.isBackground && !options.bypassQueue) {
220
- // Schedule on the limiter — scheduleVia captures the limiter promise
221
- // eagerly, so a queued agent is awaitable from spawn; guardedRun guards
222
- // against abort-while-queued when the slot frees.
223
- record.scheduleVia((thunk) => this.limiter.schedule(thunk));
224
- return id;
225
- }
226
-
227
- record.start();
228
- return id;
160
+ // A joined caller is armed before it can enter the queue. Detached callers
161
+ // deliberately omit the signal so parent tool settlement cannot stop them.
162
+ if (options.mode === "joined") record.armParentSignal(options.signal);
163
+ record.scheduleVia((task) => this.limiter.schedule(task));
164
+ return record;
229
165
  }
230
166
 
231
- /**
232
- * Spawn an agent and wait for completion (foreground use).
233
- * Foreground agents bypass the concurrency queue.
234
- */
235
- async spawnAndWait(
236
- snapshot: ParentSnapshot,
237
- type: SubagentType,
238
- prompt: string,
239
- options: Omit<AgentSpawnConfig, "isBackground">,
240
- ): Promise<Subagent> {
241
- const id = this.spawn(snapshot, type, prompt, { ...options, isBackground: false });
242
- const record = this.agents.get(id)!;
243
- await record.promise;
244
- return record;
167
+ async launch(snapshot: ParentSnapshot, type: SubagentType, prompt: string, options: AgentSpawnConfig): Promise<DeliveryOutcome> {
168
+ // An already-cancelled detached call is rejected before creating work;
169
+ // once accepted, detached execution is independent of later cancellation.
170
+ if (options.signal?.aborted && options.mode === "detached") {
171
+ throw options.signal.reason instanceof Error ? options.signal.reason : new Error("Detached launch was already cancelled");
172
+ }
173
+ const record = this.spawn(snapshot, type, prompt, options);
174
+ if (options.mode === "detached") return { kind: "detached", agentId: record.id, runId: record.runId };
175
+ await record.settlement;
176
+ return { kind: "joined", record };
245
177
  }
246
178
 
247
- /**
248
- * Resume an existing agent session with a new prompt.
249
- * Delegates to Subagent.resume(), which owns the observer subscription lifecycle.
250
- */
251
179
  async resume(
252
180
  id: string,
253
181
  prompt: string,
182
+ mode: SubagentMode,
183
+ timeoutSeconds: number | undefined,
254
184
  signal?: AbortSignal,
255
- ): Promise<Subagent | undefined> {
256
- const agent = this.agents.get(id);
257
- if (!agent?.isSessionReady()) return undefined;
258
- const resumed = agent.resume(prompt, signal);
259
- try {
260
- this.observer?.onSubagentResumedStarted?.(agent);
261
- } catch (err) {
262
- debugLog("onSubagentResumedStarted observer", err);
185
+ onReserved?: (record: Subagent) => void,
186
+ ): Promise<ResumeOutcome> {
187
+ const record = this.agents.get(id);
188
+ if (!record) return { kind: "not_found", agentId: id };
189
+ if (signal?.aborted && mode === "detached") {
190
+ throw signal.reason instanceof Error ? signal.reason : new Error("Detached resume was already cancelled");
263
191
  }
264
- await resumed;
265
- return agent;
266
- }
267
-
268
- getRecord(id: string): Subagent | undefined {
269
- return this.agents.get(id);
270
- }
271
-
272
- listAgents(): Subagent[] {
273
- return [...this.agents.values()].sort(
274
- (a, b) => b.startedAt - a.startedAt,
192
+ const admission: SubagentExecutionAdmission = this.limiter.isSaturated() ? "queued" : "immediate";
193
+ const result = record.reserveResume(
194
+ prompt,
195
+ mode,
196
+ timeoutSeconds,
197
+ (task) => this.limiter.schedule(task),
198
+ mode === "joined" ? signal : undefined,
199
+ admission,
275
200
  );
201
+ if (!result.accepted) return { kind: "wrong_state", agentId: id, status: result.status ?? record.status };
202
+ runSafely("onSubagentResumed reserved hook", () => onReserved?.(record));
203
+ if (mode === "detached") return { kind: "detached", agentId: id, runId: result.runId };
204
+ await record.settlement;
205
+ return { kind: "joined", record };
276
206
  }
277
207
 
278
- abort(id: string): boolean {
208
+ async stop(id: string, settlementTimeoutSeconds = 5, waitSignal?: AbortSignal): Promise<StopOutcome> {
279
209
  const record = this.agents.get(id);
280
- if (!record) return false;
281
-
282
- // A queued agent has not started; terminate it through the observer funnel.
283
- // Its scheduled thunk becomes a no-op when its slot eventually opens.
284
- if (record.status === "queued") {
285
- record.stopQueued();
286
- return true;
287
- }
288
-
289
- return record.abort();
210
+ if (!record) return { kind: "not_found", agentId: id };
211
+ const runId = record.runId;
212
+ if (!record.isActive()) return { kind: "already_terminal", agentId: id, runId, record };
213
+
214
+ // The child stop request is deliberately independent of the caller's wait:
215
+ // aborting the parent tool must not undo cancellation, but it may return the
216
+ // bounded stop report early when the child is uncooperative.
217
+ record.requestStop("explicit_stop");
218
+ const settled = await waitWithTimeout(record.settlement, settlementTimeoutSeconds * 1000, waitSignal);
219
+ const reason = record.stopReason ?? "explicit_stop";
220
+ return settled
221
+ ? { kind: "stopped", agentId: id, runId, reason, record }
222
+ : { kind: "stop_pending", agentId: id, runId, reason, record };
290
223
  }
291
224
 
292
- /** Dispose a record's session and remove it from the map. */
293
- private async removeRecord(id: string, record: Subagent): Promise<void> {
294
- // Remove first so no caller can acquire a record while its extensions are
295
- // shutting down asynchronously.
296
- this.agents.delete(id);
297
- await record.disposeSession();
225
+ async steer(id: string, message: string): Promise<ManagerSteerOutcome> {
226
+ const record = this.agents.get(id);
227
+ if (!record) return { kind: "not_found", agentId: id };
228
+ return record.steer(message);
298
229
  }
299
230
 
300
- private async cleanup(): Promise<void> {
301
- const now = Date.now();
302
- const config = this.getRunConfig?.();
303
- const consumedMinutes = config?.consumedSessionRetentionMinutes ?? 10;
304
- const unconsumedMinutes = config?.unconsumedSessionRetentionMinutes ?? 720;
231
+ getRecord(id: string): Subagent | undefined { return this.agents.get(id); }
305
232
 
306
- const releases: Promise<void>[] = [];
307
- for (const record of this.agents.values()) {
308
- if (record.isActive() || !record.isSessionReady()) continue;
309
- const anchor = record.consumed
310
- ? record.consumedAt ?? record.completedAt
311
- : record.completedAt;
312
- if (anchor == null) continue;
313
- const retentionMinutes = record.consumed ? consumedMinutes : unconsumedMinutes;
314
- if (anchor + retentionMinutes * 60_000 > now) continue;
315
- // Keep the lightweight terminal record and result for the whole parent
316
- // session; only release the heavy in-memory child session.
317
- releases.push(record.releaseSession());
318
- }
319
- await Promise.all(releases);
233
+ listAgents(): Subagent[] {
234
+ return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt);
320
235
  }
321
236
 
322
- /**
323
- * Remove all completed/stopped/errored records immediately.
324
- * Called on session start/switch so tasks from a prior session don't persist.
325
- */
326
237
  async clearCompleted(): Promise<void> {
327
238
  const disposals: Promise<void>[] = [];
328
239
  for (const [id, record] of this.agents) {
329
240
  if (record.isActive()) continue;
330
- try {
331
- this.observer?.onSubagentCleared?.(record);
332
- } catch (err) {
333
- debugLog("onSubagentCleared observer", err);
334
- }
335
- disposals.push(this.removeRecord(id, record));
241
+ runSafely("onSubagentCleared observer", () => this.observer?.onSubagentCleared?.(record));
242
+ this.agents.delete(id);
243
+ disposals.push(record.disposeSession());
336
244
  }
337
245
  await Promise.all(disposals);
338
246
  }
339
247
 
340
- /** Whether any agents are still running or queued. */
341
- // fallow-ignore-next-line unused-class-member
342
- hasRunning(): boolean {
343
- return [...this.agents.values()].some((record) => record.isActive());
344
- }
248
+ hasRunning(): boolean { return [...this.agents.values()].some((record) => record.isActive()); }
345
249
 
346
- /** Abort all running and queued agents immediately. */
347
- // fallow-ignore-next-line unused-class-member
250
+ /** Parent interruption is a cancellation request, not an immediate terminal transition. */
348
251
  abortAll(): number {
349
252
  let count = 0;
350
253
  for (const record of this.agents.values()) {
351
- if (record.status === "queued") {
352
- record.stopQueued();
353
- count++;
354
- } else if (record.abort()) {
355
- count++;
356
- }
254
+ if (record.requestStop("parent_cancelled")) count++;
357
255
  }
358
- // Drop pending thunks (their promises resolve).
359
- this.limiter.clear();
360
256
  return count;
361
257
  }
362
258
 
363
- /** Wait for all running and queued agents to complete (including queued ones). */
364
- // fallow-ignore-next-line unused-class-member
365
259
  async waitForAll(): Promise<void> {
366
- // Every spawned agent has a settled-on-completion promise (the limiter starts
367
- // queued ones as slots free), so a single allSettled covers the queued case.
368
- // The loop only catches agents spawned during the wait.
369
- let pending = this.pendingPromises();
370
- while (pending.length > 0) {
260
+ for (;;) {
261
+ const pending = [...this.agents.values()].filter((record) => record.isActive()).map((record) => record.settlement);
262
+ if (pending.length === 0) return;
371
263
  await Promise.allSettled(pending);
372
- pending = this.pendingPromises();
373
264
  }
374
265
  }
375
266
 
376
- /** Promises of all running/queued agents that have one. */
377
- private pendingPromises(): Promise<void>[] {
378
- return [...this.agents.values()]
379
- .filter((record) => record.isActive())
380
- .map(r => r.promise)
381
- .filter((p): p is Promise<void> => p != null);
382
- }
383
-
384
267
  dispose(): Promise<void> {
385
268
  this.disposalPromise ??= this.disposeOnce();
386
269
  return this.disposalPromise;
@@ -388,19 +271,61 @@ export class SubagentManager {
388
271
 
389
272
  private async disposeOnce(): Promise<void> {
390
273
  clearInterval(this.cleanupInterval);
391
- // Lifecycle callbacks observe the shutdown signal before their registration
392
- // disposer runs. Await their finalizers before child extension teardown.
393
- try {
394
- await this.lifecycleInterceptors.dispose();
395
- } catch (error) {
396
- debugLog("lifecycle interceptor shutdown", error);
397
- }
398
-
399
- // Drop pending thunks and make every record unreachable before awaiting
400
- // extension shutdown. No new resume can race teardown from this point.
401
- this.limiter.clear();
274
+ // Abort first so in-flight lifecycle callbacks receive the same cooperative
275
+ // signal as provider and tool work before their registrations are retired.
276
+ this.abortAll();
277
+ const interceptorsDisposed = await waitWithTimeout(this.lifecycleInterceptors.dispose(), 5_000);
278
+ if (!interceptorsDisposed) debugLog("lifecycle interceptor shutdown timeout", new Error("A lifecycle interceptor did not cooperate before shutdown"));
279
+ const settled = await waitWithTimeout(this.waitForAll(), 5_000);
280
+ if (!settled) debugLog("manager shutdown settlement timeout", new Error("One or more subagents did not cooperate before shutdown"));
402
281
  const records = [...this.agents.values()];
403
282
  this.agents.clear();
404
283
  await Promise.all(records.map((record) => record.disposeSession()));
405
284
  }
285
+
286
+ private async cleanup(): Promise<void> {
287
+ const now = Date.now();
288
+ const config = this.getRunConfig?.();
289
+ const consumedRetentionMinutes = config?.consumedSessionRetentionMinutes ?? 10;
290
+ const unconsumedRetentionMinutes = config?.unconsumedSessionRetentionMinutes ?? 720;
291
+ const releases: Promise<void>[] = [];
292
+ for (const record of this.agents.values()) {
293
+ if (record.isActive() || !record.isSessionReady() || record.completedAt == null) continue;
294
+ // Consumption changes the retention clock: once a result has actually
295
+ // been delivered, retain it for the shorter consumed window and anchor
296
+ // that window at delivery. Unconsumed sessions retain the existing
297
+ // longer grace period from completion so a parent can still resume them.
298
+ const retentionMinutes = record.consumed ? consumedRetentionMinutes : unconsumedRetentionMinutes;
299
+ const anchor = record.consumed ? (record.consumedAt ?? record.completedAt) : record.completedAt;
300
+ if (anchor + retentionMinutes * 60_000 > now) continue;
301
+ releases.push(record.releaseSession());
302
+ }
303
+ await Promise.all(releases);
304
+ }
305
+ }
306
+
307
+ async function waitWithTimeout(promise: Promise<void>, timeoutMs: number, signal?: AbortSignal): Promise<boolean> {
308
+ if (timeoutMs <= 0 || signal?.aborted) return false;
309
+ let timer: ReturnType<typeof setTimeout> | undefined;
310
+ let removeAbort: (() => void) | undefined;
311
+ const timeout = new Promise<false>((resolve) => {
312
+ timer = setTimeout(() => resolve(false), timeoutMs);
313
+ });
314
+ const callerAbort = signal
315
+ ? new Promise<false>((resolve) => {
316
+ const onAbort = (): void => resolve(false);
317
+ signal.addEventListener("abort", onAbort, { once: true });
318
+ removeAbort = () => signal.removeEventListener("abort", onAbort);
319
+ })
320
+ : undefined;
321
+ try {
322
+ return await Promise.race([
323
+ promise.then(() => true as const),
324
+ timeout,
325
+ ...(callerAbort ? [callerAbort] : []),
326
+ ]);
327
+ } finally {
328
+ if (timer) clearTimeout(timer);
329
+ removeAbort?.();
330
+ }
406
331
  }