@howarewoo/pi-subagents 0.0.0-stage → 0.2.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.
@@ -0,0 +1,1376 @@
1
+ import { requireSupportedHost } from "./supported-host.ts";
2
+ import { leafDenial, toolProvenance, type CapabilityDenial } from "./capabilities.ts";
3
+ import { randomUUID } from "node:crypto";
4
+ import {
5
+ SessionManager,
6
+ type AgentSession,
7
+ type AgentSessionEvent,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { resultValidator } from "./outcomes.ts";
10
+ import {
11
+ allowsModel,
12
+ sameSelection,
13
+ validateModelSelection,
14
+ type ModelSelection,
15
+ } from "./model-policy.ts";
16
+ import { resolveWorkspace } from "./workspace.ts";
17
+ import {
18
+ DEFAULT_POLICY,
19
+ resolveScheduling,
20
+ narrowLimits,
21
+ validatePolicy,
22
+ type ExecutionLimits,
23
+ type SchedulingPolicy,
24
+ } from "./scheduling.ts";
25
+ import {
26
+ resolveProfile,
27
+ intersectProfileGrants,
28
+ isWriter,
29
+ READ_TOOLS,
30
+ type Profile,
31
+ type BuiltinTool,
32
+ type CoordinationAction,
33
+ } from "./profiles.ts";
34
+
35
+ const MAX_MESSAGE_BYTES = 4096;
36
+ const MAX_MAILBOX_MESSAGES = 64;
37
+ const MAX_RETAINED_MESSAGES = 512;
38
+ const MAX_WAITERS = 64;
39
+ const MAX_WAIT_MS = 60000;
40
+ const MAX_GENERATIONS = 64;
41
+
42
+ export type {
43
+ DelegationMessage,
44
+ CoordinationWait,
45
+ ChildRef,
46
+ ChildSnapshot,
47
+ RegistryEvent,
48
+ StartTask,
49
+ } from "./delegation-types.ts";
50
+ import type {
51
+ DelegationMessage,
52
+ CoordinationWait,
53
+ ChildRef,
54
+ ChildSnapshot,
55
+ RegistryEvent,
56
+ StartTask,
57
+ } from "./delegation-types.ts";
58
+ import { createChildExecution, type ExecutionRecord } from "./child-execution.ts";
59
+ export { childResources } from "./child-execution.ts";
60
+
61
+ type Entry = ExecutionRecord & {
62
+ done: Promise<void>;
63
+ finish: () => void;
64
+ spawned: number;
65
+ finalizedMessages?: AgentSession["messages"];
66
+ setup?: Promise<void>;
67
+ resuming?: boolean;
68
+ pauseDrain?: Promise<void>;
69
+ callerId: string;
70
+ };
71
+
72
+ function broadenedGrant(profile: Profile, grant: Profile) {
73
+ return (["tools", "skills", "leafTools", "spawn", "coordination"] as const).find((key) =>
74
+ (profile[key] ?? []).some(
75
+ (value) => !(grant[key] as readonly string[] | undefined)?.includes(value),
76
+ ),
77
+ );
78
+ }
79
+
80
+ function admittedModel(entry: Entry, selection: ModelSelection): boolean {
81
+ return entry.input.profile.models === undefined
82
+ ? sameSelection(selection, {
83
+ ...entry.state.model,
84
+ effort: entry.state.effort as ModelSelection["effort"],
85
+ })
86
+ : allowsModel(entry.input.profile, selection);
87
+ }
88
+
89
+ /** Authoritative parent-lifetime registry. IDs are never reused; controls require their generation. */
90
+ export class DelegationCoordinator {
91
+ private entries = new Map<string, Entry>();
92
+ private queue = new Set<Entry>();
93
+ private history = new Map<string, { state: ChildSnapshot; messages: AgentSession["messages"] }>();
94
+ private presence = new Map<
95
+ string,
96
+ "active" | "pausing" | "paused" | "idle" | "parked" | "stopped"
97
+ >();
98
+ private coordinationListeners = new Set<() => void>();
99
+ private coordinationMessages: DelegationMessage[] = [];
100
+ private mailboxes = new Map<string, DelegationMessage[]>();
101
+ private waits = new Map<string, Set<string>>();
102
+ private waiterCount = 0;
103
+ private notifyCoordination(): void {
104
+ for (const listener of this.coordinationListeners) listener();
105
+ }
106
+ agents() {
107
+ return this.list().map((state) => ({
108
+ id: state.id,
109
+ generation: state.generation,
110
+ presence: this.presence.get(state.id),
111
+ }));
112
+ }
113
+ generations(id: string): ChildSnapshot[] {
114
+ return [...this.history.values()]
115
+ .map((record) => record.state)
116
+ .concat(this.list())
117
+ .filter((state) => state.id === id)
118
+ .map((state) => structuredClone(state));
119
+ }
120
+ private listeners = new Set<(event: RegistryEvent) => void>();
121
+ private parentMutations = new Set<string>();
122
+ private writer?: string;
123
+ private closing = false;
124
+ private shutdownPromise?: Promise<void>;
125
+ readonly root: string;
126
+ readonly parentId: string;
127
+ constructor(root: string, parentId: string) {
128
+ this.root = root;
129
+ this.parentId = parentId;
130
+ }
131
+ private policy: SchedulingPolicy = { ...DEFAULT_POLICY };
132
+ configure(policy: SchedulingPolicy): void {
133
+ this.policy = validatePolicy(policy);
134
+ this.pump();
135
+ }
136
+ list(): ChildSnapshot[] {
137
+ return [...this.entries.values()].map((entry) => structuredClone(entry.state));
138
+ }
139
+ snapshot(ref?: ChildRef): ChildSnapshot | undefined {
140
+ if (ref) {
141
+ const historical = this.history.get(this.key(ref));
142
+ if (historical) return structuredClone(historical.state);
143
+ }
144
+ const entry = ref ? this.entry(ref) : [...this.entries.values()].at(-1);
145
+ return entry && structuredClone(entry.state);
146
+ }
147
+ private entry(ref: ChildRef): Entry {
148
+ const entry = this.entries.get(ref.id);
149
+ if (!entry || entry.state.generation !== ref.generation)
150
+ throw new Error("Unknown or stale delegation identity");
151
+ return entry;
152
+ }
153
+ subscribe(listener: (event: RegistryEvent) => void): () => void {
154
+ this.listeners.add(listener);
155
+ return () => {
156
+ this.listeners.delete(listener);
157
+ };
158
+ }
159
+ private update(entry: Entry, patch: Partial<ChildSnapshot>, event?: AgentSessionEvent): void {
160
+ entry.state = { ...entry.state, ...patch };
161
+ this.notifyCoordination();
162
+ for (const listener of this.listeners) {
163
+ // Observers must never own execution or turn a successful run into failure.
164
+ try {
165
+ listener({ type: event ? "sdk" : "changed", child: structuredClone(entry.state), event });
166
+ } catch {
167
+ /* observer isolated */
168
+ }
169
+ }
170
+ }
171
+ /** Reserve at preflight, release on every finalized call (including blocked calls). */
172
+ parentToolStart(
173
+ id: string,
174
+ name: string,
175
+ readOnly = READ_TOOLS.includes(name as (typeof READ_TOOLS)[number]),
176
+ ): void {
177
+ if (readOnly || name === "delegate_task" || name === "delegate_coordinate") return;
178
+ const child = this.writer ? this.entries.get(this.writer) : undefined;
179
+ if (this.writer && (!child || !id.startsWith(child.callerId + "/")))
180
+ throw new Error(
181
+ `Shared checkout writer is held by ${this.writer}; stop or wait for that task`,
182
+ );
183
+ this.parentMutations.add(id);
184
+ }
185
+ parentToolEnd(id: string): void {
186
+ this.parentMutations.delete(id);
187
+ this.pump();
188
+ }
189
+ private active(): Entry[] {
190
+ return [...this.entries.values()].filter(
191
+ (entry) =>
192
+ !entry.state.disposed && entry.state.status !== "queued" && entry.state.status !== "paused",
193
+ );
194
+ }
195
+ private validate(input: StartTask): ExecutionRecord["selection"] {
196
+ if (this.closing) throw new Error("Parent shut down");
197
+ if (!input.assignment.trim()) throw new Error("A nonempty assignment is required");
198
+ const selection = {
199
+ model: input.context.model,
200
+ modelRegistry: input.context.modelRegistry,
201
+ scopedModels: input.context.scopedModels,
202
+ thinkingLevel: input.context.thinkingLevel,
203
+ cwd: input.cwd ?? input.context.cwd,
204
+ };
205
+ if (!selection.model) throw new Error("A selected parent model is required");
206
+ const requested = input.model ?? input.profile.model;
207
+ const parentState = input.parent ? this.entry(input.parent).state : undefined;
208
+ const effective: ModelSelection = requested ?? {
209
+ ...(parentState?.model ?? { provider: selection.model.provider, id: selection.model.id }),
210
+ effort:
211
+ (parentState?.effort as ModelSelection["effort"] | undefined) ??
212
+ selection.thinkingLevel ??
213
+ "off",
214
+ };
215
+ if (
216
+ (requested && !input.profile.models?.some((item) => sameSelection(item, requested))) ||
217
+ !allowsModel(input.profile, effective)
218
+ )
219
+ throw new Error("Model selection denied by effective profile grants");
220
+ selection.model = validateModelSelection(selection, effective);
221
+ selection.thinkingLevel = effective.effort;
222
+ if (input.profile.prewalk && !input.model) {
223
+ if (!input.profile.models?.some((item) => sameSelection(item, input.profile.prewalk!)))
224
+ throw new Error("Prewalk target requires an exact model grant");
225
+ validateModelSelection(selection, input.profile.prewalk);
226
+ }
227
+ const writer = this.writer ? this.entries.get(this.writer) : undefined;
228
+ if (
229
+ isWriter(input.profile) &&
230
+ [...this.parentMutations].some((id) => !writer || !id.startsWith(writer.callerId + "/"))
231
+ )
232
+ throw new Error("Shared checkout writer conflict: in-flight parent mutation");
233
+ if (input.parent) {
234
+ const parent = this.entry(input.parent);
235
+ if (parent.cancelled || parent.paused || parent.state.disposed)
236
+ throw new Error("Delegating parent is no longer active");
237
+ this.assertDescendantModels(parent, input.profile, effective, input.model);
238
+ if (!parent.input.profile.spawn?.includes(input.profile.name))
239
+ throw new Error("Ancestor spawn role denied");
240
+ if (parent.state.depth >= Math.min(this.policy.depth, parent.state.limits.depth))
241
+ throw new Error("Delegation depth limit");
242
+ if (broadenedGrant(input.profile, parent.input.profile))
243
+ throw new Error("Descendant cannot broaden ancestor grants");
244
+ let ancestor: Entry | undefined = parent;
245
+ while (ancestor) {
246
+ if (isWriter(input.profile) && isWriter(ancestor.input.profile))
247
+ throw new Error("Nested writer under writer ancestor is unsupported");
248
+ ancestor = this.entries.get(ancestor.state.parentId);
249
+ }
250
+ }
251
+ return selection;
252
+ }
253
+ private assertDescendantModels(
254
+ parent: Entry,
255
+ profile: Profile,
256
+ effective: ModelSelection,
257
+ explicit?: ModelSelection,
258
+ ): void {
259
+ if (
260
+ !admittedModel(parent, effective) ||
261
+ profile.models?.some((model) => !admittedModel(parent, model)) ||
262
+ (profile.prewalk && !explicit && !admittedModel(parent, profile.prewalk))
263
+ )
264
+ throw new Error("Descendant cannot broaden ancestor model grants");
265
+ }
266
+ private assertActive(entry: Entry): void {
267
+ let current: Entry | undefined = entry;
268
+ while (current) {
269
+ if (
270
+ this.entry(current.state) !== current ||
271
+ current.cancelled ||
272
+ current.paused ||
273
+ current.state.disposed ||
274
+ this.closing
275
+ )
276
+ throw new Error("Delegating parent is no longer active");
277
+ current = current.input.parent ? this.entry(current.input.parent) : undefined;
278
+ }
279
+ }
280
+ /** Each action is bounded by its admission and fresh policy for the whole lineage. */
281
+ private async authorize(
282
+ ref: ChildRef,
283
+ allows: (profile: Profile, index: number) => boolean,
284
+ denial: string,
285
+ ): Promise<void> {
286
+ const lineage: Entry[] = [];
287
+ let current: Entry | undefined = this.entry(ref);
288
+ while (current) {
289
+ this.assertActive(current);
290
+ if (!allows(current.input.profile, lineage.length)) throw new Error(denial);
291
+ lineage.push(current);
292
+ current = current.input.parent ? this.entry(current.input.parent) : undefined;
293
+ }
294
+ for (const [index, entry] of lineage.entries()) {
295
+ const { profile } = await resolveProfile(
296
+ entry.input.profile.name,
297
+ entry.input.context.cwd,
298
+ entry.input.context.isProjectTrusted(),
299
+ undefined,
300
+ entry.input.availableSkills?.() ?? entry.input.skills,
301
+ );
302
+ if (!allows(profile, index)) throw new Error(denial);
303
+ }
304
+ // Policy loading yields: cancellation, pause, shutdown and generation changes win.
305
+ this.assertActive(lineage[0]);
306
+ }
307
+ private effectiveCapabilities(
308
+ profile: Profile,
309
+ source: Pick<StartTask, "context" | "toolMetadata">,
310
+ cwd: string,
311
+ ) {
312
+ const callable = new Set(source.context.tools.map((tool) => tool.name));
313
+ const capabilityDenials: CapabilityDenial[] = [];
314
+ const leafTools: string[] = [];
315
+ const leafProvenance: Record<string, string> = {};
316
+ if (profile.name === "general") {
317
+ const metadata = source.toolMetadata?.() ?? [];
318
+ const sameWorkspace = cwd === resolveWorkspace(undefined, source.context.cwd);
319
+ for (const tool of metadata) {
320
+ if (profile.tools.includes(tool.name as BuiltinTool)) continue;
321
+ const reason = !callable.has(tool.name)
322
+ ? "tool is not live callable"
323
+ : leafDenial(tool, profile.leafTools ?? [], sameWorkspace);
324
+ if (reason) capabilityDenials.push({ name: tool.name, reason });
325
+ else {
326
+ leafTools.push(tool.name);
327
+ leafProvenance[tool.name] = toolProvenance(tool);
328
+ }
329
+ }
330
+ for (const name of profile.leafTools ?? [])
331
+ if (!metadata.some((tool) => tool.name === name))
332
+ capabilityDenials.push({ name, reason: "tool metadata unavailable" });
333
+ }
334
+ return {
335
+ profile: { ...profile, leafTools, tools: profile.tools.filter((name) => callable.has(name)) },
336
+ capabilityDenials,
337
+ leafProvenance,
338
+ };
339
+ }
340
+ async authorizeSpawn(
341
+ ref: ChildRef,
342
+ profile: Profile,
343
+ model?: ModelSelection,
344
+ source?: Pick<StartTask, "context" | "toolMetadata" | "cwd">,
345
+ ): Promise<void> {
346
+ const parent = this.entry(ref);
347
+ this.assertDescendantModels(
348
+ parent,
349
+ profile,
350
+ model ??
351
+ profile.model ?? {
352
+ ...parent.state.model,
353
+ effort: parent.state.effort as ModelSelection["effort"],
354
+ },
355
+ model,
356
+ );
357
+ // Compare effective capabilities, not unavailable configured ceilings.
358
+ profile = this.effectiveCapabilities(
359
+ profile,
360
+ source ?? parent.input,
361
+ resolveWorkspace(source?.cwd ?? parent.selection.cwd, (source ?? parent.input).context.cwd),
362
+ ).profile;
363
+ const roles = [profile.name];
364
+ let current: Entry | undefined = this.entry(ref);
365
+ while (current) {
366
+ roles.push(current.input.profile.name);
367
+ current = current.input.parent ? this.entry(current.input.parent) : undefined;
368
+ }
369
+ await this.authorize(
370
+ ref,
371
+ (grant, index) => !!grant.spawn?.includes(roles[index]) && !broadenedGrant(profile, grant),
372
+ "Current or admitted ancestor spawn grant denied; descendant cannot broaden ancestor grants",
373
+ );
374
+ }
375
+ async admit(
376
+ inputs: StartTask[],
377
+ replacement?: ChildRef,
378
+ signal?: AbortSignal,
379
+ ): Promise<ChildRef[]> {
380
+ requireSupportedHost();
381
+ for (const input of inputs)
382
+ if (input.parent) await this.authorizeSpawn(input.parent, input.profile, input.model, input);
383
+ signal?.throwIfAborted();
384
+ for (const input of inputs) if (input.parent) this.assertActive(this.entry(input.parent));
385
+ return this.admitAuthorized(inputs, replacement);
386
+ }
387
+ /** Whole-batch synchronous validation precedes registry insertion, disk setup or dispatch. */
388
+ private admitAuthorized(inputs: StartTask[], replacement?: ChildRef): ChildRef[] {
389
+ const previous = replacement ? this.entry(replacement) : undefined;
390
+ inputs = inputs.map((input) => {
391
+ const cwd = resolveWorkspace(
392
+ input.cwd ??
393
+ previous?.selection.cwd ??
394
+ (input.parent ? this.entry(input.parent).selection.cwd : undefined),
395
+ input.context.cwd,
396
+ );
397
+ if (previous && cwd !== previous.selection.cwd)
398
+ throw new Error("Follow-up must retain its original workspace");
399
+ if (typeof input.assertTools !== "function")
400
+ throw new Error("Delegation requires a parent builtin provenance validator");
401
+ const { profile, capabilityDenials, leafProvenance } = this.effectiveCapabilities(
402
+ input.profile,
403
+ input,
404
+ cwd,
405
+ );
406
+ if (!profile.tools.length)
407
+ throw new Error(
408
+ `Profile ${profile.name} has no callable parent builtin tools; enable an allowed parent tool or choose a usable profile`,
409
+ );
410
+ input.assertTools(profile.tools);
411
+ // Capture the admitted ceiling, but keep root/ancestor revocation live. A nested
412
+ // task uses the root dispatcher context, not its immediate parent's SDK context.
413
+ const inheritedCodemode = input.parent
414
+ ? this.entry(input.parent).input.codemodeEnabled
415
+ : undefined;
416
+ const enabled =
417
+ !!input.codemodeEnabled?.() &&
418
+ (!input.parent || !!inheritedCodemode?.()) &&
419
+ (!previous || !!previous.input.codemodeEnabled?.());
420
+ const codemodeEnabled = () =>
421
+ enabled && !!input.codemodeEnabled?.() && (!input.parent || !!inheritedCodemode?.());
422
+ return { ...input, profile, cwd, capabilityDenials, leafProvenance, codemodeEnabled };
423
+ });
424
+ if (previous) {
425
+ if (
426
+ inputs.length !== 1 ||
427
+ !previous.state.disposed ||
428
+ !["idle", "parked"].includes(this.presence.get(previous.state.id) ?? "")
429
+ )
430
+ throw new Error("Assignment is not idle or parked");
431
+ if (this.key(previous.input.parent) !== this.key(inputs[0].parent))
432
+ throw new Error("Follow-up must retain its exact original parent generation");
433
+ if (previous.state.generation >= MAX_GENERATIONS)
434
+ throw new Error("Generation retention limit");
435
+ const profile = inputs[0].profile;
436
+ if (
437
+ (previous.input.profile.models !== undefined && profile.models === undefined) ||
438
+ profile.models?.some(
439
+ (model) => !previous.input.profile.models?.some((grant) => sameSelection(model, grant)),
440
+ )
441
+ )
442
+ throw new Error("Follow-up cannot broaden previous model grants");
443
+ if (
444
+ profile.name !== previous.input.profile.name ||
445
+ broadenedGrant(profile, previous.input.profile)
446
+ )
447
+ throw new Error("Follow-up cannot broaden previous grants");
448
+ }
449
+ if (!inputs.length || inputs.length > this.policy.batch) throw new Error("Batch size limit");
450
+ const selections = inputs.map((input) => this.validate(input));
451
+ const validators = inputs.map((input) =>
452
+ input.resultSchema === undefined ? undefined : resultValidator(input.resultSchema),
453
+ );
454
+ const limits = inputs.map((input) => {
455
+ const maximum: ExecutionLimits = {
456
+ deadlineMs: this.policy.deadlineMs,
457
+ requests: this.policy.requests,
458
+ tools: this.policy.tools,
459
+ depth: this.policy.depth,
460
+ spawns: this.policy.spawns,
461
+ };
462
+ if (input.parent) {
463
+ const inherited = this.entry(input.parent).state.limits;
464
+ for (const key of Object.keys(maximum) as (keyof ExecutionLimits)[])
465
+ maximum[key] = Math.min(maximum[key], inherited[key]);
466
+ }
467
+ return narrowLimits(maximum, input.limits ?? {});
468
+ });
469
+ const queued = this.queue.size;
470
+ this.assertQueueCapacity(inputs);
471
+ const parents = new Map<Entry, number>();
472
+ for (const input of inputs)
473
+ if (input.parent) {
474
+ const parent = this.entry(input.parent);
475
+ parents.set(parent, (parents.get(parent) ?? 0) + 1);
476
+ }
477
+ for (const [parent, count] of parents) {
478
+ if (parent.spawned + count > Math.min(this.policy.spawns, parent.state.limits.spawns))
479
+ throw new Error("Delegation spawn limit");
480
+ // Waiting sessions retain their permits: no lending or hidden overcommit. Refuse
481
+ // nested waits that cannot start immediately instead of queuing a deadlock.
482
+ if (
483
+ queued ||
484
+ this.active().length + inputs.length > this.policy.concurrency ||
485
+ (inputs.some((input) => isWriter(input.profile)) &&
486
+ (this.writer || inputs.filter((input) => isWriter(input.profile)).length > 1))
487
+ )
488
+ throw new Error("Nested capacity unavailable; no permit lending");
489
+ }
490
+ const entries = inputs.map((input, index) => {
491
+ const selection = selections[index];
492
+ let finish!: () => void;
493
+ const done = new Promise<void>((resolve) => {
494
+ finish = resolve;
495
+ });
496
+ const entry: Entry = {
497
+ validator: validators[index],
498
+ input,
499
+ selection,
500
+ done,
501
+ finish,
502
+ spawned: previous?.spawned ?? 0,
503
+ cancelled: false,
504
+ callerId: input.callerId,
505
+ state: {
506
+ id: previous?.state.id ?? randomUUID(),
507
+ generation: (previous?.state.generation ?? 0) + 1,
508
+ parentGeneration: input.parent?.generation,
509
+ parentId: input.parent?.id ?? this.parentId,
510
+ depth: input.parent ? this.entry(input.parent).state.depth + 1 : 1,
511
+ createdAt: Date.now(),
512
+ endedAt: null,
513
+ status: "queued",
514
+ writerLease: false,
515
+ limits: limits[index],
516
+ toolCalls: 0,
517
+ spawned: previous?.spawned ?? 0,
518
+ window: 1,
519
+ retainedContext: false,
520
+ profile: input.profile.name,
521
+ tools: [...input.profile.tools],
522
+ leafTools: [...(input.profile.leafTools ?? [])],
523
+ skills: [...input.profile.skills],
524
+ capabilityDenials: input.capabilityDenials,
525
+ cwd: selection.cwd,
526
+ outcome: validators[index]
527
+ ? { validation: "awaiting", attempts: 0, errors: [], finalized: false }
528
+ : undefined,
529
+ assignment: input.assignment,
530
+ activity: "Queued for capacity / writer admission",
531
+ progress: "",
532
+ result: "",
533
+ disposed: false,
534
+ model: { provider: selection.model!.provider, id: selection.model!.id },
535
+ effort: selection.thinkingLevel ?? null,
536
+ prewalk: input.profile.prewalk
537
+ ? {
538
+ target: input.profile.prewalk,
539
+ status: input.model
540
+ ? "suppressed-exact"
541
+ : sameSelection(input.profile.prewalk, {
542
+ provider: selection.model!.provider,
543
+ id: selection.model!.id,
544
+ effort: selection.thinkingLevel!,
545
+ })
546
+ ? "no-op"
547
+ : "armed",
548
+ }
549
+ : undefined,
550
+ telemetry: { requests: null, tokens: null, cost: null, context: null },
551
+ },
552
+ };
553
+ return entry;
554
+ });
555
+ for (const [parent, count] of parents) {
556
+ parent.spawned += count;
557
+ this.update(parent, { spawned: parent.spawned });
558
+ }
559
+ if (previous)
560
+ this.history.set(this.key(previous.state), {
561
+ state: structuredClone(previous.state),
562
+ messages: previous.finalizedMessages ?? [],
563
+ });
564
+ for (const entry of entries) {
565
+ this.entries.set(entry.state.id, entry);
566
+ this.queue.add(entry);
567
+ this.presence.set(entry.state.id, "active");
568
+ }
569
+ for (const entry of entries) {
570
+ entry.timer = setTimeout(() => {
571
+ this.limit(entry, "deadline");
572
+ }, entry.state.limits.deadlineMs);
573
+ }
574
+ for (const entry of entries) if (!entry.state.disposed) this.update(entry, {});
575
+ this.pump();
576
+ return entries.map((entry) => ({ id: entry.state.id, generation: entry.state.generation }));
577
+ }
578
+ async start(input: StartTask): Promise<ChildRef> {
579
+ const [ref] = await this.admit([input]);
580
+ await this.entry(ref).setup;
581
+ return ref;
582
+ }
583
+ renewalParent(ref: ChildRef): ChildRef | undefined {
584
+ const parent = this.entry(ref).input.parent;
585
+ if (parent) {
586
+ const entry = this.entry(parent);
587
+ if (entry.cancelled || entry.state.disposed || entry.state.status !== "running")
588
+ throw new Error("Original parent generation is no longer active");
589
+ }
590
+ return parent && { id: parent.id, generation: parent.generation };
591
+ }
592
+ async renew(ref: ChildRef, input: StartTask, signal?: AbortSignal): Promise<ChildRef> {
593
+ requireSupportedHost();
594
+ const previous = this.entry(ref);
595
+ this.renewalParent(ref);
596
+ const profile = intersectProfileGrants(input.profile, previous.input.profile);
597
+ const retained =
598
+ previous.state.prewalk && ["handed-off", "no-op"].includes(previous.state.prewalk.status)
599
+ ? previous.state.prewalk.target
600
+ : previous.input.model;
601
+ const next = (
602
+ await this.admit(
603
+ [
604
+ {
605
+ ...input,
606
+ model: retained ?? input.model,
607
+ profile,
608
+ skills: input.skills.filter((skill) => profile.skills.includes(skill.filePath)),
609
+ resultSchema:
610
+ input.resultSchema === undefined ? previous.input.resultSchema : input.resultSchema,
611
+ limits: narrowLimits(previous.state.limits, input.limits ?? {}),
612
+ },
613
+ ],
614
+ ref,
615
+ signal,
616
+ )
617
+ )[0];
618
+ if (previous.state.prewalk && previous.state.prewalk.status !== "armed")
619
+ this.update(this.entry(next), { prewalk: previous.state.prewalk });
620
+ return next;
621
+ }
622
+ private assertQueueCapacity(inputs: StartTask[]): void {
623
+ const pending = [...this.queue].map((entry) => entry.input).concat(inputs);
624
+ let slots = Math.max(0, this.policy.concurrency - this.active().length);
625
+ let writerBusy = !!this.writer || !!this.parentMutations.size;
626
+ let dispatchable = 0;
627
+ for (const input of pending) {
628
+ if (!slots || (isWriter(input.profile) && writerBusy)) break;
629
+ slots--;
630
+ dispatchable++;
631
+ if (isWriter(input.profile)) writerBusy = true;
632
+ }
633
+ if (pending.length - dispatchable > this.policy.queue)
634
+ throw new Error("Delegation queue capacity limit");
635
+ }
636
+ private pump(): void {
637
+ if (this.closing) return;
638
+ for (const entry of this.queue) {
639
+ if (entry.cancelled || entry.state.disposed || entry.state.status !== "queued") continue;
640
+ if (this.active().length >= this.policy.concurrency) break;
641
+ const writer = isWriter(entry.input.profile);
642
+ if (writer && (this.writer || this.parentMutations.size)) break; // strict FIFO
643
+ this.queue.delete(entry);
644
+ if (writer) this.writer = entry.state.id;
645
+ entry.state = {
646
+ ...entry.state,
647
+ status: "setup",
648
+ writerLease: writer,
649
+ activity: "Preparing SDK child",
650
+ };
651
+ entry.setup = entry.session
652
+ ? Promise.resolve().then(() => {
653
+ if (!entry.paused && !entry.cancelled) return entry.prompt!(entry.resumeText!);
654
+ })
655
+ : this.create(entry);
656
+ this.update(entry, {});
657
+ void entry.setup.catch(() => {});
658
+ }
659
+ }
660
+ /** Mark the entire active subtree before aborting any SDK or releasing any permit. */
661
+ private pause(ref: ChildRef): Promise<void> {
662
+ const root = this.entry(ref);
663
+ if (root.pauseDrain) return root.pauseDrain;
664
+ const entries: Entry[] = [];
665
+ const collect = (entry: Entry) => {
666
+ if (entry.cancelled || entry.state.disposed || entry.paused) return;
667
+ entries.push(entry);
668
+ for (const child of this.children(entry)) collect(child);
669
+ };
670
+ collect(root);
671
+ for (const entry of entries) {
672
+ entry.paused = true;
673
+ this.presence.set(entry.state.id, "pausing");
674
+ clearTimeout(entry.timer);
675
+ this.queue.delete(entry);
676
+ }
677
+ for (const entry of entries)
678
+ this.update(entry, {
679
+ status: "pausing",
680
+ pauseReason: entry === root ? "deadline" : "ancestor deadline",
681
+ activity: "Deadline reached; aborting and draining before pause",
682
+ });
683
+ const drain = async (entry: Entry) => {
684
+ await entry.setup?.catch(() => {});
685
+ await entry.session?.abort();
686
+ await entry.run;
687
+ };
688
+ // Start aborts concurrently: an ancestor may be awaiting its descendant's outcome.
689
+ const drains = new Map(entries.map((entry) => [entry, drain(entry)]));
690
+ const completions = new Map<Entry, Promise<void>>();
691
+ const complete = (entry: Entry): Promise<void> => {
692
+ const existing = completions.get(entry);
693
+ if (existing) return existing;
694
+ const completion = Promise.all([
695
+ drains.get(entry),
696
+ // Independently pausing children already own their abort and subtree drain.
697
+ ...this.children(entry).map((child) =>
698
+ drains.has(child) ? complete(child) : child.pauseDrain,
699
+ ),
700
+ ]).then(() => {
701
+ if (entry.cancelled || this.closing || entry.state.disposed) return;
702
+ if (this.writer === entry.state.id) this.writer = undefined;
703
+ this.presence.set(entry.state.id, "paused");
704
+ this.update(entry, {
705
+ status: "paused",
706
+ writerLease: false,
707
+ retainedContext: !!entry.session,
708
+ activity: "Paused; explicitly resume to continue retained conversation",
709
+ });
710
+ entry.finish();
711
+ this.pump();
712
+ });
713
+ completions.set(entry, completion);
714
+ entry.pauseDrain = completion;
715
+ return completion;
716
+ };
717
+ const paused = Promise.all(entries.map(complete)).then(() => {});
718
+ return paused;
719
+ }
720
+ /** Continue a drained window without replacing the identity or SDK conversation. */
721
+ async resume(ref: ChildRef, text: string): Promise<ChildRef> {
722
+ requireSupportedHost();
723
+ const entry = this.entry(ref);
724
+ if (entry.resuming || entry.state.status !== "paused" || entry.cancelled || this.closing)
725
+ throw new Error("Resume requires a drained paused assignment; duplicate resume rejected");
726
+ if (!text.trim()) throw new Error("Nonempty continuation text is required");
727
+ entry.resuming = true;
728
+ try {
729
+ const parent = this.renewalParent(ref);
730
+ const input = entry.input;
731
+ const policy = await resolveScheduling(input.context.cwd, input.context.isProjectTrusted());
732
+ this.configure(policy);
733
+ const limits = { ...entry.state.limits };
734
+ for (const key of Object.keys(limits) as (keyof ExecutionLimits)[])
735
+ limits[key] = Math.min(
736
+ limits[key],
737
+ policy[key],
738
+ parent ? this.entry(parent).state.limits[key] : Infinity,
739
+ );
740
+ let current: Entry | undefined = entry;
741
+ let descendant: Entry | undefined;
742
+ const selected: ModelSelection = {
743
+ ...entry.state.model,
744
+ effort: entry.state.effort as ModelSelection["effort"],
745
+ };
746
+ while (current) {
747
+ const { profile } = await resolveProfile(
748
+ current.input.profile.name,
749
+ input.context.cwd,
750
+ input.context.isProjectTrusted(),
751
+ undefined,
752
+ current.input.availableSkills?.() ?? current.input.skills,
753
+ );
754
+ if (
755
+ (current.input.profile.models !== undefined && profile.models === undefined) ||
756
+ !allowsModel(profile, selected) ||
757
+ !admittedModel(current, selected)
758
+ )
759
+ throw new Error("Resume denied: current or admitted model policy revoked");
760
+ if (descendant && !profile.spawn?.includes(descendant.input.profile.name))
761
+ throw new Error("Resume denied: ancestor spawn role revoked");
762
+ // Reject revoked grants instead of rebuilding an SDK tool/resource graph. This never
763
+ // broadens the original admission and leaves the paused conversation recoverable.
764
+ const revoked = broadenedGrant(input.profile, profile);
765
+ if (revoked) throw new Error(`Resume denied: current ${revoked} grant revoked`);
766
+ if (
767
+ current === entry &&
768
+ entry.state.prewalk?.status === "armed" &&
769
+ (!profile.prewalk || !sameSelection(profile.prewalk, entry.state.prewalk.target))
770
+ )
771
+ throw new Error("Resume denied: prewalk grant revoked");
772
+ descendant = current;
773
+ current = current.input.parent ? this.entry(current.input.parent) : undefined;
774
+ }
775
+ validateModelSelection(input.context, selected);
776
+ const auth = await input.context.modelRegistry.getApiKeyAndHeaders(
777
+ validateModelSelection(input.context, selected),
778
+ );
779
+ if (!auth.ok) throw new Error(auth.error);
780
+ if (entry.state.prewalk?.status === "armed") {
781
+ const target = validateModelSelection(input.context, entry.state.prewalk.target);
782
+ const targetAuth = await input.context.modelRegistry.getApiKeyAndHeaders(target);
783
+ if (!targetAuth.ok) throw new Error(targetAuth.error);
784
+ }
785
+ input.assertTools(input.profile.tools);
786
+ for (const name of [...input.profile.tools, ...(input.profile.leafTools ?? [])]) {
787
+ if (!input.context.tools.some((tool) => tool.name === name))
788
+ throw new Error(`Resume denied: admitted tool no longer callable: ${name}`);
789
+ if (input.profile.leafTools?.includes(name)) {
790
+ const metadata = input.toolMetadata?.().find((tool) => tool.name === name);
791
+ if (
792
+ !metadata ||
793
+ leafDenial(
794
+ metadata,
795
+ input.profile.leafTools,
796
+ entry.selection.cwd === resolveWorkspace(undefined, input.context.cwd),
797
+ ) ||
798
+ toolProvenance(metadata) !== input.leafProvenance?.[name]
799
+ )
800
+ throw new Error(`Resume denied: leaf capability revoked or reclassified: ${name}`);
801
+ }
802
+ }
803
+ if (
804
+ (entry.state.telemetry.requests ?? 0) >= limits.requests ||
805
+ entry.state.toolCalls >= limits.tools ||
806
+ entry.spawned > limits.spawns
807
+ )
808
+ throw new Error("Resume denied: cumulative execution budget exhausted");
809
+ if (entry.state.depth > limits.depth)
810
+ throw new Error("Resume denied: delegation depth limit");
811
+ // Reuse the original admission's writer and nested no-lending checks, without
812
+ // admitting another task or charging another spawn.
813
+ this.validate(input);
814
+ this.renewalParent(ref);
815
+ const available =
816
+ this.active().length < policy.concurrency &&
817
+ (!isWriter(input.profile) || (!this.writer && !this.parentMutations.size));
818
+ if (parent && (this.queue.size || !available))
819
+ throw new Error("Nested capacity unavailable; no permit lending");
820
+ this.assertQueueCapacity([input]);
821
+ if (
822
+ entry.cancelled ||
823
+ this.closing ||
824
+ entry.state.disposed ||
825
+ entry.state.status !== "paused"
826
+ )
827
+ throw new Error("Resume cancelled by stop or shutdown");
828
+ entry.done = new Promise<void>((resolve) => {
829
+ entry.finish = resolve;
830
+ });
831
+ entry.paused = false;
832
+ this.presence.set(entry.state.id, "active");
833
+ entry.pauseDrain = undefined;
834
+ entry.resumeText =
835
+ !entry.prompted && entry.resumeText ? entry.resumeText + "\n\n" + text : text;
836
+ entry.run = undefined;
837
+ this.queue.add(entry);
838
+ this.update(entry, {
839
+ status: "queued",
840
+ pauseReason: undefined,
841
+ error: undefined,
842
+ limits,
843
+ window: (entry.state.window ?? 1) + 1,
844
+ activity: "Continuation queued for admission",
845
+ });
846
+ const window = entry.state.window;
847
+ entry.timer = setTimeout(() => {
848
+ if (entry.state.window === window) this.limit(entry, "deadline");
849
+ }, limits.deadlineMs);
850
+ this.pump();
851
+ return { id: ref.id, generation: ref.generation };
852
+ } finally {
853
+ entry.resuming = false;
854
+ }
855
+ }
856
+ private limit(entry: Entry, reason: string): void {
857
+ if (
858
+ entry.cancelled ||
859
+ entry.state.disposed ||
860
+ entry.state.limitReason ||
861
+ ["settled", "failed", "cancelled"].includes(entry.state.status)
862
+ )
863
+ return;
864
+ if (reason === "deadline") {
865
+ void this.pause(entry.state);
866
+ return;
867
+ }
868
+ this.update(entry, { limitReason: reason, error: `Execution limit: ${reason}` });
869
+ void this.stop(entry.state);
870
+ }
871
+ private chargeToolCall(entry: Entry): void {
872
+ if (entry.state.toolCalls >= entry.state.limits.tools) {
873
+ this.limit(entry, "tools");
874
+ throw new Error("Tool call limit");
875
+ }
876
+ this.update(entry, { toolCalls: entry.state.toolCalls + 1 });
877
+ }
878
+ private async revalidateExecutionModel(
879
+ entry: Entry,
880
+ selected: ModelSelection,
881
+ requirePrewalk: boolean,
882
+ ): Promise<void> {
883
+ const { input } = entry;
884
+ let current: Entry | undefined = entry;
885
+ while (current) {
886
+ const policy = (
887
+ await resolveProfile(
888
+ current.input.profile.name,
889
+ input.context.cwd,
890
+ input.context.isProjectTrusted(),
891
+ undefined,
892
+ current.input.availableSkills?.() ?? current.input.skills,
893
+ )
894
+ ).profile;
895
+ if (
896
+ (current.input.profile.models !== undefined && policy.models === undefined) ||
897
+ !allowsModel(policy, selected) ||
898
+ !admittedModel(current, selected)
899
+ )
900
+ throw new Error("Current or admitted model policy denied selection");
901
+ if (
902
+ current === entry &&
903
+ requirePrewalk &&
904
+ (!policy.prewalk || !sameSelection(policy.prewalk, entry.state.prewalk!.target))
905
+ )
906
+ throw new Error("Current prewalk grant revoked");
907
+ current = current.input.parent ? this.entry(current.input.parent) : undefined;
908
+ }
909
+ validateModelSelection(input.context, selected);
910
+ }
911
+ private assertExecutionTool(
912
+ entry: Entry,
913
+ name: string,
914
+ builtin: boolean,
915
+ signal?: AbortSignal,
916
+ ): void {
917
+ const { input, selection } = entry;
918
+ this.assertActive(entry);
919
+ signal?.throwIfAborted();
920
+ if (builtin) input.assertTools([name]);
921
+ else {
922
+ const metadata = input.toolMetadata?.().find((tool) => tool.name === name);
923
+ if (
924
+ !metadata ||
925
+ leafDenial(
926
+ metadata,
927
+ input.profile.leafTools ?? [],
928
+ selection.cwd === resolveWorkspace(undefined, input.context.cwd),
929
+ ) ||
930
+ toolProvenance(metadata) !== input.leafProvenance?.[name]
931
+ )
932
+ throw new Error(`Leaf capability revoked or reclassified: ${name}`);
933
+ }
934
+ if (!input.context.tools.some((tool) => tool.name === name))
935
+ throw new Error(`Admitted parent tool is no longer callable: ${name}`);
936
+ }
937
+ private chargeExecutionRequest(entry: Entry): void {
938
+ const { input } = entry;
939
+ validateModelSelection(input.context, {
940
+ ...entry.state.model,
941
+ effort: entry.state.effort as ModelSelection["effort"],
942
+ });
943
+ if (entry.cancelled || entry.paused || this.closing) throw new Error("Delegation cancelled");
944
+ if ((entry.state.telemetry.requests ?? 0) >= entry.state.limits.requests) {
945
+ this.limit(entry, "requests");
946
+ throw new Error("Direct request limit");
947
+ }
948
+ this.update(entry, {
949
+ telemetry: {
950
+ ...entry.state.telemetry,
951
+ requests: (entry.state.telemetry.requests ?? 0) + 1,
952
+ },
953
+ });
954
+ }
955
+ private observeExecutionMessage(entry: Entry, text: string): void {
956
+ for (const message of this.coordinationMessages)
957
+ if (
958
+ message.recipient !== "root" &&
959
+ this.key(message.recipient) === this.key(entry.state) &&
960
+ text ===
961
+ `Delegation message ${message.id} from ${message.sender === "root" ? "root" : this.key(message.sender)}: ${message.text}`
962
+ )
963
+ message.status = "delivered";
964
+ this.notifyCoordination();
965
+ }
966
+ private create(entry: Entry): Promise<void> {
967
+ const isClosing = () => this.closing;
968
+ return createChildExecution(this.root, entry, {
969
+ get closing() {
970
+ return isClosing();
971
+ },
972
+ update: (patch, event) => this.update(entry, patch, event),
973
+ revalidateModel: (selected, requirePrewalk) =>
974
+ this.revalidateExecutionModel(entry, selected, requirePrewalk),
975
+ authorizeTool: (name, builtin) =>
976
+ this.authorize(
977
+ entry.state,
978
+ (profile) =>
979
+ builtin
980
+ ? profile.tools.includes(name as BuiltinTool)
981
+ : !!profile.leafTools?.includes(name),
982
+ `Current capability grant revoked: ${name}`,
983
+ ),
984
+ assertTool: (name, builtin, signal) => this.assertExecutionTool(entry, name, builtin, signal),
985
+ chargeRequest: () => this.chargeExecutionRequest(entry),
986
+ chargeToolCall: () => this.chargeToolCall(entry),
987
+ observeUserMessage: (text) => this.observeExecutionMessage(entry, text),
988
+ drainDescendants: () => this.stopMany(this.children(entry).map((child) => child.state)),
989
+ dispose: () => this.dispose(entry),
990
+ });
991
+ }
992
+ private closeDescendantPresence(entry: Entry): void {
993
+ for (const child of this.entries.values())
994
+ if (
995
+ child.state.parentId === entry.state.id &&
996
+ child.state.parentGeneration === entry.state.generation
997
+ ) {
998
+ this.presence.set(child.state.id, "stopped");
999
+ this.closeDescendantPresence(child);
1000
+ }
1001
+ }
1002
+ private dispose(entry: Entry): void {
1003
+ if (entry.state.disposed) return;
1004
+ this.queue.delete(entry);
1005
+ clearTimeout(entry.timer);
1006
+ entry.finalizedMessages = structuredClone(entry.session?.messages ?? []);
1007
+ entry.unsubscribe?.();
1008
+ entry.session?.dispose();
1009
+ // Retain immutable evidence, not the disposed SDK session graph, while idle/parked.
1010
+ entry.unsubscribe = undefined;
1011
+ entry.session = undefined;
1012
+ entry.prompt = undefined;
1013
+ entry.resumeText = undefined;
1014
+ entry.setup = undefined;
1015
+ entry.run = undefined;
1016
+ entry.pauseDrain = undefined;
1017
+ if (this.writer === entry.state.id) this.writer = undefined;
1018
+ this.closeDescendantPresence(entry);
1019
+ this.presence.set(entry.state.id, entry.cancelled || this.closing ? "stopped" : "idle");
1020
+ this.update(entry, {
1021
+ disposed: true,
1022
+ retainedContext: false,
1023
+ endedAt: Date.now(),
1024
+ writerLease: false,
1025
+ outcome: entry.state.outcome ? { ...entry.state.outcome, finalized: true } : undefined,
1026
+ });
1027
+ entry.finish();
1028
+ this.pump();
1029
+ }
1030
+ private key(ref?: ChildRef): string {
1031
+ return ref ? `${ref.id}/${ref.generation}` : "root";
1032
+ }
1033
+ private async actor(
1034
+ ref: ChildRef | undefined,
1035
+ action: CoordinationAction,
1036
+ ): Promise<Entry | undefined> {
1037
+ if (this.closing) throw new Error("Parent shut down");
1038
+ if (!ref) return;
1039
+ const actor = this.entry(ref);
1040
+ if (actor.cancelled || actor.state.disposed || actor.state.status !== "running")
1041
+ throw new Error("Sender is no longer running");
1042
+ if (!actor.input.profile.coordination?.includes(action))
1043
+ throw new Error(`Coordination action denied: ${action}`);
1044
+ await this.authorize(
1045
+ ref,
1046
+ (profile) => !!profile.coordination?.includes(action),
1047
+ `Coordination action denied: ${action}`,
1048
+ );
1049
+ if (actor.state.status !== "running") throw new Error("Sender is no longer running");
1050
+ return actor;
1051
+ }
1052
+ private target(actor: Entry | undefined, ref: ChildRef): Entry {
1053
+ const target = this.entry(ref);
1054
+ // A selected action grants family addressing, never transcript/resource access.
1055
+ if (
1056
+ actor &&
1057
+ target !== actor &&
1058
+ !(
1059
+ target.state.parentId === actor.state.id &&
1060
+ target.state.parentGeneration === actor.state.generation
1061
+ ) &&
1062
+ !(
1063
+ actor.state.parentId === target.state.id &&
1064
+ actor.state.parentGeneration === target.state.generation
1065
+ ) &&
1066
+ !(
1067
+ actor.state.parentId === target.state.parentId &&
1068
+ actor.state.parentGeneration === target.state.parentGeneration
1069
+ )
1070
+ )
1071
+ throw new Error("Coordination target outside granted family");
1072
+ return target;
1073
+ }
1074
+ async inbox(actor?: ChildRef, signal?: AbortSignal): Promise<DelegationMessage[]> {
1075
+ const sender = await this.actor(actor, "inbox");
1076
+ if (sender) this.assertActive(sender);
1077
+ if (this.closing) throw new Error("Parent shut down");
1078
+ signal?.throwIfAborted();
1079
+ return this.takeInbox(actor);
1080
+ }
1081
+ async message(
1082
+ actor: ChildRef | undefined,
1083
+ target: ChildRef | undefined,
1084
+ text: string,
1085
+ mode: "inbox" | "steer" | "followUp" = "inbox",
1086
+ signal?: AbortSignal,
1087
+ ): Promise<DelegationMessage> {
1088
+ if (mode !== "inbox") requireSupportedHost();
1089
+ const sender = await this.actor(actor, "message");
1090
+ if (sender) this.assertActive(sender);
1091
+ if (this.closing) throw new Error("Parent shut down");
1092
+ signal?.throwIfAborted();
1093
+ if (typeof text !== "string" || !text.trim() || Buffer.byteLength(text) > MAX_MESSAGE_BYTES)
1094
+ throw new Error("Message requires 1–4096 UTF-8 bytes");
1095
+ if (!target && !sender) throw new Error("Root cannot message itself");
1096
+ const recipient = target ? this.target(sender, target) : undefined;
1097
+ if (!target && sender?.state.parentId !== this.parentId)
1098
+ throw new Error("Only direct children may message root");
1099
+ if (target && actor && this.key(target) === this.key(actor))
1100
+ throw new Error("Self messaging denied");
1101
+ const record: DelegationMessage = {
1102
+ id: randomUUID(),
1103
+ sender: actor ? { ...actor } : "root",
1104
+ recipient: target ? { ...target } : "root",
1105
+ text,
1106
+ mode,
1107
+ status: "accepted",
1108
+ };
1109
+ if (
1110
+ recipient &&
1111
+ (recipient.cancelled || recipient.state.disposed || recipient.state.status !== "running")
1112
+ )
1113
+ return { ...record, status: "not-running" };
1114
+ if (!target && mode !== "inbox") throw new Error("Root accepts inbox messages only");
1115
+ const key = this.key(target);
1116
+ const mailbox = this.mailboxes.get(key) ?? [];
1117
+ if (
1118
+ mailbox.length >= MAX_MAILBOX_MESSAGES ||
1119
+ this.coordinationMessages.length >= MAX_RETAINED_MESSAGES
1120
+ )
1121
+ throw new Error("Parent message retention/mailbox limit");
1122
+ this.coordinationMessages.push(record);
1123
+ mailbox.push(record);
1124
+ this.mailboxes.set(key, mailbox);
1125
+ this.notifyCoordination();
1126
+ if (mode !== "inbox") {
1127
+ try {
1128
+ const content = `Delegation message ${record.id} from ${this.key(actor)}: ${text}`;
1129
+ const disposition = await recipient!.session![mode](content);
1130
+ // The captured recipient/session is never looked up again by ID.
1131
+ if (record.status !== "delivered")
1132
+ record.status = disposition === "queued" ? "queued" : "handled";
1133
+ } catch (error) {
1134
+ record.status = "error";
1135
+ record.error = String(error);
1136
+ }
1137
+ this.notifyCoordination();
1138
+ }
1139
+ return structuredClone(record);
1140
+ }
1141
+ park(ref: ChildRef): void {
1142
+ if (this.closing) throw new Error("Parent shut down");
1143
+ const entry = this.entry(ref);
1144
+ this.renewalParent(ref);
1145
+ if (!entry.state.disposed || this.presence.get(ref.id) !== "idle")
1146
+ throw new Error("Park requires a disposed idle assignment; stop active work explicitly");
1147
+ this.presence.set(ref.id, "parked");
1148
+ this.update(entry, {});
1149
+ }
1150
+ async coordinateWait(
1151
+ actor: ChildRef | undefined,
1152
+ target: ChildRef | undefined,
1153
+ timeoutMs: number,
1154
+ signal?: AbortSignal,
1155
+ messageId?: string,
1156
+ ): Promise<CoordinationWait> {
1157
+ const sender = await this.actor(actor, "wait");
1158
+ if (sender) this.assertActive(sender);
1159
+ if (signal?.aborted) return { status: "cancelled" };
1160
+ if (this.closing) return { status: "shutdown" };
1161
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_WAIT_MS)
1162
+ throw new Error("Wait timeout must be 1–60000ms");
1163
+ if (this.waiterCount >= MAX_WAITERS) throw new Error("Parent waiter limit");
1164
+ if (target && messageId) throw new Error("Wait selects either an outcome or message receipt");
1165
+ const receipt = messageId
1166
+ ? this.coordinationMessages.find((message) => message.id === messageId)
1167
+ : undefined;
1168
+ if (
1169
+ messageId &&
1170
+ (!receipt ||
1171
+ (this.key(receipt.sender === "root" ? undefined : receipt.sender) !== this.key(actor) &&
1172
+ this.key(receipt.recipient === "root" ? undefined : receipt.recipient) !==
1173
+ this.key(actor)))
1174
+ )
1175
+ throw new Error("Unknown or foreign message receipt");
1176
+ const destination = target ? this.target(sender, target) : undefined;
1177
+ const pendingReceipt =
1178
+ receipt && !["delivered", "handled", "error", "not-running"].includes(receipt.status);
1179
+ if (sender && pendingReceipt && receipt.recipient === "root")
1180
+ throw new Error("Waiting for a pending root receipt with a retained permit is unsupported");
1181
+ const recipientRef = receipt?.recipient === "root" ? undefined : receipt?.recipient;
1182
+ const receiptEntry = recipientRef ? this.entries.get(recipientRef.id) : undefined;
1183
+ const dependency =
1184
+ destination ??
1185
+ (pendingReceipt && receiptEntry?.state.generation === recipientRef?.generation
1186
+ ? receiptEntry
1187
+ : undefined);
1188
+ const sourceKey = this.key(actor),
1189
+ targetKey = dependency ? this.key(dependency.state) : this.key(target);
1190
+ if (sender && dependency) {
1191
+ if (sender === dependency) throw new Error("Self wait denied");
1192
+ let ancestor: Entry | undefined = sender;
1193
+ while (ancestor) {
1194
+ if (ancestor === dependency) throw new Error("Ancestor wait would deadlock");
1195
+ ancestor = this.entries.get(ancestor.state.parentId);
1196
+ }
1197
+ if (dependency.state.status === "queued")
1198
+ throw new Error("Wait on queued work with retained permit is unsupported");
1199
+ const reaches = (key: string, seen = new Set<string>()): boolean => {
1200
+ if (key === sourceKey) return true;
1201
+ if (seen.has(key)) return false;
1202
+ seen.add(key);
1203
+ const children = [...this.entries.values()]
1204
+ .filter(
1205
+ (entry) =>
1206
+ !entry.state.disposed &&
1207
+ entry.state.parentGeneration !== undefined &&
1208
+ `${entry.state.parentId}/${entry.state.parentGeneration}` === key,
1209
+ )
1210
+ .map((entry) => this.key(entry.state));
1211
+ return [...(this.waits.get(key) ?? []), ...children].some((next) => reaches(next, seen));
1212
+ };
1213
+ if (reaches(targetKey)) throw new Error("Coordination wait cycle");
1214
+ }
1215
+ const edges = this.waits.get(sourceKey) ?? new Set<string>();
1216
+ if (dependency && edges.has(targetKey)) throw new Error("Duplicate active wait");
1217
+ if (dependency) {
1218
+ edges.add(targetKey);
1219
+ this.waits.set(sourceKey, edges);
1220
+ }
1221
+ this.waiterCount++;
1222
+ return new Promise<CoordinationWait>((resolve) => {
1223
+ let finished = false;
1224
+ const finish = (value: CoordinationWait) => {
1225
+ if (finished) return;
1226
+ finished = true;
1227
+ clearTimeout(timer);
1228
+ this.coordinationListeners.delete(check);
1229
+ signal?.removeEventListener("abort", abort);
1230
+ if (dependency) {
1231
+ edges.delete(targetKey);
1232
+ if (!edges.size) this.waits.delete(sourceKey);
1233
+ }
1234
+ this.waiterCount--;
1235
+ resolve(value);
1236
+ };
1237
+ const check = () => {
1238
+ if (this.closing) return finish({ status: "shutdown" });
1239
+ if (
1240
+ sender &&
1241
+ (sender.cancelled ||
1242
+ sender.paused ||
1243
+ sender.state.disposed ||
1244
+ this.entries.get(sender.state.id) !== sender)
1245
+ )
1246
+ return finish({ status: "cancelled" });
1247
+ if (receipt) {
1248
+ if (["delivered", "handled", "error", "not-running"].includes(receipt.status))
1249
+ return finish({ status: "message", message: structuredClone(receipt) });
1250
+ if (receipt.recipient !== "root") {
1251
+ const recipient = this.entries.get(receipt.recipient.id);
1252
+ if (
1253
+ !recipient ||
1254
+ recipient.state.generation !== receipt.recipient.generation ||
1255
+ recipient.state.disposed ||
1256
+ recipient.cancelled
1257
+ )
1258
+ return finish({
1259
+ status: "message",
1260
+ message: { ...structuredClone(receipt), status: "not-running" },
1261
+ });
1262
+ }
1263
+ } else if (destination) {
1264
+ if (destination.state.disposed || destination.state.status === "paused")
1265
+ return finish({
1266
+ status: "outcome",
1267
+ outcome: {
1268
+ id: destination.state.id,
1269
+ generation: destination.state.generation,
1270
+ status: destination.state.status,
1271
+ limitReason: destination.state.limitReason,
1272
+ ...(destination.state.pauseReason
1273
+ ? { pauseReason: destination.state.pauseReason }
1274
+ : {}),
1275
+ },
1276
+ });
1277
+ } else if ((this.mailboxes.get(sourceKey)?.length ?? 0) > 0)
1278
+ return finish({ status: "messages", messages: this.takeInbox(actor) });
1279
+ };
1280
+ const abort = () => finish({ status: "cancelled" });
1281
+ const timer = setTimeout(() => finish({ status: "timeout" }), timeoutMs);
1282
+ this.coordinationListeners.add(check);
1283
+ signal?.addEventListener("abort", abort, { once: true });
1284
+ if (signal?.aborted) abort();
1285
+ else check();
1286
+ });
1287
+ }
1288
+ private takeInbox(actor?: ChildRef): DelegationMessage[] {
1289
+ const key = this.key(actor),
1290
+ messages = this.mailboxes.get(key) ?? [];
1291
+ this.mailboxes.delete(key);
1292
+ for (const message of messages) if (message.mode === "inbox") message.status = "delivered";
1293
+ this.notifyCoordination();
1294
+ return structuredClone(messages);
1295
+ }
1296
+ async taskInputRead(ref: ChildRef, signal?: AbortSignal): Promise<void> {
1297
+ await this.authorize(
1298
+ ref,
1299
+ (profile) => profile.tools.includes("read"),
1300
+ "Delegating parent has no active read grant",
1301
+ );
1302
+ const entry = this.entry(ref);
1303
+ this.assertActive(entry);
1304
+ signal?.throwIfAborted();
1305
+ this.chargeToolCall(entry);
1306
+ }
1307
+ async steer(ref: ChildRef, text: string): Promise<"queued" | "handled"> {
1308
+ requireSupportedHost();
1309
+ const entry = this.entry(ref);
1310
+ if (!text.trim()) throw new Error("Nonempty steering text is required");
1311
+ if (entry.state.status !== "running" || entry.cancelled) throw new Error("No running child");
1312
+ return entry.session!.steer(text);
1313
+ }
1314
+ private children(entry: Entry): Entry[] {
1315
+ return [...this.entries.values()].filter(
1316
+ (child) =>
1317
+ child.state.parentId === entry.state.id &&
1318
+ child.state.parentGeneration === entry.state.generation &&
1319
+ !child.state.disposed,
1320
+ );
1321
+ }
1322
+ async stopMany(refs: readonly ChildRef[]): Promise<void> {
1323
+ const entries = refs.map((ref) => this.entry(ref));
1324
+ // Mark the whole cancellation set before any disposal can dequeue a sibling.
1325
+ for (const entry of entries) if (!entry.state.disposed) entry.cancelled = true;
1326
+ await Promise.all(entries.map((entry) => this.stop(entry.state)));
1327
+ }
1328
+ async stop(ref: ChildRef): Promise<void> {
1329
+ const entry = this.entry(ref);
1330
+ this.presence.set(entry.state.id, "stopped");
1331
+ this.closeDescendantPresence(entry);
1332
+ this.notifyCoordination();
1333
+ if (entry.state.disposed) return;
1334
+ entry.cancelled = true;
1335
+ if (entry.state.status === "queued") {
1336
+ this.update(entry, { status: "cancelled", activity: "Cancelled before setup" });
1337
+ this.dispose(entry);
1338
+ return;
1339
+ }
1340
+ this.update(entry, { status: "draining", activity: "Cancelling and draining" });
1341
+ // Start subtree cancellation without waiting for tools queued behind this session.
1342
+ const descendants = this.stopMany(this.children(entry).map((child) => child.state));
1343
+ await entry.setup?.catch(() => {});
1344
+ await Promise.all([descendants, entry.state.disposed ? undefined : entry.session?.abort()]);
1345
+ if (entry.state.disposed) return;
1346
+ await entry.run;
1347
+ if (!entry.state.disposed) this.update(entry, { status: "cancelled" });
1348
+ this.dispose(entry);
1349
+ }
1350
+ async wait(ref: ChildRef): Promise<ChildSnapshot> {
1351
+ const entry = this.entry(ref);
1352
+ await entry.done;
1353
+ return structuredClone(entry.state);
1354
+ }
1355
+ /** Detached public SDK transcript for one admitted generation; no replay or arbitrary file access. */
1356
+ messages(ref: ChildRef): AgentSession["messages"] {
1357
+ const historical = this.history.get(this.key(ref));
1358
+ if (historical) return structuredClone(historical.messages);
1359
+ const entry = this.entry(ref);
1360
+ return structuredClone(entry.session?.messages ?? entry.finalizedMessages ?? []);
1361
+ }
1362
+ reopenTranscript(ref: ChildRef) {
1363
+ const path = this.snapshot(ref)?.transcript;
1364
+ if (!path) throw new Error("No transcript yet");
1365
+ return SessionManager.open(path).buildSessionContext();
1366
+ }
1367
+ shutdown(): Promise<void> {
1368
+ this.closing = true;
1369
+ this.notifyCoordination();
1370
+ this.shutdownPromise ??= (async () => {
1371
+ await Promise.all(this.list().map((ref) => this.stop(ref)));
1372
+ this.listeners.clear();
1373
+ })();
1374
+ return this.shutdownPromise;
1375
+ }
1376
+ }