@howarewoo/pi-subagents 0.1.1 → 0.2.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.
@@ -1,42 +1,22 @@
1
- import {
2
- leafDenial,
3
- toolProvenance,
4
- type ToolMetadata,
5
- type CapabilityDenial,
6
- } from "./capabilities.ts";
1
+ import { requireSupportedHost } from "./supported-host.ts";
2
+ import { leafDenial, toolProvenance, type CapabilityDenial } from "./capabilities.ts";
7
3
  import { randomUUID } from "node:crypto";
8
- import { mkdir } from "node:fs/promises";
9
- import { join } from "node:path";
10
4
  import {
11
- createAgentSession,
12
- createExtensionRuntime,
13
- DefaultResourceLoader,
14
5
  SessionManager,
15
- SettingsManager,
16
6
  type AgentSession,
17
7
  type AgentSessionEvent,
18
- type ExtensionToolContext,
19
- type ResourceLoader,
20
- type Skill,
21
- type ToolDefinition,
22
8
  } from "@earendil-works/pi-coding-agent";
23
- import {
24
- resultValidator,
25
- submissionParameters,
26
- saveOutcomeArtifact,
27
- type StructuredOutcome,
28
- } from "./outcomes.ts";
29
- import type { Validator } from "@earendil-works/pi-schema";
9
+ import { resultValidator } from "./outcomes.ts";
30
10
  import {
31
11
  allowsModel,
32
12
  sameSelection,
33
13
  validateModelSelection,
34
14
  type ModelSelection,
35
15
  } from "./model-policy.ts";
36
- import { parentModelRuntime } from "./model-adapter.ts";
37
- import { resolveWorkspace, workspaceArguments } from "./workspace.ts";
16
+ import { resolveWorkspace } from "./workspace.ts";
38
17
  import {
39
18
  DEFAULT_POLICY,
19
+ resolveScheduling,
40
20
  narrowLimits,
41
21
  validatePolicy,
42
22
  type ExecutionLimits,
@@ -59,111 +39,44 @@ const MAX_WAITERS = 64;
59
39
  const MAX_WAIT_MS = 60000;
60
40
  const MAX_GENERATIONS = 64;
61
41
 
62
- export type DelegationMessage = {
63
- id: string;
64
- sender: ChildRef | "root";
65
- recipient: ChildRef | "root";
66
- text: string;
67
- mode: "inbox" | "steer" | "followUp";
68
- status: "accepted" | "queued" | "handled" | "delivered" | "not-running" | "error";
69
- error?: string;
70
- };
71
- export type CoordinationWait =
72
- | { status: "timeout" | "cancelled" | "shutdown" }
73
- | { status: "messages"; messages: DelegationMessage[] }
74
- | { status: "message"; message: DelegationMessage }
75
- | {
76
- status: "outcome";
77
- outcome: Pick<ChildSnapshot, "id" | "generation" | "status" | "limitReason">;
78
- };
79
- export type ChildRef = Readonly<{ id: string; generation: number }>;
80
- export type ChildSnapshot = Readonly<
81
- ChildRef & {
82
- parentId: string;
83
- parentGeneration?: number;
84
- depth: number;
85
- writerLease: boolean;
86
- limits: ExecutionLimits;
87
- limitReason?: string;
88
- toolCalls: number;
89
- status: "queued" | "setup" | "running" | "draining" | "settled" | "failed" | "cancelled";
90
- profile: string;
91
- tools: readonly BuiltinTool[];
92
- leafTools?: readonly string[];
93
- skills?: readonly string[];
94
- capabilityDenials?: readonly CapabilityDenial[];
95
- cwd?: string;
96
- assignment: string;
97
- activity: string;
98
- progress: string;
99
- result: string;
100
- model: { provider: string; id: string };
101
- effort: string | null;
102
- prewalk?: {
103
- target: ModelSelection;
104
- status: "armed" | "handed-off" | "suppressed-exact" | "no-op";
105
- };
106
- telemetry: {
107
- requests: number | null;
108
- tokens: ReturnType<AgentSession["getSessionStats"]>["tokens"] | null;
109
- cost: number | null;
110
- context: ReturnType<AgentSession["getContextUsage"]> | null;
111
- };
112
- outcome?: StructuredOutcome;
113
- artifacts?: { text?: string; structured?: string; error?: string };
114
- error?: string;
115
- transcript?: string;
116
- disposed: boolean;
117
- createdAt: number;
118
- endedAt: number | null;
119
- }
120
- >;
121
- export type RegistryEvent = Readonly<{
122
- type: "changed" | "sdk";
123
- child: ChildSnapshot;
124
- event?: AgentSessionEvent;
125
- }>;
126
- export type StartTask = {
127
- cwd?: string;
128
- model?: ModelSelection;
129
- resultSchema?: unknown;
130
- assignment: string;
131
- profile: Profile;
132
- skills: Skill[];
133
- context: ExtensionToolContext;
134
- assertTools: (names: readonly string[]) => void;
135
- toolMetadata?: () => readonly ToolMetadata[];
136
- availableSkills?: () => readonly Skill[];
137
- capabilityDenials?: CapabilityDenial[];
138
- leafProvenance?: Readonly<Record<string, string>>;
139
- callerId: string;
140
- parent?: ChildRef;
141
- limits?: Partial<ExecutionLimits>;
142
- coordinationTools?: (actor: ChildRef) => ToolDefinition[];
143
- nestedTool?: (parent: ChildRef) => ToolDefinition;
144
- };
145
- type ChildSelection = Pick<
146
- ExtensionToolContext,
147
- "model" | "modelRegistry" | "scopedModels" | "thinkingLevel" | "cwd"
148
- >;
149
- type Entry = {
150
- validator?: Validator;
151
- input: StartTask;
152
- selection: ChildSelection;
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 & {
153
62
  done: Promise<void>;
154
63
  finish: () => void;
155
- timer?: ReturnType<typeof setTimeout>;
156
64
  spawned: number;
157
- state: ChildSnapshot;
158
65
  finalizedMessages?: AgentSession["messages"];
159
66
  setup?: Promise<void>;
160
- run?: Promise<void>;
161
- session?: AgentSession;
162
- unsubscribe?: () => void;
163
- cancelled: boolean;
67
+ resuming?: boolean;
68
+ pauseDrain?: Promise<void>;
164
69
  callerId: string;
165
70
  };
166
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
+
167
80
  function admittedModel(entry: Entry, selection: ModelSelection): boolean {
168
81
  return entry.input.profile.models === undefined
169
82
  ? sameSelection(selection, {
@@ -173,31 +86,15 @@ function admittedModel(entry: Entry, selection: ModelSelection): boolean {
173
86
  : allowsModel(entry.input.profile, selection);
174
87
  }
175
88
 
176
- export function childResources(prompt: string, skills: Skill[] = []): ResourceLoader {
177
- const extensions = { extensions: [], errors: [], runtime: createExtensionRuntime() };
178
- return {
179
- getExtensions: () => extensions,
180
- getSkills: () => ({ skills, diagnostics: [] }),
181
- getPrompts: () => ({ prompts: [], diagnostics: [] }),
182
- getThemes: () => ({ themes: [], diagnostics: [] }),
183
- getAgentsFiles: () => ({ agentsFiles: [] }),
184
- getSystemPrompt: () =>
185
- prompt +
186
- "\nYou are a delegated child. Complete only this assignment; use only explicitly granted tools and spawn roles.",
187
- getSystemPromptSource: () => undefined,
188
- getAppendSystemPrompt: () => [],
189
- getAppendSystemPromptSources: () => [],
190
- extendResources: () => {},
191
- reload: async () => {},
192
- };
193
- }
194
-
195
89
  /** Authoritative parent-lifetime registry. IDs are never reused; controls require their generation. */
196
90
  export class DelegationCoordinator {
197
91
  private entries = new Map<string, Entry>();
198
92
  private queue = new Set<Entry>();
199
93
  private history = new Map<string, { state: ChildSnapshot; messages: AgentSession["messages"] }>();
200
- private presence = new Map<string, "active" | "idle" | "parked" | "stopped">();
94
+ private presence = new Map<
95
+ string,
96
+ "active" | "pausing" | "paused" | "idle" | "parked" | "stopped"
97
+ >();
201
98
  private coordinationListeners = new Set<() => void>();
202
99
  private coordinationMessages: DelegationMessage[] = [];
203
100
  private mailboxes = new Map<string, DelegationMessage[]>();
@@ -291,10 +188,11 @@ export class DelegationCoordinator {
291
188
  }
292
189
  private active(): Entry[] {
293
190
  return [...this.entries.values()].filter(
294
- (entry) => !entry.state.disposed && entry.state.status !== "queued",
191
+ (entry) =>
192
+ !entry.state.disposed && entry.state.status !== "queued" && entry.state.status !== "paused",
295
193
  );
296
194
  }
297
- private validate(input: StartTask): ChildSelection {
195
+ private validate(input: StartTask): ExecutionRecord["selection"] {
298
196
  if (this.closing) throw new Error("Parent shut down");
299
197
  if (!input.assignment.trim()) throw new Error("A nonempty assignment is required");
300
198
  const selection = {
@@ -334,27 +232,14 @@ export class DelegationCoordinator {
334
232
  throw new Error("Shared checkout writer conflict: in-flight parent mutation");
335
233
  if (input.parent) {
336
234
  const parent = this.entry(input.parent);
337
- if (parent.cancelled || parent.state.disposed)
235
+ if (parent.cancelled || parent.paused || parent.state.disposed)
338
236
  throw new Error("Delegating parent is no longer active");
339
- if (
340
- !admittedModel(parent, effective) ||
341
- input.profile.models?.some((model) => !admittedModel(parent, model)) ||
342
- (input.profile.prewalk && !input.model && !admittedModel(parent, input.profile.prewalk))
343
- )
344
- throw new Error("Descendant cannot broaden ancestor model grants");
237
+ this.assertDescendantModels(parent, input.profile, effective, input.model);
345
238
  if (!parent.input.profile.spawn?.includes(input.profile.name))
346
239
  throw new Error("Ancestor spawn role denied");
347
240
  if (parent.state.depth >= Math.min(this.policy.depth, parent.state.limits.depth))
348
241
  throw new Error("Delegation depth limit");
349
- if (
350
- input.profile.tools.some((tool) => !parent.input.profile.tools.includes(tool)) ||
351
- input.profile.skills.some((skill) => !parent.input.profile.skills.includes(skill)) ||
352
- input.profile.leafTools?.some((name) => !parent.input.profile.leafTools?.includes(name)) ||
353
- input.profile.spawn?.some((role) => !parent.input.profile.spawn?.includes(role)) ||
354
- input.profile.coordination?.some(
355
- (action) => !parent.input.profile.coordination?.includes(action),
356
- )
357
- )
242
+ if (broadenedGrant(input.profile, parent.input.profile))
358
243
  throw new Error("Descendant cannot broaden ancestor grants");
359
244
  let ancestor: Entry | undefined = parent;
360
245
  while (ancestor) {
@@ -365,8 +250,142 @@ export class DelegationCoordinator {
365
250
  }
366
251
  return selection;
367
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
+ }
368
387
  /** Whole-batch synchronous validation precedes registry insertion, disk setup or dispatch. */
369
- admit(inputs: StartTask[], replacement?: ChildRef): ChildRef[] {
388
+ private admitAuthorized(inputs: StartTask[], replacement?: ChildRef): ChildRef[] {
370
389
  const previous = replacement ? this.entry(replacement) : undefined;
371
390
  inputs = inputs.map((input) => {
372
391
  const cwd = resolveWorkspace(
@@ -379,39 +398,28 @@ export class DelegationCoordinator {
379
398
  throw new Error("Follow-up must retain its original workspace");
380
399
  if (typeof input.assertTools !== "function")
381
400
  throw new Error("Delegation requires a parent builtin provenance validator");
382
- const callable = new Set(input.context.tools.map((tool) => tool.name));
383
- const capabilityDenials: CapabilityDenial[] = [];
384
- const leafTools: string[] = [];
385
- const leafProvenance: Record<string, string> = {};
386
- if (input.profile.name === "general") {
387
- const metadata = input.toolMetadata?.() ?? [];
388
- const sameWorkspace = cwd === resolveWorkspace(undefined, input.context.cwd);
389
- for (const tool of metadata) {
390
- if (input.profile.tools.includes(tool.name as BuiltinTool)) continue;
391
- const reason = !callable.has(tool.name)
392
- ? "tool is not live callable"
393
- : leafDenial(tool, input.profile.leafTools ?? [], sameWorkspace);
394
- if (reason) capabilityDenials.push({ name: tool.name, reason });
395
- else {
396
- leafTools.push(tool.name);
397
- leafProvenance[tool.name] = toolProvenance(tool);
398
- }
399
- }
400
- for (const name of input.profile.leafTools ?? [])
401
- if (!metadata.some((tool) => tool.name === name))
402
- capabilityDenials.push({ name, reason: "tool metadata unavailable" });
403
- }
404
- const profile = {
405
- ...input.profile,
406
- leafTools,
407
- tools: input.profile.tools.filter((name) => callable.has(name)),
408
- };
401
+ const { profile, capabilityDenials, leafProvenance } = this.effectiveCapabilities(
402
+ input.profile,
403
+ input,
404
+ cwd,
405
+ );
409
406
  if (!profile.tools.length)
410
407
  throw new Error(
411
408
  `Profile ${profile.name} has no callable parent builtin tools; enable an allowed parent tool or choose a usable profile`,
412
409
  );
413
410
  input.assertTools(profile.tools);
414
- return { ...input, profile, cwd, capabilityDenials, leafProvenance };
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 };
415
423
  });
416
424
  if (previous) {
417
425
  if (
@@ -434,13 +442,7 @@ export class DelegationCoordinator {
434
442
  throw new Error("Follow-up cannot broaden previous model grants");
435
443
  if (
436
444
  profile.name !== previous.input.profile.name ||
437
- profile.tools.some((tool) => !previous.input.profile.tools.includes(tool)) ||
438
- profile.skills.some((skill) => !previous.input.profile.skills.includes(skill)) ||
439
- profile.leafTools?.some((name) => !previous.input.profile.leafTools?.includes(name)) ||
440
- profile.spawn?.some((role) => !previous.input.profile.spawn?.includes(role)) ||
441
- profile.coordination?.some(
442
- (action) => !previous.input.profile.coordination?.includes(action),
443
- )
445
+ broadenedGrant(profile, previous.input.profile)
444
446
  )
445
447
  throw new Error("Follow-up cannot broaden previous grants");
446
448
  }
@@ -465,18 +467,7 @@ export class DelegationCoordinator {
465
467
  return narrowLimits(maximum, input.limits ?? {});
466
468
  });
467
469
  const queued = this.queue.size;
468
- const pending = [...this.queue].map((entry) => entry.input).concat(inputs);
469
- let slots = Math.max(0, this.policy.concurrency - this.active().length);
470
- let writerBusy = !!this.writer || !!this.parentMutations.size;
471
- let dispatchable = 0;
472
- for (const input of pending) {
473
- if (!slots || (isWriter(input.profile) && writerBusy)) break;
474
- slots--;
475
- dispatchable++;
476
- if (isWriter(input.profile)) writerBusy = true;
477
- }
478
- if (pending.length - dispatchable > this.policy.queue)
479
- throw new Error("Delegation queue capacity limit");
470
+ this.assertQueueCapacity(inputs);
480
471
  const parents = new Map<Entry, number>();
481
472
  for (const input of inputs)
482
473
  if (input.parent) {
@@ -523,6 +514,9 @@ export class DelegationCoordinator {
523
514
  writerLease: false,
524
515
  limits: limits[index],
525
516
  toolCalls: 0,
517
+ spawned: previous?.spawned ?? 0,
518
+ window: 1,
519
+ retainedContext: false,
526
520
  profile: input.profile.name,
527
521
  tools: [...input.profile.tools],
528
522
  leafTools: [...(input.profile.leafTools ?? [])],
@@ -558,7 +552,10 @@ export class DelegationCoordinator {
558
552
  };
559
553
  return entry;
560
554
  });
561
- for (const [parent, count] of parents) parent.spawned += count;
555
+ for (const [parent, count] of parents) {
556
+ parent.spawned += count;
557
+ this.update(parent, { spawned: parent.spawned });
558
+ }
562
559
  if (previous)
563
560
  this.history.set(this.key(previous.state), {
564
561
  state: structuredClone(previous.state),
@@ -579,7 +576,7 @@ export class DelegationCoordinator {
579
576
  return entries.map((entry) => ({ id: entry.state.id, generation: entry.state.generation }));
580
577
  }
581
578
  async start(input: StartTask): Promise<ChildRef> {
582
- const [ref] = this.admit([input]);
579
+ const [ref] = await this.admit([input]);
583
580
  await this.entry(ref).setup;
584
581
  return ref;
585
582
  }
@@ -592,7 +589,8 @@ export class DelegationCoordinator {
592
589
  }
593
590
  return parent && { id: parent.id, generation: parent.generation };
594
591
  }
595
- renew(ref: ChildRef, input: StartTask): ChildRef {
592
+ async renew(ref: ChildRef, input: StartTask, signal?: AbortSignal): Promise<ChildRef> {
593
+ requireSupportedHost();
596
594
  const previous = this.entry(ref);
597
595
  this.renewalParent(ref);
598
596
  const profile = intersectProfileGrants(input.profile, previous.input.profile);
@@ -600,24 +598,41 @@ export class DelegationCoordinator {
600
598
  previous.state.prewalk && ["handed-off", "no-op"].includes(previous.state.prewalk.status)
601
599
  ? previous.state.prewalk.target
602
600
  : previous.input.model;
603
- const next = this.admit(
604
- [
605
- {
606
- ...input,
607
- model: retained ?? input.model,
608
- profile,
609
- skills: input.skills.filter((skill) => profile.skills.includes(skill.filePath)),
610
- resultSchema:
611
- input.resultSchema === undefined ? previous.input.resultSchema : input.resultSchema,
612
- limits: narrowLimits(previous.state.limits, input.limits ?? {}),
613
- },
614
- ],
615
- ref,
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
+ )
616
617
  )[0];
617
618
  if (previous.state.prewalk && previous.state.prewalk.status !== "armed")
618
619
  this.update(this.entry(next), { prewalk: previous.state.prewalk });
619
620
  return next;
620
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
+ }
621
636
  private pump(): void {
622
637
  if (this.closing) return;
623
638
  for (const entry of this.queue) {
@@ -633,445 +648,346 @@ export class DelegationCoordinator {
633
648
  writerLease: writer,
634
649
  activity: "Preparing SDK child",
635
650
  };
636
- entry.setup = this.create(entry, entry.input, entry.selection);
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);
637
656
  this.update(entry, {});
638
657
  void entry.setup.catch(() => {});
639
658
  }
640
659
  }
641
- private limit(entry: Entry, reason: string): void {
642
- if (
643
- entry.cancelled ||
644
- entry.state.disposed ||
645
- entry.state.limitReason ||
646
- ["settled", "failed", "cancelled"].includes(entry.state.status)
647
- )
648
- return;
649
- this.update(entry, { limitReason: reason, error: `Execution limit: ${reason}` });
650
- void this.stop(entry.state);
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;
651
719
  }
652
- private async create(entry: Entry, input: StartTask, selection: ChildSelection): Promise<void> {
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;
653
728
  try {
654
- await mkdir(this.root, { recursive: true });
655
- const taskDir = join(
656
- this.root,
657
- entry.state.generation === 1
658
- ? entry.state.id
659
- : `${entry.state.id}-g${entry.state.generation}`,
660
- );
661
- await mkdir(taskDir); // exclusive ownership: never reuse an existing task record
662
- const agentDir = join(taskDir, "agent");
663
- await mkdir(agentDir);
664
- const target =
665
- entry.state.prewalk?.status === "armed"
666
- ? validateModelSelection(input.context, entry.state.prewalk.target)
667
- : undefined;
668
- let policyError: string | undefined;
669
- const effective = (): ModelSelection => ({
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 = {
670
743
  ...entry.state.model,
671
744
  effort: entry.state.effort as ModelSelection["effort"],
672
- });
673
- const revalidate = async (selected: ModelSelection, requirePrewalk: boolean) => {
674
- let current: Entry | undefined = entry;
675
- while (current) {
676
- const policy = (
677
- await resolveProfile(
678
- current.input.profile.name,
679
- input.context.cwd,
680
- input.context.isProjectTrusted(),
681
- undefined,
682
- current.input.availableSkills?.() ?? current.input.skills,
683
- )
684
- ).profile;
685
- if (
686
- (current.input.profile.models !== undefined && policy.models === undefined) ||
687
- !allowsModel(policy, selected) ||
688
- !admittedModel(current, selected)
689
- )
690
- throw new Error("Current or admitted model policy denied selection");
691
- if (
692
- current === entry &&
693
- requirePrewalk &&
694
- (!policy.prewalk || !sameSelection(policy.prewalk, entry.state.prewalk!.target))
695
- )
696
- throw new Error("Current prewalk grant revoked");
697
- current = current.input.parent ? this.entry(current.input.parent) : undefined;
698
- }
699
- validateModelSelection(input.context, selected);
700
745
  };
701
- const runtime = await parentModelRuntime(
702
- selection,
703
- agentDir,
704
- () => {
705
- if (policyError) throw new Error(policyError);
706
- validateModelSelection(input.context, effective());
707
- if (entry.cancelled || this.closing) throw new Error("Delegation cancelled");
708
- if ((entry.state.telemetry.requests ?? 0) >= entry.state.limits.requests) {
709
- this.limit(entry, "requests");
710
- throw new Error("Direct request limit");
711
- }
712
- this.update(entry, {
713
- telemetry: {
714
- ...entry.state.telemetry,
715
- requests: (entry.state.telemetry.requests ?? 0) + 1,
716
- },
717
- });
718
- },
719
- target ? [target] : [],
720
- );
721
- const resources = childResources(
722
- input.profile.prompt +
723
- (target
724
- ? "\nPrewalk: investigate and plan using only granted context/tools. After the first completed edit or write the same conversation switches once to the explicitly granted implementation target; all normal budgets still apply."
725
- : "") +
726
- `\nYour delegation identity is ${entry.state.id} generation ${entry.state.generation}; parent ${entry.state.parentId}. Coordination, if granted, is limited to family identities explicitly supplied to you.` +
727
- (entry.validator
728
- ? "\nThis assignment requires a structured result. Call submit_result with the actual value; final prose is not a submission."
729
- : ""),
730
- input.skills,
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),
731
778
  );
732
- const loader = new DefaultResourceLoader({
733
- cwd: selection.cwd,
734
- agentDir,
735
- settingsManager: SettingsManager.inMemory(),
736
- noExtensions: true,
737
- noSkills: true,
738
- noPromptTemplates: true,
739
- noThemes: true,
740
- noContextFiles: true,
741
- extensionFactories: [
742
- (pi) => {
743
- pi.on("turn_start", async () => {
744
- try {
745
- await revalidate(effective(), entry.state.prewalk?.status === "armed");
746
- } catch (error) {
747
- policyError = String(error);
748
- }
749
- });
750
- pi.on("turn_end", async (event) => {
751
- if (
752
- !entry.cancelled &&
753
- !this.closing &&
754
- entry.state.prewalk?.status === "armed" &&
755
- event.toolResults.some(
756
- (result) =>
757
- (result.toolName === "edit" || result.toolName === "write") &&
758
- input.profile.tools.includes(result.toolName),
759
- )
760
- ) {
761
- try {
762
- const next = entry.state.prewalk.target;
763
- await revalidate(next, true);
764
- if (entry.cancelled || this.closing) return;
765
- await entry.session!.setModel(target!);
766
- entry.session!.setThinkingLevel(next.effort);
767
- if (entry.session!.thinkingLevel !== next.effort)
768
- throw new Error("SDK coerced prewalk effort");
769
- this.update(entry, {
770
- model: { provider: next.provider, id: next.id },
771
- effort: next.effort,
772
- prewalk: { target: next, status: "handed-off" },
773
- });
774
- } catch (error) {
775
- policyError = String(error);
776
- }
777
- }
778
- });
779
- },
780
- ],
781
- });
782
- await loader.reload();
783
- resources.getExtensions = () => loader.getExtensions();
784
- const tools: ToolDefinition[] = [
785
- ...input.profile.tools,
786
- ...(input.profile.leafTools ?? []),
787
- ].map((name) => {
788
- const tool = input.context.tools.find((tool) => tool.name === name);
789
- if (!tool) throw new Error(`Admitted parent tool is no longer callable: ${name}`);
790
- return {
791
- name,
792
- label: name,
793
- description: tool.description,
794
- parameters: tool.parameters,
795
- execute: async (_id, args, signal, onUpdate) => {
796
- if (entry.cancelled || this.closing) throw new Error("Delegation cancelled");
797
- if (!input.context.tools.some((tool) => tool.name === name))
798
- throw new Error(`Admitted parent tool is no longer callable: ${name}`);
799
- const builtin = input.profile.tools.includes(name as BuiltinTool);
800
- // Re-read configuration for this generation and every ancestor before dispatch.
801
- let current: Entry | undefined = entry;
802
- while (current) {
803
- const { profile } = await resolveProfile(
804
- current.input.profile.name,
805
- input.context.cwd,
806
- input.context.isProjectTrusted(),
807
- undefined,
808
- current.input.availableSkills?.() ?? current.input.skills,
809
- );
810
- if (
811
- builtin
812
- ? !profile.tools.includes(name as BuiltinTool)
813
- : !profile.leafTools?.includes(name)
814
- )
815
- throw new Error(`Current capability grant revoked: ${name}`);
816
- current = current.input.parent ? this.entry(current.input.parent) : undefined;
817
- }
818
- if (entry.cancelled || this.closing) throw new Error("Delegation cancelled");
819
- if (builtin) input.assertTools([name]);
820
- else {
821
- const metadata = input.toolMetadata?.().find((tool) => tool.name === name);
822
- if (
823
- !metadata ||
824
- leafDenial(
825
- metadata,
826
- input.profile.leafTools ?? [],
827
- selection.cwd === resolveWorkspace(undefined, input.context.cwd),
828
- ) ||
829
- toolProvenance(metadata) !== input.leafProvenance?.[name]
830
- )
831
- throw new Error(`Leaf capability revoked or reclassified: ${name}`);
832
- }
833
- if (!input.context.tools.some((tool) => tool.name === name))
834
- throw new Error(`Admitted parent tool is no longer callable: ${name}`);
835
- // The public parent dispatcher preserves validation and permission hooks. Do not
836
- // call a raw create*Tool.execute here: that would bypass operator policy.
837
- const outcome = await input.context.executeTool(
838
- name,
839
- builtin
840
- ? workspaceArguments(name as BuiltinTool, args, selection.cwd, input.context.cwd)
841
- : args,
842
- { signal, onUpdate },
843
- );
844
- return { ...outcome.result, isError: outcome.isError };
845
- },
846
- };
847
- });
848
- if (entry.validator) {
849
- const submission: ToolDefinition<typeof submissionParameters> = {
850
- name: "submit_result",
851
- label: "Submit result",
852
- exposure: "model-only",
853
- description: `Submit the assignment's required structured JSON value exactly once. Invalid values return errors and can be corrected within normal limits. Submission is provisional until prompt/tools/descendants drain; it never overrides cancellation or failure. Required schema: ${JSON.stringify(input.resultSchema)}`,
854
- parameters: submissionParameters,
855
- execute: async (_id, args, signal) => {
856
- signal?.throwIfAborted();
857
- if (
858
- entry.cancelled ||
859
- this.closing ||
860
- entry.state.disposed ||
861
- entry.state.status !== "running"
862
- )
863
- throw new Error("Submission identity is no longer running");
864
- const outcome = entry.state.outcome!;
865
- if (outcome.validation === "submitted")
866
- throw new Error("A result was already submitted; duplicate submission rejected");
867
- const checked = entry.validator!.validate(args.value);
868
- if (!checked.ok) {
869
- const errors = checked.errors
870
- .map((error) => `${error.path || "/"}: ${error.message}`)
871
- .slice(0, 32);
872
- this.update(entry, {
873
- outcome: {
874
- validation: "invalid",
875
- attempts: outcome.attempts + 1,
876
- errors,
877
- finalized: false,
878
- },
879
- });
880
- throw new Error(
881
- "Invalid structured result; correct and resubmit: " + errors.join("; "),
882
- );
883
- }
884
- this.update(entry, {
885
- outcome: {
886
- validation: "submitted",
887
- attempts: outcome.attempts + 1,
888
- errors: [],
889
- value: structuredClone(args.value),
890
- finalized: false,
891
- },
892
- });
893
- return {
894
- content: [
895
- {
896
- type: "text",
897
- text: "Structured value submitted; final outcome awaits prompt/tool/descendant drain.",
898
- },
899
- ],
900
- details: {},
901
- };
902
- },
903
- };
904
- tools.push(submission);
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);
905
784
  }
906
- if (input.profile.coordination?.length && input.coordinationTools)
907
- tools.push(
908
- ...input.coordinationTools({ id: entry.state.id, generation: entry.state.generation }),
909
- );
910
- if (input.profile.spawn?.length && input.nestedTool)
911
- tools.push(input.nestedTool(entry.state));
912
- for (const tool of tools) {
913
- const execute = tool.execute;
914
- tool.execute = async (...args) => {
915
- if (entry.cancelled || this.closing) throw new Error("Delegation cancelled");
916
- if (entry.state.toolCalls >= entry.state.limits.tools) {
917
- this.limit(entry, "tools");
918
- throw new Error("Tool call limit");
919
- }
920
- this.update(entry, { toolCalls: entry.state.toolCalls + 1 });
921
- return execute(...args);
922
- };
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
+ }
923
802
  }
924
- const { session, modelFallbackMessage } = await createAgentSession({
925
- cwd: selection.cwd,
926
- agentDir,
927
- modelRuntime: runtime,
928
- model: selection.model,
929
- thinkingLevel: selection.thinkingLevel,
930
- scopedModels: [
931
- { model: selection.model!, thinkingLevel: selection.thinkingLevel },
932
- ...(target ? [{ model: target, thinkingLevel: input.profile.prewalk!.effort }] : []),
933
- ],
934
- resourceLoader: resources,
935
- tools: tools.map((tool) => tool.name),
936
- customTools: tools,
937
- settingsManager: SettingsManager.inMemory({
938
- compaction: { enabled: false },
939
- retry: { enabled: false },
940
- }),
941
- sessionManager: SessionManager.create(selection.cwd, join(taskDir, "transcripts")),
942
- });
943
- entry.session = session;
944
- if (session.thinkingLevel !== selection.thinkingLevel)
945
- throw new Error("SDK did not preserve selected effort");
946
803
  if (
947
- modelFallbackMessage ||
948
- session.model?.id !== selection.model?.id ||
949
- session.model?.provider !== selection.model?.provider
804
+ (entry.state.telemetry.requests ?? 0) >= limits.requests ||
805
+ entry.state.toolCalls >= limits.tools ||
806
+ entry.spawned > limits.spawns
950
807
  )
951
- throw new Error("SDK did not preserve selected model");
952
- let markStarted!: () => void;
953
- const started = new Promise<void>((resolve) => {
954
- markStarted = resolve;
955
- });
956
- entry.unsubscribe = session.subscribe((event) => {
957
- const patch: { -readonly [K in keyof ChildSnapshot]?: ChildSnapshot[K] } = {};
958
- if (event.type === "agent_start") {
959
- patch.status = entry.cancelled ? "draining" : "running";
960
- markStarted();
961
- }
962
- if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
963
- patch.progress = (entry.state.progress + event.assistantMessageEvent.delta).slice(-4096);
964
- patch.activity = patch.progress;
965
- }
966
- if (event.type === "message_end" && event.message.role === "user") {
967
- const text =
968
- typeof event.message.content === "string"
969
- ? event.message.content
970
- : event.message.content
971
- .filter((block) => block.type === "text")
972
- .map((block) => block.text)
973
- .join("");
974
- for (const message of this.coordinationMessages)
975
- if (
976
- message.recipient !== "root" &&
977
- this.key(message.recipient) === this.key(entry.state) &&
978
- text ===
979
- `Delegation message ${message.id} from ${message.sender === "root" ? "root" : this.key(message.sender)}: ${message.text}`
980
- )
981
- message.status = "delivered";
982
- this.notifyCoordination();
983
- }
984
- if (event.type === "tool_execution_start") patch.activity = `Tool: ${event.toolName}`;
985
- if (event.type === "message_end" && event.message.role === "assistant") {
986
- const stats = session.getSessionStats();
987
- patch.telemetry = {
988
- ...entry.state.telemetry,
989
- tokens: stats.tokens,
990
- cost: stats.cost,
991
- context: session.getContextUsage() ?? null,
992
- };
993
- }
994
- this.update(entry, patch, event);
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;
995
830
  });
996
- this.update(entry, { transcript: session.sessionFile });
997
- if (this.closing || entry.cancelled) {
998
- this.update(entry, { status: "cancelled" });
999
- this.dispose(entry);
1000
- return;
1001
- }
1002
- entry.run = session
1003
- .prompt(input.assignment, { expandPromptTemplates: false })
1004
- .then(async () => {
1005
- await this.stopMany(this.children(entry).map((child) => child.state));
1006
- const last = session.messages.filter((message) => message.role === "assistant").at(-1);
1007
- const failed = !last || last.stopReason === "error";
1008
- const stats = session.getSessionStats();
1009
- this.update(entry, { status: "draining", activity: "Finalizing outcome artifacts" });
1010
- const result = session.getLastAssistantText() ?? "";
1011
- const artifacts: NonNullable<ChildSnapshot["artifacts"]> = {};
1012
- try {
1013
- artifacts.text = await saveOutcomeArtifact(
1014
- taskDir,
1015
- "result.txt",
1016
- last?.content
1017
- .filter((block) => block.type === "text")
1018
- .map((block) => block.text)
1019
- .join("") ?? "",
1020
- );
1021
- if (entry.state.outcome?.validation === "submitted")
1022
- artifacts.structured = await saveOutcomeArtifact(
1023
- taskDir,
1024
- "structured.json",
1025
- JSON.stringify(entry.state.outcome.value),
1026
- );
1027
- } catch (error) {
1028
- artifacts.error = `Outcome artifact persistence failed: ${String(error)}`;
1029
- }
1030
- const missing = entry.validator && entry.state.outcome?.validation !== "submitted";
1031
- this.update(entry, {
1032
- artifacts,
1033
- outcome: entry.state.outcome ? { ...entry.state.outcome, finalized: true } : undefined,
1034
- status:
1035
- entry.cancelled || last?.stopReason === "aborted"
1036
- ? "cancelled"
1037
- : failed || missing || artifacts.error
1038
- ? "failed"
1039
- : "settled",
1040
- result,
1041
- error: entry.state.limitReason
1042
- ? entry.state.error
1043
- : failed
1044
- ? (last?.errorMessage ?? "No final assistant outcome")
1045
- : missing
1046
- ? "Required structured result was not submitted successfully"
1047
- : artifacts.error,
1048
- activity: "Prompt completed",
1049
- telemetry: {
1050
- requests: entry.state.telemetry.requests,
1051
- tokens: stats.assistantMessages ? stats.tokens : null,
1052
- cost: stats.assistantMessages ? stats.cost : null,
1053
- context: session.getContextUsage() ?? null,
1054
- },
1055
- });
1056
- })
1057
- .catch(async (error) => {
1058
- await this.stopMany(this.children(entry).map((child) => child.state));
1059
- this.update(entry, {
1060
- status: entry.cancelled ? "cancelled" : "failed",
1061
- error: entry.state.limitReason ? entry.state.error : String(error),
1062
- });
1063
- })
1064
- .finally(() => this.dispose(entry));
1065
- // prompt preparation precedes abort-signal ownership; acknowledge only abortable work.
1066
- await Promise.race([started, entry.run]);
1067
- } catch (error) {
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);
1068
838
  this.update(entry, {
1069
- status: entry.cancelled || this.closing ? "cancelled" : "failed",
1070
- error: String(error),
839
+ status: "queued",
840
+ pauseReason: undefined,
841
+ error: undefined,
842
+ limits,
843
+ window: (entry.state.window ?? 1) + 1,
844
+ activity: "Continuation queued for admission",
1071
845
  });
1072
- this.dispose(entry);
1073
- throw error;
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");
1074
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
+ });
1075
991
  }
1076
992
  private closeDescendantPresence(entry: Entry): void {
1077
993
  for (const child of this.entries.values())
@@ -1093,11 +1009,17 @@ export class DelegationCoordinator {
1093
1009
  // Retain immutable evidence, not the disposed SDK session graph, while idle/parked.
1094
1010
  entry.unsubscribe = undefined;
1095
1011
  entry.session = undefined;
1012
+ entry.prompt = undefined;
1013
+ entry.resumeText = undefined;
1014
+ entry.setup = undefined;
1015
+ entry.run = undefined;
1016
+ entry.pauseDrain = undefined;
1096
1017
  if (this.writer === entry.state.id) this.writer = undefined;
1097
1018
  this.closeDescendantPresence(entry);
1098
1019
  this.presence.set(entry.state.id, entry.cancelled || this.closing ? "stopped" : "idle");
1099
1020
  this.update(entry, {
1100
1021
  disposed: true,
1022
+ retainedContext: false,
1101
1023
  endedAt: Date.now(),
1102
1024
  writerLease: false,
1103
1025
  outcome: entry.state.outcome ? { ...entry.state.outcome, finalized: true } : undefined,
@@ -1108,7 +1030,10 @@ export class DelegationCoordinator {
1108
1030
  private key(ref?: ChildRef): string {
1109
1031
  return ref ? `${ref.id}/${ref.generation}` : "root";
1110
1032
  }
1111
- private actor(ref: ChildRef | undefined, action: CoordinationAction): Entry | undefined {
1033
+ private async actor(
1034
+ ref: ChildRef | undefined,
1035
+ action: CoordinationAction,
1036
+ ): Promise<Entry | undefined> {
1112
1037
  if (this.closing) throw new Error("Parent shut down");
1113
1038
  if (!ref) return;
1114
1039
  const actor = this.entry(ref);
@@ -1116,6 +1041,12 @@ export class DelegationCoordinator {
1116
1041
  throw new Error("Sender is no longer running");
1117
1042
  if (!actor.input.profile.coordination?.includes(action))
1118
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");
1119
1050
  return actor;
1120
1051
  }
1121
1052
  private target(actor: Entry | undefined, ref: ChildRef): Entry {
@@ -1140,8 +1071,11 @@ export class DelegationCoordinator {
1140
1071
  throw new Error("Coordination target outside granted family");
1141
1072
  return target;
1142
1073
  }
1143
- inbox(actor?: ChildRef): DelegationMessage[] {
1144
- this.actor(actor, "inbox");
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();
1145
1079
  return this.takeInbox(actor);
1146
1080
  }
1147
1081
  async message(
@@ -1149,8 +1083,13 @@ export class DelegationCoordinator {
1149
1083
  target: ChildRef | undefined,
1150
1084
  text: string,
1151
1085
  mode: "inbox" | "steer" | "followUp" = "inbox",
1086
+ signal?: AbortSignal,
1152
1087
  ): Promise<DelegationMessage> {
1153
- const sender = this.actor(actor, "message");
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();
1154
1093
  if (typeof text !== "string" || !text.trim() || Buffer.byteLength(text) > MAX_MESSAGE_BYTES)
1155
1094
  throw new Error("Message requires 1–4096 UTF-8 bytes");
1156
1095
  if (!target && !sender) throw new Error("Root cannot message itself");
@@ -1215,7 +1154,10 @@ export class DelegationCoordinator {
1215
1154
  signal?: AbortSignal,
1216
1155
  messageId?: string,
1217
1156
  ): Promise<CoordinationWait> {
1218
- const sender = this.actor(actor, "wait");
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" };
1219
1161
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_WAIT_MS)
1220
1162
  throw new Error("Wait timeout must be 1–60000ms");
1221
1163
  if (this.waiterCount >= MAX_WAITERS) throw new Error("Parent waiter limit");
@@ -1297,6 +1239,7 @@ export class DelegationCoordinator {
1297
1239
  if (
1298
1240
  sender &&
1299
1241
  (sender.cancelled ||
1242
+ sender.paused ||
1300
1243
  sender.state.disposed ||
1301
1244
  this.entries.get(sender.state.id) !== sender)
1302
1245
  )
@@ -1318,7 +1261,7 @@ export class DelegationCoordinator {
1318
1261
  });
1319
1262
  }
1320
1263
  } else if (destination) {
1321
- if (destination.state.disposed)
1264
+ if (destination.state.disposed || destination.state.status === "paused")
1322
1265
  return finish({
1323
1266
  status: "outcome",
1324
1267
  outcome: {
@@ -1326,6 +1269,9 @@ export class DelegationCoordinator {
1326
1269
  generation: destination.state.generation,
1327
1270
  status: destination.state.status,
1328
1271
  limitReason: destination.state.limitReason,
1272
+ ...(destination.state.pauseReason
1273
+ ? { pauseReason: destination.state.pauseReason }
1274
+ : {}),
1329
1275
  },
1330
1276
  });
1331
1277
  } else if ((this.mailboxes.get(sourceKey)?.length ?? 0) > 0)
@@ -1347,22 +1293,19 @@ export class DelegationCoordinator {
1347
1293
  this.notifyCoordination();
1348
1294
  return structuredClone(messages);
1349
1295
  }
1350
- taskInputRead(ref: ChildRef): void {
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
+ );
1351
1302
  const entry = this.entry(ref);
1352
- if (
1353
- entry.cancelled ||
1354
- entry.state.disposed ||
1355
- this.closing ||
1356
- !entry.input.profile.tools.includes("read")
1357
- )
1358
- throw new Error("Delegating parent has no active read grant");
1359
- if (entry.state.toolCalls >= entry.state.limits.tools) {
1360
- this.limit(entry, "tools");
1361
- throw new Error("Tool call limit");
1362
- }
1363
- this.update(entry, { toolCalls: entry.state.toolCalls + 1 });
1303
+ this.assertActive(entry);
1304
+ signal?.throwIfAborted();
1305
+ this.chargeToolCall(entry);
1364
1306
  }
1365
1307
  async steer(ref: ChildRef, text: string): Promise<"queued" | "handled"> {
1308
+ requireSupportedHost();
1366
1309
  const entry = this.entry(ref);
1367
1310
  if (!text.trim()) throw new Error("Nonempty steering text is required");
1368
1311
  if (entry.state.status !== "running" || entry.cancelled) throw new Error("No running child");
@@ -1401,6 +1344,7 @@ export class DelegationCoordinator {
1401
1344
  await Promise.all([descendants, entry.state.disposed ? undefined : entry.session?.abort()]);
1402
1345
  if (entry.state.disposed) return;
1403
1346
  await entry.run;
1347
+ if (!entry.state.disposed) this.update(entry, { status: "cancelled" });
1404
1348
  this.dispose(entry);
1405
1349
  }
1406
1350
  async wait(ref: ChildRef): Promise<ChildSnapshot> {