@jopqior/pi-subagents 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +2705 -0
  2. package/LICENSE +21 -0
  3. package/README.md +503 -0
  4. package/dist/public.d.ts +331 -0
  5. package/dist/settings.d.ts +82 -0
  6. package/docs/architecture/architecture.md +1566 -0
  7. package/docs/architecture/client-server-opportunities.md +127 -0
  8. package/docs/architecture/history/phase-1-api-boundary.md +8 -0
  9. package/docs/architecture/history/phase-10-structural-decomposition.md +141 -0
  10. package/docs/architecture/history/phase-11-closure-to-class.md +100 -0
  11. package/docs/architecture/history/phase-12-complexity-test-fixtures.md +55 -0
  12. package/docs/architecture/history/phase-13-remaining-smells.md +88 -0
  13. package/docs/architecture/history/phase-14-strip-policy.md +49 -0
  14. package/docs/architecture/history/phase-15-domain-model-evolution.md +73 -0
  15. package/docs/architecture/history/phase-16-invert-dependencies.md +144 -0
  16. package/docs/architecture/history/phase-17-core-consolidation.md +214 -0
  17. package/docs/architecture/history/phase-18-reconsider-ui.md +166 -0
  18. package/docs/architecture/history/phase-19-implement-ui-decisions.md +282 -0
  19. package/docs/architecture/history/phase-2-remove-scheduling.md +9 -0
  20. package/docs/architecture/history/phase-20-result-delivery.md +245 -0
  21. package/docs/architecture/history/phase-21-classification-model-boundary.md +107 -0
  22. package/docs/architecture/history/phase-3-remove-rpc-groupjoin.md +11 -0
  23. package/docs/architecture/history/phase-4-implement-service.md +8 -0
  24. package/docs/architecture/history/phase-5-decompose-index.md +42 -0
  25. package/docs/architecture/history/phase-7-encapsulation.md +173 -0
  26. package/docs/architecture/history/phase-8-testability.md +103 -0
  27. package/docs/architecture/history/phase-9-observation-ctx.md +122 -0
  28. package/docs/comparison-with-upstream.md +77 -0
  29. package/docs/configuration.md +364 -0
  30. package/docs/decisions/0001-deferred-patches.md +80 -0
  31. package/docs/decisions/0002-extensions-on-a-minimal-core.md +125 -0
  32. package/docs/decisions/0003-publish-bundled-type-declarations.md +71 -0
  33. package/docs/decisions/0004-reconsider-ui-direction.md +279 -0
  34. package/docs/decisions/0005-subagent-record-admission-policy.md +106 -0
  35. package/docs/decisions/0006-inherited-prompt-is-identity-only.md +104 -0
  36. package/docs/decisions/0007-transcript-viewer-is-not-an-overlay.md +228 -0
  37. package/docs/decisions/0008-inherited-region-is-shared-parts.md +81 -0
  38. package/docs/decisions/0009-portable-inheritance-is-provider-scoped.md +116 -0
  39. package/package.json +91 -0
  40. package/src/config/agent-types.ts +135 -0
  41. package/src/config/custom-agents.ts +151 -0
  42. package/src/config/default-agents.ts +121 -0
  43. package/src/config/invocation-config.ts +167 -0
  44. package/src/config/thinking-level.ts +58 -0
  45. package/src/debug.ts +14 -0
  46. package/src/handlers/index.ts +3 -0
  47. package/src/handlers/interrupt.ts +58 -0
  48. package/src/handlers/lifecycle.ts +71 -0
  49. package/src/handlers/widget-events.ts +49 -0
  50. package/src/index.ts +292 -0
  51. package/src/layered-settings.ts +105 -0
  52. package/src/lifecycle/child-lifecycle.ts +115 -0
  53. package/src/lifecycle/child-shutdown.ts +105 -0
  54. package/src/lifecycle/concurrency-limiter.ts +55 -0
  55. package/src/lifecycle/create-subagent-session.ts +335 -0
  56. package/src/lifecycle/parent-snapshot.ts +119 -0
  57. package/src/lifecycle/run-listeners.ts +37 -0
  58. package/src/lifecycle/selection-scope.ts +116 -0
  59. package/src/lifecycle/spawn-selection.ts +259 -0
  60. package/src/lifecycle/subagent-manager.ts +546 -0
  61. package/src/lifecycle/subagent-session.ts +347 -0
  62. package/src/lifecycle/subagent-state.ts +404 -0
  63. package/src/lifecycle/subagent.ts +885 -0
  64. package/src/lifecycle/turn-limits.ts +13 -0
  65. package/src/lifecycle/usage.ts +60 -0
  66. package/src/lifecycle/workspace-bracket.ts +76 -0
  67. package/src/lifecycle/workspace.ts +46 -0
  68. package/src/observation/composite-subagent-observer.ts +74 -0
  69. package/src/observation/notification.ts +430 -0
  70. package/src/observation/outcome-delivery.ts +239 -0
  71. package/src/observation/record-observer.ts +78 -0
  72. package/src/observation/renderer.ts +161 -0
  73. package/src/observation/subagent-events-observer.ts +148 -0
  74. package/src/runtime.ts +137 -0
  75. package/src/service/service-adapter.ts +201 -0
  76. package/src/service/service.ts +246 -0
  77. package/src/session/ask-parent-tool.ts +69 -0
  78. package/src/session/content-items.ts +53 -0
  79. package/src/session/context.ts +80 -0
  80. package/src/session/conversation.ts +49 -0
  81. package/src/session/env.ts +40 -0
  82. package/src/session/model-resolver.ts +126 -0
  83. package/src/session/notify-parent-tool.ts +83 -0
  84. package/src/session/package-exclusions.ts +75 -0
  85. package/src/session/prompts.ts +231 -0
  86. package/src/session/provider-inheritance.ts +56 -0
  87. package/src/session/selection-catalogue.ts +143 -0
  88. package/src/session/session-config.ts +202 -0
  89. package/src/session/session-dir.ts +38 -0
  90. package/src/settings.ts +447 -0
  91. package/src/tools/agent-tool.ts +305 -0
  92. package/src/tools/background-spawner.ts +83 -0
  93. package/src/tools/foreground-runner.ts +159 -0
  94. package/src/tools/get-result-renderer.ts +119 -0
  95. package/src/tools/get-result-report.ts +84 -0
  96. package/src/tools/get-result-tool.ts +192 -0
  97. package/src/tools/helpers.ts +118 -0
  98. package/src/tools/result-renderer.ts +153 -0
  99. package/src/tools/spawn-config.ts +192 -0
  100. package/src/tools/steer-tool.ts +109 -0
  101. package/src/types.ts +143 -0
  102. package/src/ui/agent-widget.ts +333 -0
  103. package/src/ui/bounded-lines.ts +45 -0
  104. package/src/ui/display.ts +180 -0
  105. package/src/ui/glyphs.ts +62 -0
  106. package/src/ui/session-navigation.ts +150 -0
  107. package/src/ui/session-navigator.ts +255 -0
  108. package/src/ui/subagents-settings.ts +179 -0
  109. package/src/ui/transcript-content.ts +374 -0
  110. package/src/ui/widget-renderer.ts +301 -0
@@ -0,0 +1,546 @@
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
+ */
8
+
9
+ import { randomUUID } from "node:crypto";
10
+ import type { Model } from "@earendil-works/pi-ai";
11
+ import { type BackgroundRequest, resolveBackgroundMode } from "#src/config/invocation-config";
12
+ import { debugLog } from "#src/debug";
13
+ import type { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
14
+ import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent-session";
15
+ import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
16
+ import type { SelectionScopeHandle } from "#src/lifecycle/selection-scope";
17
+ import { type ResumeRefusal, Subagent, type SubagentLifecycleObserver } from "#src/lifecycle/subagent";
18
+ import type { SubagentSession } from "#src/lifecycle/subagent-session";
19
+ import { SubagentState } from "#src/lifecycle/subagent-state";
20
+ import type { WorkspaceProvider } from "#src/lifecycle/workspace";
21
+
22
+ import type { RunConfig } from "#src/runtime";
23
+ import type { AgentConfig, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
24
+
25
+ /**
26
+ * The agent-registry slice the manager needs to resolve a spawn. Deliberately
27
+ * narrower than AgentConfigLookup, whose slice serves session assembly (ISP).
28
+ */
29
+ export interface SpawnTypeResolver {
30
+ resolveType(name: string): string | undefined;
31
+ isValidType(type: string): boolean;
32
+ resolveAgentConfig(type: string): AgentConfig;
33
+ }
34
+
35
+ /**
36
+ * Why a resume was refused, across every front door.
37
+ *
38
+ * Widens the record's own vocabulary by the one refusal that is not a fact
39
+ * about a record: an id no record answers to.
40
+ */
41
+ export type ResumeRefusalReason = ResumeRefusal | "unknown-agent";
42
+
43
+ /**
44
+ * What a resume attempt produced: the record whose run was restarted, or the
45
+ * reason nothing was started.
46
+ *
47
+ * A resumed run that *failed* is still `resumed` — the record carries the
48
+ * error. `refused` means the turn loop never ran.
49
+ */
50
+ export type ResumeOutcome =
51
+ | { kind: "resumed"; record: Subagent }
52
+ | { kind: "refused"; reason: ResumeRefusalReason };
53
+
54
+ /** Per-call knobs for a resume; both doors pass their own. */
55
+ export interface ResumeCallOptions {
56
+ /** Cancels the resumed turn loop. A resume does not run under the record's own controller. */
57
+ signal?: AbortSignal;
58
+ /**
59
+ * The caller will deliver this outcome to the parent, so nothing announces
60
+ * it. Omitted, the resumed outcome is announced like any other completion.
61
+ */
62
+ claimOutcome?: boolean;
63
+ }
64
+
65
+ /** A spawn's resolved identity and mode — the invariants every front door shares. */
66
+ interface ResolvedSpawn {
67
+ type: SubagentType;
68
+ isBackground: boolean;
69
+ }
70
+
71
+ /**
72
+ * Session-retention windows (minutes). `SettingsManager` satisfies this
73
+ * structurally; a live getter (`getRetentionPolicy`) lets the sweep read the
74
+ * current values without a construction-time settings dependency.
75
+ */
76
+ export interface RetentionPolicy {
77
+ readonly consumedSessionRetentionMinutes: number;
78
+ readonly unconsumedSessionRetentionMinutes: number;
79
+ }
80
+
81
+ const DEFAULT_RETENTION_POLICY: RetentionPolicy = {
82
+ consumedSessionRetentionMinutes: 10,
83
+ unconsumedSessionRetentionMinutes: 720,
84
+ };
85
+
86
+ /**
87
+ * Only what the retention rule reads. Narrower than `Subagent` so the rule
88
+ * stays a plain function over four facts, testable without spawning an agent.
89
+ */
90
+ export interface RetentionCandidate {
91
+ consumed: boolean;
92
+ completedAt: number | undefined;
93
+ consumedAt: number | undefined;
94
+ pendingQuestion: string | undefined;
95
+ }
96
+
97
+ /** When a terminal record's session-release window opened, and how long it runs. */
98
+ export interface RetentionWindow {
99
+ referenceAt: number;
100
+ windowMinutes: number;
101
+ }
102
+
103
+ /**
104
+ * Pick the retention window for one terminal record.
105
+ *
106
+ * A collected outcome releases on the short window, measured from the later of
107
+ * completion and collection, so a late read still gets a full resume window; an
108
+ * uncollected one holds until the long safety cap.
109
+ *
110
+ * A record carrying an unanswered question is not collected, whatever
111
+ * `consumed` says: the parent has read the question but has not answered it,
112
+ * and the answer is delivered by resuming the very session the short window
113
+ * would release.
114
+ */
115
+ export function resolveRetentionWindow(
116
+ record: RetentionCandidate,
117
+ policy: RetentionPolicy,
118
+ ): RetentionWindow {
119
+ if (record.consumed && record.pendingQuestion === undefined) {
120
+ return {
121
+ referenceAt: Math.max(record.completedAt ?? 0, record.consumedAt ?? 0),
122
+ windowMinutes: policy.consumedSessionRetentionMinutes,
123
+ };
124
+ }
125
+ return {
126
+ referenceAt: record.completedAt ?? 0,
127
+ windowMinutes: policy.unconsumedSessionRetentionMinutes,
128
+ };
129
+ }
130
+
131
+ /** Observer interface for agent lifecycle notifications. */
132
+ export interface SubagentManagerObserver {
133
+ onSubagentStarted(record: Subagent): void;
134
+ onSubagentCompleted(record: Subagent): void;
135
+ /**
136
+ * Fires when a resume starts, from whichever front door asked for it.
137
+ * Required: a consumer that tracks the widget's live set has to learn that a
138
+ * settled record went back to running, and the only alternative is polling.
139
+ */
140
+ onSubagentResuming(record: Subagent): void;
141
+ /** Fires when a resumed run reaches a terminal state (distinct from a fresh completion). */
142
+ onSubagentResumed(record: Subagent): void;
143
+ /**
144
+ * Fires when a running child sends its parent a mid-run message.
145
+ * Optional: the widget has no use for it, and a hook nobody supplies is a
146
+ * vacant one.
147
+ */
148
+ onSubagentUpdate?(record: Subagent, message: string): void;
149
+ /**
150
+ * Fires when a teardown after the record's result was delivered reported
151
+ * where its work went.
152
+ * Optional for the same reason as `onSubagentUpdate`: the widget has no use
153
+ * for it, and a hook nobody supplies is a vacant one.
154
+ */
155
+ onSubagentWorkspaceNotice?(record: Subagent, notice: string): void;
156
+ onSubagentCompacted(record: Subagent, info: CompactionInfo): void;
157
+ /** Fires synchronously after a background agent record is created (before run). */
158
+ onSubagentCreated(record: Subagent): void;
159
+ }
160
+
161
+ export interface SubagentManagerOptions {
162
+ /** Assembly factory that produces a born-complete SubagentSession per spawn. */
163
+ createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
164
+ /** Concurrency limiter — schedules background run thunks FIFO against the limit. */
165
+ limiter: ConcurrencyLimiter;
166
+ /** Base working directory handed to a workspace provider (the parent cwd). */
167
+ baseCwd: string;
168
+ getRunConfig?: () => RunConfig;
169
+ /** Live accessor for the session-retention windows; defaults applied when absent. */
170
+ getRetentionPolicy?: () => RetentionPolicy;
171
+ observer?: SubagentManagerObserver;
172
+ /** Agent registry, consulted to canonicalize a spawn's type and resolve its config. */
173
+ registry: SpawnTypeResolver;
174
+ /**
175
+ * The spawning session's retained selection scope, threaded into every
176
+ * record — queued records included — so an admitted run consults the tree's
177
+ * active provider before creating its child session.
178
+ */
179
+ selectionScope: SelectionScopeHandle;
180
+ }
181
+
182
+ export interface AgentSpawnConfig {
183
+ description: string;
184
+ model?: Model<any>;
185
+ maxTurns?: number;
186
+ inheritContext?: boolean;
187
+ thinkingLevel?: ThinkingLevel;
188
+ /**
189
+ * Whether this door has committed to a background mode or is offering a
190
+ * default the agent's frontmatter may override. Required so a new front door
191
+ * cannot silently inherit another's policy.
192
+ */
193
+ background: BackgroundRequest;
194
+ /**
195
+ * Skip the maxConcurrent queue check for this spawn - start immediately even
196
+ * if the configured concurrency limit would otherwise queue it. Useful for
197
+ * callers (e.g. cross-extension RPC) that must not be deferred by the queue.
198
+ */
199
+ bypassQueue?: boolean;
200
+ /** Parent abort signal - when aborted, the subagent is also stopped. */
201
+ signal?: AbortSignal;
202
+ /** Per-subagent lifecycle observer — replaces onSessionCreated callback. */
203
+ observer?: SubagentLifecycleObserver;
204
+ /** Parent session identity - grouped fields that travel together from the tool boundary. */
205
+ parentSession?: ParentSessionInfo;
206
+ }
207
+
208
+ export class SubagentManager {
209
+ private agents = new Map<string, Subagent>();
210
+ private sweepInterval: ReturnType<typeof setInterval>;
211
+ private readonly observer?: SubagentManagerObserver;
212
+ private readonly createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
213
+ private readonly limiter: ConcurrencyLimiter;
214
+ private readonly baseCwd: string;
215
+ private getRunConfig?: () => RunConfig;
216
+ private getRetentionPolicy?: () => RetentionPolicy;
217
+ private readonly registry: SpawnTypeResolver;
218
+ private readonly selectionScope: SelectionScopeHandle;
219
+ private _workspaceProvider?: WorkspaceProvider;
220
+
221
+ /** The registered workspace provider, or undefined when none is registered. */
222
+ get workspaceProvider(): WorkspaceProvider | undefined {
223
+ return this._workspaceProvider;
224
+ }
225
+
226
+ constructor(options: SubagentManagerOptions) {
227
+ this.createSubagentSession = options.createSubagentSession;
228
+ this.limiter = options.limiter;
229
+ this.baseCwd = options.baseCwd;
230
+ this.observer = options.observer;
231
+ this.getRunConfig = options.getRunConfig;
232
+ this.getRetentionPolicy = options.getRetentionPolicy;
233
+ this.registry = options.registry;
234
+ this.selectionScope = options.selectionScope;
235
+ // Periodically release the heavy session of terminal agents past their
236
+ // retention window. The lightweight record (with its result) is kept for the
237
+ // session lifetime, so get_subagent_result never misses in-session.
238
+ this.sweepInterval = setInterval(() => this.sweep(), 60_000);
239
+ this.sweepInterval.unref();
240
+ }
241
+
242
+ /**
243
+ * Register the single workspace provider. Throws if one is already
244
+ * registered (chaining is out of scope — see ADR 0002). Returns a disposer
245
+ * that clears the slot only if this provider is still the active one.
246
+ */
247
+ registerWorkspaceProvider(provider: WorkspaceProvider): () => void {
248
+ if (this._workspaceProvider) {
249
+ throw new Error(
250
+ "A WorkspaceProvider is already registered; only one is supported.",
251
+ );
252
+ }
253
+ this._workspaceProvider = provider;
254
+ return () => {
255
+ if (this._workspaceProvider === provider) this._workspaceProvider = undefined;
256
+ };
257
+ }
258
+
259
+ /** Compose a per-agent lifecycle observer from manager and spawn-config concerns. */
260
+ private buildObserver(options: AgentSpawnConfig): SubagentLifecycleObserver {
261
+ return {
262
+ onStarted: (agent) => {
263
+ this.observer?.onSubagentStarted(agent);
264
+ },
265
+ onSessionCreated: options.observer?.onSessionCreated
266
+ ? (agent) => options.observer!.onSessionCreated!(agent)
267
+ : undefined,
268
+ // Terminal transitions are reported for every agent. Whether the parent
269
+ // needs telling is the notification layer's decision, made from the
270
+ // carrier claim; suppressing the observer here would also suppress the
271
+ // lifecycle event and the session-history record, which are facts about
272
+ // the run rather than announcements.
273
+ onRunFinished: (agent) => {
274
+ try { this.observer?.onSubagentCompleted(agent); } catch (err) { debugLog("onSubagentCompleted observer", err); }
275
+ },
276
+ onResumeStarted: (agent) => {
277
+ try { this.observer?.onSubagentResuming(agent); } catch (err) { debugLog("onSubagentResuming observer", err); }
278
+ },
279
+ onResumeFinished: (agent) => {
280
+ try { this.observer?.onSubagentResumed(agent); } catch (err) { debugLog("onSubagentResumed observer", err); }
281
+ },
282
+ onUpdateSent: (agent, message) => {
283
+ this.observer?.onSubagentUpdate?.(agent, message);
284
+ },
285
+ onWorkspaceNotice: (agent, notice) => {
286
+ this.observer?.onSubagentWorkspaceNotice?.(agent, notice);
287
+ },
288
+ onCompacted: (agent, info) => {
289
+ this.observer?.onSubagentCompacted(agent, info);
290
+ },
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Spawn an agent and return its ID immediately (for background use).
296
+ * If the concurrency limit is reached, the agent is queued.
297
+ *
298
+ * Throws when the named agent type is disabled.
299
+ */
300
+ spawn(
301
+ snapshot: ParentSnapshot,
302
+ type: SubagentType,
303
+ prompt: string,
304
+ options: AgentSpawnConfig,
305
+ ): string {
306
+ return this.create(snapshot, this.resolveSpawn(type, options.background), prompt, options);
307
+ }
308
+
309
+ /**
310
+ * Spawn an agent and wait for completion (foreground use).
311
+ * Foreground agents bypass the concurrency queue.
312
+ *
313
+ * The caller holds the result, which is a delivery commitment: the agent must
314
+ * not be queued and must not be announced, whatever its frontmatter declares.
315
+ *
316
+ * Rejects when the named agent type is disabled.
317
+ */
318
+ async spawnAndWait(
319
+ snapshot: ParentSnapshot,
320
+ type: SubagentType,
321
+ prompt: string,
322
+ options: Omit<AgentSpawnConfig, "background">,
323
+ ): Promise<Subagent> {
324
+ const foreground: BackgroundRequest = { kind: "explicit", isBackground: false };
325
+ const id = this.create(snapshot, this.resolveSpawn(type, foreground), prompt, {
326
+ ...options,
327
+ background: foreground,
328
+ });
329
+ const record = this.agents.get(id)!;
330
+ // The caller holds the result, so this call is the carrier: claim the outcome
331
+ // before awaiting it, so nothing announces what is already being delivered.
332
+ record.claim();
333
+ await record.promise;
334
+ return record;
335
+ }
336
+
337
+ /**
338
+ * Stamp the invariants every front door shares: a canonical agent type, a
339
+ * rejection for a disabled one, and the effective background mode.
340
+ */
341
+ private resolveSpawn(type: string, background: BackgroundRequest): ResolvedSpawn {
342
+ const canonical = this.registry.resolveType(type);
343
+ if (canonical !== undefined && !this.registry.isValidType(canonical)) {
344
+ throw new Error(`Agent type "${canonical}" is disabled`);
345
+ }
346
+ const resolvedType = canonical ?? "general-purpose";
347
+ const agentConfig = this.registry.resolveAgentConfig(resolvedType);
348
+ return { type: resolvedType, isBackground: resolveBackgroundMode(agentConfig, background) };
349
+ }
350
+
351
+ /** Create, register, and start (or queue) a record for an already-resolved spawn. */
352
+ private create(
353
+ snapshot: ParentSnapshot,
354
+ resolved: ResolvedSpawn,
355
+ prompt: string,
356
+ options: AgentSpawnConfig,
357
+ ): string {
358
+ const { type, isBackground } = resolved;
359
+ const id = randomUUID().slice(0, 17);
360
+ const record = new Subagent({
361
+ id,
362
+ type,
363
+ description: options.description,
364
+ isBackground,
365
+ state: new SubagentState({
366
+ status: isBackground ? "queued" : "running",
367
+ startedAt: Date.now(),
368
+ }),
369
+ execution: {
370
+ createSubagentSession: this.createSubagentSession,
371
+ snapshot,
372
+ prompt,
373
+ baseCwd: this.baseCwd,
374
+ observer: this.buildObserver(options),
375
+ getRunConfig: this.getRunConfig,
376
+ getWorkspaceProvider: () => this._workspaceProvider,
377
+ selectionScope: this.selectionScope,
378
+ model: options.model,
379
+ maxTurns: options.maxTurns,
380
+ thinkingLevel: options.thinkingLevel,
381
+ parentSession: options.parentSession,
382
+ signal: options.signal,
383
+ },
384
+ });
385
+ this.agents.set(id, record);
386
+
387
+ if (isBackground) {
388
+ this.observer?.onSubagentCreated(record);
389
+ }
390
+
391
+ if (isBackground && !options.bypassQueue) {
392
+ // Schedule on the limiter — scheduleVia captures the limiter promise
393
+ // eagerly, so a queued agent is awaitable from spawn; guardedRun guards
394
+ // against abort-while-queued when the slot frees.
395
+ record.scheduleVia((thunk) => this.limiter.schedule(thunk));
396
+ return id;
397
+ }
398
+
399
+ record.start();
400
+ return id;
401
+ }
402
+
403
+ /**
404
+ * Resume an existing agent session with a new prompt.
405
+ *
406
+ * The refusal policy lives here rather than in a caller, so every front door
407
+ * declines the same resumes for the same reasons; a door owns only how it
408
+ * words the answer. Delegates to Subagent.resume(), which owns the observer
409
+ * subscription lifecycle.
410
+ */
411
+ async resume(id: string, prompt: string, options: ResumeCallOptions = {}): Promise<ResumeOutcome> {
412
+ const agent = this.agents.get(id);
413
+ if (!agent) return { kind: "refused", reason: "unknown-agent" };
414
+ const refusal = agent.resumeRefusal;
415
+ if (refusal) return { kind: "refused", reason: refusal };
416
+ // Before the resume starts: resetForResume runs synchronously inside
417
+ // resume(), so a claim taken afterwards would miss the terminal edge.
418
+ if (options.claimOutcome) agent.claim();
419
+ await agent.resume(prompt, options.signal);
420
+ return { kind: "resumed", record: agent };
421
+ }
422
+
423
+ getRecord(id: string): Subagent | undefined {
424
+ return this.agents.get(id);
425
+ }
426
+
427
+ listAgents(): Subagent[] {
428
+ return [...this.agents.values()].sort(
429
+ (a, b) => b.startedAt - a.startedAt,
430
+ );
431
+ }
432
+
433
+ abort(id: string): boolean {
434
+ const record = this.agents.get(id);
435
+ if (!record) return false;
436
+
437
+ // A queued agent has not started; stop it through the same terminal funnel
438
+ // a running agent's stop uses. Its scheduled thunk becomes a no-op (status
439
+ // guard) when its slot finally opens.
440
+ if (record.status === "queued") {
441
+ record.stopQueued();
442
+ return true;
443
+ }
444
+
445
+ return record.abort();
446
+ }
447
+
448
+ /**
449
+ * Remove a record from the map and tear its session down.
450
+ * The map is updated first so the record is unreachable while its child's
451
+ * extensions shut down.
452
+ */
453
+ private removeRecord(id: string, record: Subagent): Promise<void> {
454
+ this.agents.delete(id);
455
+ return record.disposeSession();
456
+ }
457
+
458
+ /**
459
+ * Release the heavy session of any terminal agent past its retention window.
460
+ * The record (with its result) is retained for the session lifetime; only the
461
+ * live `AgentSession` is freed. `resolveRetentionWindow` owns which window
462
+ * applies.
463
+ */
464
+ private sweep() {
465
+ const policy = this.getRetentionPolicy?.() ?? DEFAULT_RETENTION_POLICY;
466
+ const now = Date.now();
467
+ for (const record of this.agents.values()) {
468
+ if (record.isActive()) continue;
469
+ if (!record.isSessionReady()) continue; // already released, or never had a session
470
+ const { referenceAt, windowMinutes } = resolveRetentionWindow(record, policy);
471
+ // Fire-and-forget: the sweep runs on an interval with no one to await it,
472
+ // and Subagent.releaseSession() already swallows a failing teardown.
473
+ if (now - referenceAt >= windowMinutes * 60_000) void record.releaseSession();
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Remove all completed/stopped/errored records immediately.
479
+ * Called on session start/switch so tasks from a prior session don't persist.
480
+ */
481
+ async clearCompleted(): Promise<void> {
482
+ const teardowns: Promise<void>[] = [];
483
+ for (const [id, record] of this.agents) {
484
+ if (record.isActive()) continue;
485
+ teardowns.push(this.removeRecord(id, record));
486
+ }
487
+ await Promise.all(teardowns);
488
+ }
489
+
490
+ /** Whether any agents are still running or queued. */
491
+ // fallow-ignore-next-line unused-class-member
492
+ hasRunning(): boolean {
493
+ return [...this.agents.values()].some(r => r.isActive());
494
+ }
495
+
496
+ /** Abort all running and queued agents immediately. */
497
+ abortAll(): number {
498
+ let count = 0;
499
+ for (const record of this.agents.values()) {
500
+ if (record.status === "queued") {
501
+ record.stopQueued();
502
+ count++;
503
+ } else if (record.abort()) {
504
+ count++;
505
+ }
506
+ }
507
+ // Drop pending thunks (their promises resolve).
508
+ this.limiter.clear();
509
+ return count;
510
+ }
511
+
512
+ /** Wait for all running and queued agents to complete (including queued ones). */
513
+ async waitForAll(): Promise<void> {
514
+ // Every spawned agent has a settled-on-completion promise (the limiter starts
515
+ // queued ones as slots free), so a single allSettled covers the queued case.
516
+ // The loop only catches agents spawned during the wait.
517
+ let pending = this.pendingPromises();
518
+ while (pending.length > 0) {
519
+ await Promise.allSettled(pending);
520
+ pending = this.pendingPromises();
521
+ }
522
+ }
523
+
524
+ /** Promises of all running/queued agents that have one. */
525
+ private pendingPromises(): Promise<void>[] {
526
+ return [...this.agents.values()]
527
+ .filter(r => r.isActive())
528
+ .map(r => r.promise)
529
+ .filter((p): p is Promise<void> => p != null);
530
+ }
531
+
532
+ /**
533
+ * Tear down every record, resolving once each child's extensions have shut
534
+ * down. The registry is emptied before the teardowns are awaited, so nothing
535
+ * can reach a dying record; `allSettled` keeps one failing child from
536
+ * abandoning its siblings.
537
+ */
538
+ async dispose(): Promise<void> {
539
+ clearInterval(this.sweepInterval);
540
+ // Drop pending thunks
541
+ this.limiter.clear();
542
+ const teardowns = [...this.agents.values()].map(record => record.disposeSession());
543
+ this.agents.clear();
544
+ await Promise.allSettled(teardowns);
545
+ }
546
+ }