pi-legion 2.0.4 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/CONTRIBUTING.md +27 -24
  3. package/README.md +103 -19
  4. package/docs/contracts/osc-busy-plugin.md +72 -0
  5. package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
  6. package/docs/contracts/wait-not-stop.md +30 -0
  7. package/docs/design.md +2 -2
  8. package/docs/headless-child-contract.md +83 -0
  9. package/docs/inprocess-supervisor.md +140 -0
  10. package/docs/qa-hunting-round.md +150 -0
  11. package/docs/subagents-teams.md +55 -18
  12. package/docs/tui-qa-playbook.md +284 -0
  13. package/index.ts +315 -49
  14. package/package.json +15 -8
  15. package/skills/pi-legion/SKILL.md +5 -0
  16. package/src/agents/agents.ts +34 -6
  17. package/src/agents/context.ts +83 -1
  18. package/src/agents/overrides.ts +1 -0
  19. package/src/agents/settings.ts +122 -1
  20. package/src/api.ts +23 -0
  21. package/src/extension/child-guard.ts +42 -3
  22. package/src/extension/legion-commands.ts +9 -2
  23. package/src/extension/legion-doctor.ts +54 -0
  24. package/src/extension/legion-stop.ts +6 -2
  25. package/src/extension/playbook.ts +3 -3
  26. package/src/extension/runtime.ts +57 -8
  27. package/src/extension/scout-guard.ts +39 -1
  28. package/src/extension/session-errors.ts +191 -0
  29. package/src/runs/args.ts +8 -0
  30. package/src/runs/orchestrator.ts +125 -16
  31. package/src/shared/output-cap.ts +2 -2
  32. package/src/shared/session.ts +2 -1
  33. package/src/shared/types.ts +74 -0
  34. package/src/supervisor/child-context.ts +90 -0
  35. package/src/supervisor/child-memory.ts +27 -3
  36. package/src/supervisor/inprocess-depth.ts +71 -0
  37. package/src/supervisor/inprocess-supervisor.ts +1011 -0
  38. package/src/supervisor/routing-supervisor.ts +179 -0
  39. package/src/supervisor/rpc-supervisor.ts +14 -0
  40. package/src/supervisor/spawn-mode.ts +35 -0
  41. package/src/supervisor/types.ts +13 -0
  42. package/src/supervisor/ui-proxy.ts +78 -0
  43. package/src/tui/live-overlay.ts +147 -55
  44. package/src/tui/status.ts +71 -35
  45. package/src/tui/viz/model.ts +6 -0
  46. package/src/tui/viz/panels.ts +8 -9
  47. package/src/tui/viz/railway.ts +154 -21
  48. package/src/tui/viz/rate.ts +15 -14
@@ -0,0 +1,1011 @@
1
+ /**
2
+ * LegionSupervisor backed by in-process AgentSession (#197).
3
+ *
4
+ * Uses createAgentSession + `SessionManager.open(input.sessionFile, ...)` so the
5
+ * child transcript lands on the file the Orchestrator pre-created (D7; an
6
+ * in-memory manager would leave it empty), or an injectable factory in unit
7
+ * tests. Production factory wires role tools / model / system prompt (#199).
8
+ * Does NOT use RpcClient. Process-tree / SIG retire is skipped.
9
+ */
10
+
11
+ import { formatChildTask } from "../extension/child-guard.ts";
12
+ import {
13
+ applyThinkingSuffix,
14
+ childInheritsExtensions,
15
+ collectChildExtensions,
16
+ } from "../runs/args.ts";
17
+ import {
18
+ ErrorCodes,
19
+ isThinkingLevel,
20
+ type InProcessLimits,
21
+ SubagentError,
22
+ DEFAULT_LEGION_SETTINGS,
23
+ } from "../shared/types.ts";
24
+ import {
25
+ DEFAULT_INPROCESS_MAX_ACTIVE,
26
+ } from "./spawn-mode.ts";
27
+ import {
28
+ type ChildLaunchContext,
29
+ runInChildContext,
30
+ runOutsideChildContext,
31
+ } from "./child-context.ts";
32
+ import {
33
+ inProcessDepth,
34
+ runAtAbsoluteDepth,
35
+ runAtInProcessChildDepthAsync,
36
+ } from "./inprocess-depth.ts";
37
+ import {
38
+ InMemoryUIProxy,
39
+ type UIProxy,
40
+ } from "./ui-proxy.ts";
41
+ import type {
42
+ ChildHandle,
43
+ EventSubscriptionOptions,
44
+ LegionSupervisor,
45
+ SettleResult,
46
+ SpawnInput,
47
+ SupervisorEvent,
48
+ SupervisorEventListener,
49
+ UsageSnapshot,
50
+ } from "./types.ts";
51
+
52
+ /**
53
+ * Stats shape this supervisor reads. Permissive on purpose: it must be
54
+ * structurally assignable from the SDK's `SessionStats` (nested `tokens` +
55
+ * `cost`) AND from lightweight test doubles that report flat counters. Both
56
+ * shapes are declared, and both are actually read in `stats()` — a shape that
57
+ * is declared but never read would silently report zero (REV2-6).
58
+ */
59
+ export interface InProcessSessionStats {
60
+ /** Flat counters (test doubles). */
61
+ tokensIn?: number;
62
+ tokensOut?: number;
63
+ costUsd?: number;
64
+ totalCost?: number;
65
+ /** SDK `SessionStats` shape. */
66
+ tokens?: {
67
+ input?: number;
68
+ output?: number;
69
+ cacheRead?: number;
70
+ cacheWrite?: number;
71
+ total?: number;
72
+ };
73
+ cost?: number;
74
+ }
75
+
76
+ /** Narrow session surface InProcessSupervisor needs from AgentSession. */
77
+ export interface InProcessSession {
78
+ subscribe(listener: (event: unknown) => void): () => void;
79
+ prompt(text: string): Promise<unknown>;
80
+ steer?(text: string): Promise<unknown>;
81
+ followUp?(text: string): Promise<unknown>;
82
+ abort?(): Promise<unknown>;
83
+ dispose(): void;
84
+ getSessionStats?(): InProcessSessionStats | Promise<InProcessSessionStats>;
85
+ }
86
+
87
+ /** Input to the production / injectable session factory (#199). */
88
+ export interface InProcessSessionFactoryInput {
89
+ name: string;
90
+ cwd: string;
91
+ agent: SpawnInput["agent"];
92
+ sessionFile: string;
93
+ /** Resolved model id from Orchestrator (same source as RPC `--model`). */
94
+ model?: string;
95
+ modelProvider?: string;
96
+ thinking?: string | false;
97
+ /**
98
+ * Settings-injected child context. Joined with the role prompt here the
99
+ * same way RPC's `pushSystemPromptArgs` does — Orchestrator must not also
100
+ * inject it through another path for the same spawn.
101
+ */
102
+ childContext?: string;
103
+ }
104
+
105
+ export type InProcessSessionFactory = (
106
+ input: InProcessSessionFactoryInput,
107
+ ) => Promise<InProcessSession>;
108
+
109
+ /**
110
+ * Pure createAgentSession plan derived from spawn input (#199).
111
+ * Unit-tested without constructing a real SDK session.
112
+ */
113
+ export interface InProcessSessionPlan {
114
+ cwd: string;
115
+ /** Undefined → omit `tools` so the SDK keeps its default selection. */
116
+ tools?: string[];
117
+ modelRef?: string;
118
+ modelProvider?: string;
119
+ /** Combined role prompt (+ childContext); undefined when neither is set. */
120
+ systemPromptText?: string;
121
+ systemPromptMode: "replace" | "append";
122
+ noExtensions: boolean;
123
+ additionalExtensionPaths: string[];
124
+ noSkills: boolean;
125
+ noContextFiles: boolean;
126
+ }
127
+
128
+ /**
129
+ * Build the v1 in-process session plan: role tools whitelist, resolved model
130
+ * ref, and system prompt (replace/append) aligned with RPC child argv.
131
+ */
132
+ export function buildInProcessSessionPlan(
133
+ input: InProcessSessionFactoryInput,
134
+ ): InProcessSessionPlan {
135
+ const tools = resolveInProcessTools(input.agent);
136
+ const modelRef = applyThinkingSuffix(input.model, input.thinking);
137
+ const { text: systemPromptText, mode: systemPromptMode } =
138
+ resolveInProcessSystemPrompt(input);
139
+
140
+ const noExtensions = !childInheritsExtensions(input.agent);
141
+ const additionalExtensionPaths = noExtensions
142
+ ? collectChildExtensions(input.agent)
143
+ : [];
144
+
145
+ return {
146
+ cwd: input.cwd,
147
+ ...(tools ? { tools } : {}),
148
+ ...(modelRef ? { modelRef } : {}),
149
+ ...(input.modelProvider ? { modelProvider: input.modelProvider } : {}),
150
+ ...(systemPromptText ? { systemPromptText } : {}),
151
+ systemPromptMode,
152
+ noExtensions,
153
+ additionalExtensionPaths,
154
+ noSkills: input.agent.inheritSkills === false,
155
+ noContextFiles: input.agent.inheritProjectContext === false,
156
+ };
157
+ }
158
+
159
+ /** Mirror RPC `pushToolArgs` whitelist (+ vexillatio for non-utility roles). */
160
+ function resolveInProcessTools(
161
+ agent: SpawnInput["agent"],
162
+ ): string[] | undefined {
163
+ const tools = (agent.tools ?? []).filter(Boolean);
164
+ if (tools.length === 0) return undefined;
165
+ if (!agent.utility && !tools.includes("vexillatio")) {
166
+ tools.push("vexillatio");
167
+ }
168
+ return tools;
169
+ }
170
+
171
+ /** Mirror RPC `pushSystemPromptArgs` join + mode rules. */
172
+ function resolveInProcessSystemPrompt(input: InProcessSessionFactoryInput): {
173
+ text?: string;
174
+ mode: "replace" | "append";
175
+ } {
176
+ const systemPrompt = input.agent.systemPrompt?.trim();
177
+ const childContext = input.childContext?.trim();
178
+ if (!systemPrompt && !childContext) {
179
+ return { mode: input.agent.systemPromptMode };
180
+ }
181
+ const text = [systemPrompt, childContext].filter(Boolean).join("\n\n");
182
+ // When only childContext is present, never replace the SDK base prompt.
183
+ const mode = systemPrompt
184
+ ? input.agent.systemPromptMode
185
+ : input.agent.systemPromptMode === "replace"
186
+ ? "append"
187
+ : input.agent.systemPromptMode;
188
+ return { text, mode };
189
+ }
190
+
191
+ export interface InProcessSupervisorOptions {
192
+ uiProxy?: UIProxy;
193
+ /**
194
+ * Injected in tests; production uses createAgentSession +
195
+ * `SessionManager.open(input.sessionFile, ...)` (D7 transcript persistence).
196
+ */
197
+ sessionFactory?: InProcessSessionFactory;
198
+ /** Limits for this supervisor instance (inprocess.* only). */
199
+ limits?: InProcessLimits;
200
+ /** Resolve limits dynamically (settings reload). Wins over static `limits`. */
201
+ getLimits?: () => InProcessLimits | undefined;
202
+ onSpawnRefused?: (message: string) => void;
203
+ }
204
+
205
+ interface EventSubscription {
206
+ listener: SupervisorEventListener;
207
+ includeTextDelta: boolean;
208
+ }
209
+
210
+ interface SettleWaiter {
211
+ resolve(result: SettleResult): void;
212
+ timer?: NodeJS.Timeout;
213
+ cleanup?: () => void;
214
+ }
215
+
216
+ type ChildState = "starting" | "running" | "idle" | "retiring" | "exited" | "retired";
217
+
218
+ interface ChildRecord {
219
+ name: string;
220
+ sessionFile: string;
221
+ session: InProcessSession | null;
222
+ state: ChildState;
223
+ alive: boolean;
224
+ turnActive: boolean;
225
+ retireRequested: boolean;
226
+ lastTurnResult?: SettleResult;
227
+ exitResult?: SettleResult;
228
+ waiters: Set<SettleWaiter>;
229
+ subscriptions: Set<EventSubscription>;
230
+ unsubscribeSession?: () => void;
231
+ retirePromise?: Promise<void>;
232
+ abortedPending: boolean;
233
+ spawnDepth: number;
234
+ parentKey: string;
235
+ /** Identity reused by every later dispatch into this child (tools inherit it). */
236
+ context: ChildLaunchContext;
237
+ }
238
+
239
+ const COARSE_EVENT_TYPES = new Set([
240
+ "agent_start",
241
+ "agent_end",
242
+ "agent_settled",
243
+ "turn_start",
244
+ "turn_end",
245
+ "tool_execution_start",
246
+ "tool_execution_end",
247
+ ]);
248
+
249
+ /** Parent key for maxChildrenPerNode (name before last `.`, or "" for roots). */
250
+ function parentKeyOf(name: string): string {
251
+ const idx = name.lastIndexOf(".");
252
+ return idx === -1 ? "" : name.slice(0, idx);
253
+ }
254
+
255
+ /**
256
+ * Identity scope for one in-process child (spawn-default-inprocess-v3 D5).
257
+ *
258
+ * A caller may hand over a full `childLaunchContext`; when it does not, we
259
+ * still establish a minimal context so that the child never observes "no
260
+ * identity" and mistakes itself for the root session. The fallback is derived
261
+ * from the spawn input only — it carries no db/node lineage, which is exactly
262
+ * why partial identity must not be mistaken for a complete one downstream.
263
+ */
264
+ function resolveChildLaunchContext(input: SpawnInput): ChildLaunchContext {
265
+ const provided = input.childLaunchContext;
266
+ if (provided) {
267
+ // REV2-3: store a deep-enough snapshot, never the caller's live object.
268
+ // A caller that reuses one template object (or one `parentPath` array)
269
+ // across spawns would otherwise mutate the identity of an already-spawned
270
+ // child for its later dispatches — the A-becomes-B leak D5 exists to kill.
271
+ return {
272
+ ...provided,
273
+ // Element-level copy too: after the path became `NestedPathEntry[]`
274
+ // (objects), `[...path]` would still alias every entry, so a caller
275
+ // mutating `template.parentPath[0].runId` would rewrite an
276
+ // already-spawned child's lineage — the exact A-becomes-B leak.
277
+ ...(provided.parentPath
278
+ ? { parentPath: provided.parentPath.map((entry) => ({ ...entry })) }
279
+ : {}),
280
+ };
281
+ }
282
+ const acceptanceRole = input.agent.acceptance?.role;
283
+ return {
284
+ allowNested: input.allowNestedSubagents === true,
285
+ utility: Boolean(input.agent.utility),
286
+ spawnMode: "inprocess",
287
+ ...(acceptanceRole ? { acceptanceRole } : {}),
288
+ };
289
+ }
290
+
291
+ function recordOf(value: unknown): Record<string, unknown> | null {
292
+ return typeof value === "object" && value !== null && !Array.isArray(value)
293
+ ? (value as Record<string, unknown>)
294
+ : null;
295
+ }
296
+
297
+ async function defaultSessionFactory(
298
+ input: InProcessSessionFactoryInput,
299
+ ): Promise<InProcessSession> {
300
+ const {
301
+ createAgentSession,
302
+ SessionManager,
303
+ DefaultResourceLoader,
304
+ getAgentDir,
305
+ ModelRuntime,
306
+ resolveCliModel,
307
+ } = await import("@earendil-works/pi-coding-agent");
308
+
309
+ const plan = buildInProcessSessionPlan(input);
310
+ const agentDir = getAgentDir();
311
+
312
+ const loaderOptions: ConstructorParameters<typeof DefaultResourceLoader>[0] = {
313
+ cwd: plan.cwd,
314
+ agentDir,
315
+ noExtensions: plan.noExtensions,
316
+ ...(plan.additionalExtensionPaths.length > 0
317
+ ? { additionalExtensionPaths: plan.additionalExtensionPaths }
318
+ : {}),
319
+ ...(plan.noSkills ? { noSkills: true } : {}),
320
+ ...(plan.noContextFiles ? { noContextFiles: true } : {}),
321
+ };
322
+
323
+ if (plan.systemPromptText) {
324
+ if (plan.systemPromptMode === "replace") {
325
+ loaderOptions.systemPromptOverride = () => plan.systemPromptText;
326
+ // Avoid host APPEND_SYSTEM.md appending onto a replaced role prompt.
327
+ loaderOptions.appendSystemPromptOverride = () => [];
328
+ } else {
329
+ loaderOptions.appendSystemPromptOverride = (base) => [
330
+ ...base,
331
+ plan.systemPromptText!,
332
+ ];
333
+ }
334
+ }
335
+
336
+ const resourceLoader = new DefaultResourceLoader(loaderOptions);
337
+ await resourceLoader.reload();
338
+
339
+ // D7 — the transcript must land on the file the Orchestrator pre-created
340
+ // (`orchestrator.ts` `preCreateSessionFile`). With `SessionManager.inMemory()`
341
+ // `getSessionFile()` returns undefined and nothing is ever written, so
342
+ // `deriveOutcome(parseSessionFile(...))` sees an empty file and every
343
+ // in-process child "succeeds with zero output". `open()` on the pre-created
344
+ // empty file works and reports that path. Deliberately no try/catch fallback
345
+ // to inMemory: a silent fallback would restore exactly that zero-output bug.
346
+ const createOptions: Parameters<typeof createAgentSession>[0] = {
347
+ cwd: plan.cwd,
348
+ sessionManager: SessionManager.open(input.sessionFile, undefined, plan.cwd),
349
+ resourceLoader,
350
+ ...(plan.tools ? { tools: plan.tools } : {}),
351
+ };
352
+
353
+ if (plan.modelRef) {
354
+ const modelRuntime = await ModelRuntime.create();
355
+ const resolved = resolveCliModel({
356
+ cliModel: plan.modelRef,
357
+ ...(plan.modelProvider ? { cliProvider: plan.modelProvider } : {}),
358
+ modelRuntime,
359
+ });
360
+ if (resolved.error || !resolved.model) {
361
+ throw new SubagentError(
362
+ `inprocess model resolve failed for '${plan.modelRef}'` +
363
+ (resolved.error ? `: ${resolved.error}` : ""),
364
+ ErrorCodes.START_FAILED,
365
+ );
366
+ }
367
+ createOptions.modelRuntime = modelRuntime;
368
+ createOptions.model = resolved.model;
369
+ if (resolved.thinkingLevel && isThinkingLevel(resolved.thinkingLevel)) {
370
+ createOptions.thinkingLevel = resolved.thinkingLevel;
371
+ } else if (
372
+ typeof input.thinking === "string" &&
373
+ isThinkingLevel(input.thinking)
374
+ ) {
375
+ createOptions.thinkingLevel = input.thinking;
376
+ }
377
+ }
378
+
379
+ const { session } = await createAgentSession(createOptions);
380
+ return session;
381
+ }
382
+
383
+ /**
384
+ * Host-side in-process adapter for the frozen §3.1 LegionSupervisor contract.
385
+ */
386
+ export class InProcessSupervisor implements LegionSupervisor {
387
+ private readonly children = new Map<string, ChildRecord>();
388
+ private readonly subscriptionsBeforeSpawn = new Map<string, Set<EventSubscription>>();
389
+ private readonly sessionFactory: InProcessSessionFactory;
390
+ private readonly getLimits: () => InProcessLimits | undefined;
391
+ private readonly onSpawnRefused?: (message: string) => void;
392
+ readonly uiProxy: UIProxy;
393
+ private spawnCount = 0;
394
+
395
+ constructor(options: InProcessSupervisorOptions = {}) {
396
+ this.sessionFactory = options.sessionFactory ?? defaultSessionFactory;
397
+ this.uiProxy = options.uiProxy ?? new InMemoryUIProxy();
398
+ this.getLimits = options.getLimits
399
+ ?? (() => options.limits);
400
+ this.onSpawnRefused = options.onSpawnRefused;
401
+ }
402
+
403
+ /**
404
+ * Effective in-process limits.
405
+ *
406
+ * RECORDED CONTRACT CHANGE (spawn-default-inprocess-v3 D4): the old rule was
407
+ * "no `subagents.inprocess` block → unlimited". Now that inprocess is the
408
+ * default backend, "no config" would mean unbounded recursive spawning, so
409
+ * an absent block falls back to the same safe numbers RPC uses
410
+ * (`DEFAULT_LEGION_SETTINGS.maxDepth/maxChildrenPerNode/maxActiveNodes`).
411
+ *
412
+ * An *explicit* block keeps `DEFAULT_INPROCESS_MAX_ACTIVE` when it omits
413
+ * `maxActive` (existing behaviour; tests depend on it).
414
+ * `maxSpawnsPerSession` / `waitTimeoutMs` stay undefined by default — same
415
+ * asymmetry as the RPC knobs, which have no defaults either.
416
+ */
417
+ private limits(): Required<
418
+ Pick<InProcessLimits, "maxDepth" | "maxChildrenPerNode" | "maxActive">
419
+ > &
420
+ InProcessLimits {
421
+ const configured = this.getLimits();
422
+ if (configured === undefined) {
423
+ return {
424
+ maxDepth: DEFAULT_LEGION_SETTINGS.maxDepth,
425
+ maxChildrenPerNode: DEFAULT_LEGION_SETTINGS.maxChildrenPerNode,
426
+ maxActive: DEFAULT_LEGION_SETTINGS.maxActiveNodes,
427
+ };
428
+ }
429
+ return {
430
+ ...configured,
431
+ maxDepth: configured.maxDepth ?? DEFAULT_LEGION_SETTINGS.maxDepth,
432
+ maxChildrenPerNode:
433
+ configured.maxChildrenPerNode ??
434
+ DEFAULT_LEGION_SETTINGS.maxChildrenPerNode,
435
+ maxActive: configured.maxActive ?? DEFAULT_INPROCESS_MAX_ACTIVE,
436
+ };
437
+ }
438
+
439
+ async spawnChild(input: SpawnInput): Promise<ChildHandle> {
440
+ if (input.agent.kind !== "pi") {
441
+ throw new SubagentError(
442
+ `InProcessSupervisor only supports pi children (received kind '${input.agent.kind}')`,
443
+ ErrorCodes.INVALID_PARAMS,
444
+ );
445
+ }
446
+ if (input.agent.worktree === true) {
447
+ throw new SubagentError(
448
+ `spawnMode "inprocess" cannot use worktree isolation for '${input.name}'; use spawnMode "rpc" or set worktree: false`,
449
+ ErrorCodes.INVALID_PARAMS,
450
+ );
451
+ }
452
+
453
+ this.assertWithinLimits(input.name);
454
+
455
+ const previous = this.children.get(input.name);
456
+ if (previous && previous.state !== "exited" && previous.state !== "retired") {
457
+ throw new SubagentError(
458
+ `child is already registered and alive: ${input.name}`,
459
+ ErrorCodes.NAME_TAKEN,
460
+ );
461
+ }
462
+ if (previous) this.disposeRecord(previous);
463
+
464
+ const registration: ChildRecord = {
465
+ name: input.name,
466
+ sessionFile: input.sessionFile,
467
+ session: null,
468
+ state: "starting",
469
+ alive: true,
470
+ turnActive: false,
471
+ retireRequested: false,
472
+ abortedPending: false,
473
+ waiters: new Set(),
474
+ subscriptions:
475
+ this.subscriptionsBeforeSpawn.get(input.name) ?? new Set(),
476
+ spawnDepth: inProcessDepth() + 1,
477
+ parentKey: parentKeyOf(input.name),
478
+ context: resolveChildLaunchContext(input),
479
+ };
480
+ this.subscriptionsBeforeSpawn.delete(input.name);
481
+ this.children.set(input.name, registration);
482
+
483
+ try {
484
+ return await runAtInProcessChildDepthAsync(() =>
485
+ // Identity scope: the session factory and the first prompt both run
486
+ // inside it, so every tool the child executes during that turn
487
+ // inherits the child's context instead of seeing the root's.
488
+ runInChildContext(registration.context, async () => {
489
+ const session = await this.sessionFactory({
490
+ name: input.name,
491
+ cwd: input.cwd,
492
+ agent: input.agent,
493
+ sessionFile: input.sessionFile,
494
+ ...(input.model !== undefined ? { model: input.model } : {}),
495
+ ...(input.modelProvider !== undefined
496
+ ? { modelProvider: input.modelProvider }
497
+ : {}),
498
+ ...(input.thinking !== undefined
499
+ ? { thinking: input.thinking }
500
+ : {}),
501
+ ...(input.childContext !== undefined
502
+ ? { childContext: input.childContext }
503
+ : {}),
504
+ });
505
+ registration.session = session;
506
+ registration.unsubscribeSession = session.subscribe((event) =>
507
+ this.handleSessionEvent(registration, event),
508
+ );
509
+ registration.state = "idle";
510
+ this.spawnCount += 1;
511
+ this.beginTurn(registration);
512
+ await session.prompt(
513
+ formatChildTask(input.task, {
514
+ allowNested: input.allowNestedSubagents === true,
515
+ ...(input.worktreeBranch
516
+ ? { worktreeBranch: input.worktreeBranch }
517
+ : {}),
518
+ }),
519
+ );
520
+ if (!this.isAlive(input.name)) {
521
+ throw new SubagentError(
522
+ `in-process session ended after its first prompt: ${input.name}`,
523
+ ErrorCodes.START_FAILED,
524
+ );
525
+ }
526
+ return {
527
+ name: registration.name,
528
+ sessionFile: registration.sessionFile,
529
+ };
530
+ }),
531
+ );
532
+ } catch (error) {
533
+ await this.rollbackSpawn(registration);
534
+ if (error instanceof SubagentError) {
535
+ this.onSpawnRefused?.(error.message);
536
+ }
537
+ throw error;
538
+ }
539
+ }
540
+
541
+ async prompt(name: string, text: string): Promise<void> {
542
+ const child = this.requireChild(name);
543
+ await this.dispatch(child, "prompt", text);
544
+ }
545
+
546
+ async steer(name: string, text: string): Promise<void> {
547
+ const child = this.requireChild(name);
548
+ await this.dispatch(child, "steer", text);
549
+ }
550
+
551
+ async followUp(name: string, text: string): Promise<void> {
552
+ const child = this.requireChild(name);
553
+ await this.dispatch(child, "followUp", text);
554
+ }
555
+
556
+ async abort(name: string): Promise<void> {
557
+ const child = this.requireChild(name);
558
+ this.assertCanSend(child);
559
+ if (!child.turnActive) return;
560
+ // Same identity scope and nesting depth as the child's own turn: abort can
561
+ // settle tool promises whose continuations still run inside it.
562
+ await runAtAbsoluteDepth(child.spawnDepth, () =>
563
+ runInChildContext(child.context, async () => {
564
+ await child.session?.abort?.();
565
+ }),
566
+ );
567
+ }
568
+
569
+ waitSettled(name: string, timeoutMs = 900_000, signal?: AbortSignal): Promise<SettleResult> {
570
+ const child = this.requireChild(name);
571
+ return runAtAbsoluteDepth(child.spawnDepth, () =>
572
+ runInChildContext(child.context, () =>
573
+ this.awaitSettled(child, timeoutMs, signal),
574
+ ),
575
+ );
576
+ }
577
+
578
+ private awaitSettled(
579
+ child: ChildRecord,
580
+ timeoutMs: number,
581
+ signal: AbortSignal | undefined,
582
+ ): Promise<SettleResult> {
583
+ const name = child.name;
584
+ const limits = this.limits();
585
+ const effectiveTimeout =
586
+ timeoutMs === 900_000 && limits.waitTimeoutMs !== undefined
587
+ ? limits.waitTimeoutMs
588
+ : timeoutMs;
589
+ if (signal?.aborted) {
590
+ return Promise.resolve({ settled: false, abnormal: false, reason: "aborted by parent" });
591
+ }
592
+ if (!child.turnActive && child.lastTurnResult) {
593
+ return Promise.resolve(child.lastTurnResult);
594
+ }
595
+ if (child.exitResult) return Promise.resolve(child.exitResult);
596
+ if (!this.isAlive(name)) {
597
+ return Promise.resolve(
598
+ child.exitResult ?? {
599
+ settled: false,
600
+ abnormal: !child.retireRequested,
601
+ reason: child.retireRequested ? "child retired" : "in-process session ended",
602
+ },
603
+ );
604
+ }
605
+ if (!child.turnActive) {
606
+ return Promise.resolve(
607
+ child.lastTurnResult ?? {
608
+ settled: false,
609
+ abnormal: false,
610
+ reason: "no active turn",
611
+ },
612
+ );
613
+ }
614
+
615
+ return new Promise((resolve) => {
616
+ const waiter: SettleWaiter = { resolve };
617
+ let abortListener: (() => void) | undefined;
618
+ waiter.cleanup = () => {
619
+ if (waiter.timer) {
620
+ clearTimeout(waiter.timer);
621
+ waiter.timer = undefined;
622
+ }
623
+ if (signal && abortListener) {
624
+ signal.removeEventListener("abort", abortListener);
625
+ abortListener = undefined;
626
+ }
627
+ };
628
+ child.waiters.add(waiter);
629
+ if (effectiveTimeout > 0) {
630
+ waiter.timer = setTimeout(() => {
631
+ child.waiters.delete(waiter);
632
+ waiter.cleanup?.();
633
+ resolve({ settled: false, abnormal: false, reason: "timeout" });
634
+ }, effectiveTimeout);
635
+ }
636
+ if (signal) {
637
+ abortListener = () => {
638
+ if (!child.waiters.delete(waiter)) return;
639
+ waiter.cleanup?.();
640
+ resolve({ settled: false, abnormal: false, reason: "aborted by parent" });
641
+ };
642
+ if (signal.aborted) abortListener();
643
+ else signal.addEventListener("abort", abortListener, { once: true });
644
+ }
645
+ });
646
+ }
647
+
648
+ isAlive(name: string): boolean {
649
+ const child = this.children.get(name);
650
+ if (!child || !child.alive) return false;
651
+ return child.state !== "retired" && child.state !== "exited";
652
+ }
653
+
654
+ async stats(name: string): Promise<UsageSnapshot | null> {
655
+ const child = this.children.get(name);
656
+ if (!child || !child.session || !this.isAlive(name)) return null;
657
+ try {
658
+ const raw = await child.session.getSessionStats?.();
659
+ if (!raw) return null;
660
+ // REV2-6: the real AgentSession reports `tokens: {input, output}` + `cost`
661
+ // (agent-session.d.ts SessionStats), while test doubles use flat counters.
662
+ // Reading only the flat keys made production usage read as 0 and let
663
+ // stats() overwrite the correct usage the Orchestrator had already
664
+ // written to the transcript.
665
+ return {
666
+ tokensIn: Number(raw.tokensIn ?? raw.tokens?.input ?? 0),
667
+ tokensOut: Number(raw.tokensOut ?? raw.tokens?.output ?? 0),
668
+ costUsd: Number(raw.costUsd ?? raw.totalCost ?? raw.cost ?? 0),
669
+ };
670
+ } catch {
671
+ return null;
672
+ }
673
+ }
674
+
675
+ retire(name: string, opts: { graceful?: boolean } = {}): Promise<void> {
676
+ const child = this.requireChild(name);
677
+ if (child.retirePromise) return child.retirePromise;
678
+ const pending = this.retireChild(child, opts.graceful !== false);
679
+ child.retirePromise = pending;
680
+ void pending.catch(() => {
681
+ if (child.retirePromise === pending) child.retirePromise = undefined;
682
+ });
683
+ return pending;
684
+ }
685
+
686
+ onEvent(
687
+ name: string,
688
+ listener: SupervisorEventListener,
689
+ options: EventSubscriptionOptions = {},
690
+ ): () => void {
691
+ const child = this.children.get(name);
692
+ const subscription: EventSubscription = {
693
+ listener,
694
+ includeTextDelta: options.includeTextDelta === true,
695
+ };
696
+ const subscriptions = child?.subscriptions ?? this.pendingSubscriptions(name);
697
+ subscriptions.add(subscription);
698
+ return () => {
699
+ subscriptions.delete(subscription);
700
+ if (
701
+ !child &&
702
+ subscriptions.size === 0 &&
703
+ this.subscriptionsBeforeSpawn.get(name) === subscriptions
704
+ ) {
705
+ this.subscriptionsBeforeSpawn.delete(name);
706
+ }
707
+ };
708
+ }
709
+
710
+ private pendingSubscriptions(name: string): Set<EventSubscription> {
711
+ let subscriptions = this.subscriptionsBeforeSpawn.get(name);
712
+ if (!subscriptions) {
713
+ subscriptions = new Set();
714
+ this.subscriptionsBeforeSpawn.set(name, subscriptions);
715
+ }
716
+ return subscriptions;
717
+ }
718
+
719
+ private assertWithinLimits(name: string): void {
720
+ const limits = this.limits();
721
+ const active = [...this.children.values()].filter(
722
+ (c) => c.state !== "exited" && c.state !== "retired",
723
+ );
724
+ if (limits.maxSpawnsPerSession !== undefined && this.spawnCount >= limits.maxSpawnsPerSession) {
725
+ const message =
726
+ `inprocess spawn budget exhausted (${this.spawnCount}/${limits.maxSpawnsPerSession}); ` +
727
+ `raise subagents.inprocess.maxSpawnsPerSession to allow more`;
728
+ this.onSpawnRefused?.(message);
729
+ throw new SubagentError(message, ErrorCodes.BUDGET_EXCEEDED);
730
+ }
731
+ if (limits.maxActive !== undefined && active.length >= limits.maxActive) {
732
+ const message =
733
+ `inprocess maxActive reached (${active.length}/${limits.maxActive}); ` +
734
+ `raise subagents.inprocess.maxActive to allow more`;
735
+ this.onSpawnRefused?.(message);
736
+ throw new SubagentError(message, ErrorCodes.BUDGET_EXCEEDED);
737
+ }
738
+ const nextDepth = inProcessDepth() + 1;
739
+ if (limits.maxDepth !== undefined && nextDepth > limits.maxDepth) {
740
+ const message =
741
+ `inprocess nesting limit reached (depth ${inProcessDepth()}, max ${limits.maxDepth}); ` +
742
+ `raise subagents.inprocess.maxDepth to allow deeper nesting`;
743
+ this.onSpawnRefused?.(message);
744
+ throw new SubagentError(message, ErrorCodes.BUDGET_EXCEEDED);
745
+ }
746
+ if (limits.maxChildrenPerNode !== undefined) {
747
+ const parent = parentKeyOf(name);
748
+ const siblings = active.filter((c) => c.parentKey === parent).length;
749
+ if (siblings >= limits.maxChildrenPerNode) {
750
+ const message =
751
+ `inprocess maxChildrenPerNode reached for '${parent || "<root>"}' ` +
752
+ `(${siblings}/${limits.maxChildrenPerNode})`;
753
+ this.onSpawnRefused?.(message);
754
+ throw new SubagentError(message, ErrorCodes.BUDGET_EXCEEDED);
755
+ }
756
+ }
757
+ }
758
+
759
+ private dispatch(
760
+ child: ChildRecord,
761
+ kind: "prompt" | "steer" | "followUp",
762
+ text: string,
763
+ ): Promise<void> {
764
+ // Every turn is dispatched inside the child's identity scope AND at the
765
+ // child's recorded absolute nesting depth. The Orchestrator drives turn 2+
766
+ // from the root chain, where `inProcessDepth()` is 0; without pinning the
767
+ // recorded depth here a grandchild would look like a first-generation
768
+ // child and `maxDepth` would never fire (REV2-1).
769
+ return runAtAbsoluteDepth(child.spawnDepth, () =>
770
+ runInChildContext(child.context, () =>
771
+ this.dispatchInContext(child, kind, text),
772
+ ),
773
+ );
774
+ }
775
+
776
+ private async dispatchInContext(
777
+ child: ChildRecord,
778
+ kind: "prompt" | "steer" | "followUp",
779
+ text: string,
780
+ ): Promise<void> {
781
+ this.assertCanSend(child);
782
+ const session = child.session!;
783
+ const wasActive = child.turnActive;
784
+ if (!wasActive) this.beginTurn(child);
785
+ try {
786
+ if (kind === "followUp" && wasActive && session.followUp) {
787
+ await session.followUp(text);
788
+ } else if ((kind === "steer" || kind === "prompt") && wasActive && session.steer) {
789
+ await session.steer(text);
790
+ } else {
791
+ await session.prompt(text);
792
+ }
793
+ } catch (error) {
794
+ if (!wasActive && child.turnActive) {
795
+ child.turnActive = false;
796
+ child.state = "idle";
797
+ child.lastTurnResult = {
798
+ settled: false,
799
+ abnormal: false,
800
+ reason: "session command was rejected",
801
+ };
802
+ this.resolveWaiters(child, child.lastTurnResult);
803
+ }
804
+ throw error;
805
+ }
806
+ }
807
+
808
+ private beginTurn(child: ChildRecord): void {
809
+ child.lastTurnResult = undefined;
810
+ child.exitResult = undefined;
811
+ child.turnActive = true;
812
+ child.state = "running";
813
+ }
814
+
815
+ private async rollbackSpawn(child: ChildRecord): Promise<void> {
816
+ child.state = "retiring";
817
+ child.alive = false;
818
+ child.turnActive = false;
819
+ child.subscriptions.clear();
820
+ child.unsubscribeSession?.();
821
+ child.unsubscribeSession = undefined;
822
+ this.uiProxy.forgetChild(child.name);
823
+ this.resolveWaiters(child, {
824
+ settled: false,
825
+ abnormal: true,
826
+ reason: "child spawn failed",
827
+ });
828
+ try {
829
+ child.session?.dispose();
830
+ } catch {
831
+ /* preserve launch error */
832
+ } finally {
833
+ child.session = null;
834
+ child.state = "exited";
835
+ if (this.children.get(child.name) === child) {
836
+ this.children.delete(child.name);
837
+ }
838
+ }
839
+ }
840
+
841
+ private assertCanSend(child: ChildRecord): void {
842
+ if (!this.isAlive(child.name) || child.retireRequested || !child.session) {
843
+ throw new SubagentError(
844
+ `child is not available: ${child.name}`,
845
+ ErrorCodes.NOT_FOUND,
846
+ );
847
+ }
848
+ }
849
+
850
+ private requireChild(name: string): ChildRecord {
851
+ const child = this.children.get(name);
852
+ if (!child) {
853
+ throw new SubagentError(`unknown child: ${name}`, ErrorCodes.NOT_FOUND);
854
+ }
855
+ return child;
856
+ }
857
+
858
+ private handleSessionEvent(child: ChildRecord, event: unknown): void {
859
+ const record = recordOf(event);
860
+ if (!record || typeof record.type !== "string") return;
861
+ this.updateTurnState(child, record);
862
+ this.publishEvent(child, record);
863
+ }
864
+
865
+ private updateTurnState(child: ChildRecord, event: Record<string, unknown>): void {
866
+ if (event.type === "agent_start" || event.type === "turn_start") {
867
+ child.turnActive = true;
868
+ if (event.type === "agent_start") child.abortedPending = false;
869
+ if (child.state !== "retiring") child.state = "running";
870
+ return;
871
+ }
872
+ if (event.type === "turn_end" || event.type === "agent_end") {
873
+ child.abortedPending = this.eventContainsAbort(event, child.abortedPending);
874
+ return;
875
+ }
876
+ if (event.type !== "agent_settled") return;
877
+ const result: SettleResult = {
878
+ settled: true,
879
+ abnormal: false,
880
+ aborted: child.abortedPending,
881
+ };
882
+ child.abortedPending = false;
883
+ child.turnActive = false;
884
+ child.lastTurnResult = result;
885
+ if (child.state !== "retiring") child.state = "idle";
886
+ this.resolveWaiters(child, result);
887
+ }
888
+
889
+ private eventContainsAbort(
890
+ event: Record<string, unknown>,
891
+ previous: boolean,
892
+ ): boolean {
893
+ const message = recordOf(event.message);
894
+ if (message?.stopReason === "aborted") return true;
895
+ const messages = Array.isArray(event.messages) ? event.messages : [];
896
+ return (
897
+ previous ||
898
+ messages.some((item) => recordOf(item)?.stopReason === "aborted")
899
+ );
900
+ }
901
+
902
+ private publishEvent(child: ChildRecord, record: Record<string, unknown>): void {
903
+ // Sessions emit synchronously from inside the child's await chain, so a
904
+ // parent-side listener would otherwise run with the child's ALS identity
905
+ // (currentChildContext() would report the child). Host callbacks run at
906
+ // root identity (REV2-7).
907
+ runOutsideChildContext(() => this.publishEventAtRoot(child, record));
908
+ }
909
+
910
+ private publishEventAtRoot(
911
+ child: ChildRecord,
912
+ record: Record<string, unknown>,
913
+ ): void {
914
+ const type = record.type;
915
+ if (typeof type !== "string") return;
916
+ if (type === "message_update") {
917
+ const assistantEvent = recordOf(record.assistantMessageEvent);
918
+ if (!assistantEvent || typeof assistantEvent.type !== "string") return;
919
+ const updateType = assistantEvent.type;
920
+ const isTextDelta = updateType === "text_delta";
921
+ const toolName =
922
+ typeof assistantEvent.toolName === "string"
923
+ ? assistantEvent.toolName
924
+ : undefined;
925
+ if (!isTextDelta && !toolName) return;
926
+ const coarse: SupervisorEvent = {
927
+ name: child.name,
928
+ type,
929
+ updateType,
930
+ ...(toolName ? { toolName } : {}),
931
+ };
932
+ const detailed: SupervisorEvent = {
933
+ name: child.name,
934
+ type,
935
+ updateType,
936
+ ...(isTextDelta && typeof assistantEvent.delta === "string"
937
+ ? { delta: assistantEvent.delta }
938
+ : {}),
939
+ ...(toolName ? { toolName } : {}),
940
+ };
941
+ for (const subscription of [...child.subscriptions]) {
942
+ if (isTextDelta && !subscription.includeTextDelta) continue;
943
+ this.notify(subscription, isTextDelta ? detailed : coarse);
944
+ }
945
+ return;
946
+ }
947
+ if (!COARSE_EVENT_TYPES.has(type)) return;
948
+ const event: SupervisorEvent = { name: child.name, type };
949
+ for (const key of ["toolName", "toolCallId", "aborted", "willRetry", "reason"]) {
950
+ if (record[key] !== undefined) event[key] = record[key];
951
+ }
952
+ for (const subscription of [...child.subscriptions]) {
953
+ this.notify(subscription, event);
954
+ }
955
+ }
956
+
957
+ private notify(subscription: EventSubscription, event: SupervisorEvent): void {
958
+ try {
959
+ subscription.listener(event);
960
+ } catch {
961
+ /* UI callback must not break lifecycle */
962
+ }
963
+ }
964
+
965
+ private resolveWaiters(child: ChildRecord, result: SettleResult): void {
966
+ const waiters = [...child.waiters];
967
+ child.waiters.clear();
968
+ for (const waiter of waiters) {
969
+ waiter.cleanup?.();
970
+ waiter.resolve(result);
971
+ }
972
+ }
973
+
974
+ private async retireChild(child: ChildRecord, _graceful: boolean): Promise<void> {
975
+ if (child.state === "retired" || child.state === "exited") return;
976
+ child.retireRequested = true;
977
+ child.state = "retiring";
978
+ child.alive = false;
979
+ child.turnActive = false;
980
+ child.unsubscribeSession?.();
981
+ child.unsubscribeSession = undefined;
982
+ this.uiProxy.forgetChild(child.name);
983
+ const result: SettleResult = {
984
+ settled: false,
985
+ abnormal: false,
986
+ reason: "child retired",
987
+ };
988
+ child.exitResult = result;
989
+ child.lastTurnResult = result;
990
+ this.resolveWaiters(child, result);
991
+ try {
992
+ child.session?.dispose();
993
+ } catch {
994
+ /* dispose best-effort */
995
+ } finally {
996
+ child.session = null;
997
+ child.state = "retired";
998
+ }
999
+ }
1000
+
1001
+ private disposeRecord(child: ChildRecord): void {
1002
+ child.unsubscribeSession?.();
1003
+ try {
1004
+ child.session?.dispose();
1005
+ } catch {
1006
+ /* ignore */
1007
+ }
1008
+ this.children.delete(child.name);
1009
+ }
1010
+ }
1011
+