@inixiative/agent-session 0.6.0 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1532 @@
1
+ // ---------------------------------------------------------------------------
2
+ // ClaudeSubagentSessions — decision roles as subagents of one side session
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // One Claude CLI process per group (a Foundry thread) serves every decision
6
+ // role of that group as a subagent: each role is a custom agent whose system
7
+ // prompt is its instructions plus its stable context, so the subagent reads it
8
+ // from the prompt cache. Decisions that arrive together are fanned out in one
9
+ // turn of a dispatcher session:
10
+ //
11
+ // dispatch one user message lists the members (agent name + token); the
12
+ // dispatcher calls the Agent tool once per member, in the
13
+ // foreground. A PreToolUse hook answers every call: it replaces
14
+ // the call's prompt with that member's exact input, so what a
15
+ // subagent sees never depends on the dispatcher model. Calls it
16
+ // cannot match are denied.
17
+ // settle each member settles on its own task frames (its subagent text,
18
+ // usage and model), without waiting for the dispatcher's turn.
19
+ // reset the dispatcher's turn is rewound once it ends, so every
20
+ // fan-out starts from the same empty conversation. A member the
21
+ // dispatcher skipped is sent again in one more fan-out.
22
+ //
23
+ // standby pre-started members (`prestart`): each role is dispatched ahead
24
+ // of its trigger as a subagent holding its stable context and the
25
+ // thread's current standby context, and calls the host's
26
+ // `moment` tool (an SDK MCP server over the control channel),
27
+ // which the host holds open. A decision whose standby context
28
+ // matches answers that call with its input, so every pre-started
29
+ // member starts at once, with no dispatch on the decision's path.
30
+ // Each spare serves one decision; `prestart` with new standby
31
+ // context replaces the spares (a turn landed), and a consumed one
32
+ // is pre-started again.
33
+ //
34
+ // Every member is a fresh subagent conversation: nothing of one decision
35
+ // reaches another. Groups above `maxMembers` split evenly across more side
36
+ // sessions of the group, which also serve overlapping fan-outs.
37
+ // ---------------------------------------------------------------------------
38
+
39
+ import { createHash } from 'node:crypto';
40
+ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
41
+ import { tmpdir } from 'node:os';
42
+ import { join } from 'node:path';
43
+ import type { PipedSubprocess } from './claude-code-session';
44
+ import { claudeRateLimitSnapshot, type LimitSnapshot, mergeLimits } from './limits';
45
+ import {
46
+ DecisionError,
47
+ type DecisionRequest,
48
+ type DecisionResult,
49
+ type PrimedEvent,
50
+ type PrimedSessions,
51
+ type PrimedSnapshot,
52
+ type PrimeSpec,
53
+ } from './primed';
54
+
55
+ /** How long a decision reserved on a spare still starting waits for it before fanning out. */
56
+ const RESERVE_START_MS = 2_000;
57
+
58
+ /** The tool a pre-started member calls; its result is the decision input. */
59
+ const MOMENT_TOOL = 'mcp__foundry__moment';
60
+ const standbyPrompt = (standby: string, token: string) =>
61
+ `${standby}\n\nThe moment to assess has not arrived yet. Call the ${MOMENT_TOOL} tool now with token "${token}" and do nothing else first. Its result is the moment; then answer it exactly as your instructions and its response protocol say.`;
62
+
63
+ const DISPATCHER =
64
+ 'You dispatch work to subagents and never answer anything yourself. Each user message lists ' +
65
+ 'members as `<agent name> <token>`. Call the Agent tool once for every listed member, all calls ' +
66
+ 'in one single message: subagent_type = the agent name, description = the token, prompt = "-". ' +
67
+ 'After the results return, reply with exactly DONE.';
68
+
69
+ export interface ClaudeSubagentConfig {
70
+ bin?: string;
71
+ model: string;
72
+ effort?: string;
73
+ /** Private working directory for the side sessions. */
74
+ cwd: string;
75
+ /** Prepended to every member's system prompt. */
76
+ baseInstructions?: string;
77
+ /** Merged over process.env. API keys are always removed. */
78
+ env?: Record<string, string | undefined>;
79
+ spawn?: (
80
+ argv: string[],
81
+ options: { cwd: string; env: Record<string, string | undefined> },
82
+ ) => PipedSubprocess;
83
+ /** Members per fan-out; a larger batch splits evenly across side sessions. Default 12. */
84
+ maxMembers?: number;
85
+ /** Side sessions per group (pre-started spares hold some). Default 6. */
86
+ maxSessionsPerGroup?: number;
87
+ /** Decisions arriving within this window of each other fan out together. Default 25 ms. */
88
+ batchWindowMs?: number;
89
+ /** Close a side session idle this long. Default 30 min. */
90
+ idleMs?: number;
91
+ /** Default decision deadline. Default 30 s. */
92
+ timeoutMs?: number;
93
+ onEvent?: (event: PrimedEvent) => void;
94
+ }
95
+
96
+ interface Member {
97
+ readonly spec: PrimeSpec;
98
+ /** Replaced (standby context in front of input) when a reserved spare cannot serve it. */
99
+ request: DecisionRequest;
100
+ /** Its admission was registered: never registered again, and only then is it sent. */
101
+ admitted?: boolean;
102
+ readonly name: string;
103
+ /** Its position in a fan-out (m<n>), or its spare's token: the dispatcher echoes it, so it is deterministic. */
104
+ token: string;
105
+ readonly started: number;
106
+ readonly deadline: number;
107
+ admissionId?: string;
108
+ toolUseId?: string;
109
+ taskId?: string;
110
+ dispatchedAt?: number;
111
+ texts: string[];
112
+ tokens: { input: number; output: number; cacheRead: number; cacheWrite: number };
113
+ model?: string;
114
+ violation?: string;
115
+ /** Served by a pre-started spare instead of a fan-out. */
116
+ spare?: Spare;
117
+ deliveredAt?: number;
118
+ settled: boolean;
119
+ /** Resolves when the member settles either way. */
120
+ done: Promise<void>;
121
+ resolve(result: DecisionResult): void;
122
+ reject(error: DecisionError): void;
123
+ }
124
+
125
+ interface Spare {
126
+ /** The role's definition name (`d-…`); the standby variant is `w-…`. */
127
+ readonly name: string;
128
+ readonly standbyHash: string;
129
+ readonly prompt: string;
130
+ readonly token: string;
131
+ readonly side: Side;
132
+ toolUseId?: string;
133
+ taskId?: string;
134
+ /** The held `moment` call. */
135
+ pending?: { requestId: string; rpcId: unknown };
136
+ /** The decision it serves, once reserved. */
137
+ member?: Member;
138
+ violation?: string;
139
+ done: boolean;
140
+ /** Its standby turn's usage before its moment. */
141
+ tokens: { input: number; output: number; cacheRead: number; cacheWrite: number };
142
+ reported?: boolean;
143
+ }
144
+
145
+ interface Side {
146
+ readonly group: string;
147
+ readonly names: ReadonlySet<string>;
148
+ readonly proc: PipedSubprocess;
149
+ readonly dir: string;
150
+ readonly generation: number;
151
+ /** Eligible for new fan-outs; false from the moment it is being killed. */
152
+ alive: boolean;
153
+ /** Its exit has been handled. */
154
+ gone: boolean;
155
+ ready: Promise<void>;
156
+ busy: boolean;
157
+ lastUsed: number;
158
+ sessionId?: string;
159
+ /** Members of the fan-out in flight, by token. */
160
+ members: Map<string, Member>;
161
+ /** Pre-started members this side session's standby turn holds, by token. */
162
+ spares: Map<string, Spare>;
163
+ /** Its standby context was replaced: end its standby turn once no decision uses it. */
164
+ retire?: boolean;
165
+ onResult?: () => void;
166
+ control: Map<string, { resolve(r: Record<string, unknown>): void; reject(e: Error): void }>;
167
+ }
168
+
169
+ interface Group {
170
+ readonly id: string;
171
+ /** Agent definitions seen for this group, by name. */
172
+ defs: Map<string, { prompt: string; lastUsed: number }>;
173
+ sides: Side[];
174
+ batch: Member[];
175
+ timer?: ReturnType<typeof setTimeout>;
176
+ firstAt?: number;
177
+ /** Chunks waiting for a free side session. */
178
+ waiting: Member[][];
179
+ /** Roles to keep pre-started, with the standby context to hold. */
180
+ desired: Map<string, { standby: string; standbyHash: string }>;
181
+ /** Last decision or pre-start: a group idle longer than `idleMs` lets its spares go. */
182
+ lastActive: number;
183
+ /** Standby side sessions that failed to start; pre-starting backs off while it grows. */
184
+ startFailures: number;
185
+ retryAt: number;
186
+ }
187
+
188
+ /** Role definitions a group's side sessions launch with; the least recently used beyond it drop. */
189
+ const MAX_DEFINITIONS = 64;
190
+
191
+ const hash = (value: string) => createHash('sha256').update(value).digest('hex');
192
+ const standbyName = (name: string) => `w-${name.slice(2)}`;
193
+
194
+ export class ClaudeSubagentSessions implements PrimedSessions {
195
+ readonly runtime = 'claude' as const;
196
+ private readonly _config: ClaudeSubagentConfig &
197
+ Required<
198
+ Pick<
199
+ ClaudeSubagentConfig,
200
+ 'maxMembers' | 'maxSessionsPerGroup' | 'batchWindowMs' | 'idleMs' | 'timeoutMs'
201
+ >
202
+ >;
203
+ private _groups = new Map<string, Group>();
204
+ private _limits?: LimitSnapshot;
205
+ private _closed = false;
206
+ private _generation = 0;
207
+ private _idleTimer?: ReturnType<typeof setInterval>;
208
+
209
+ constructor(config: ClaudeSubagentConfig) {
210
+ if (!config.cwd?.startsWith('/'))
211
+ throw Error('Claude subagent sessions require an absolute private working directory');
212
+ this._config = {
213
+ ...config,
214
+ maxMembers: config.maxMembers ?? 12,
215
+ maxSessionsPerGroup: config.maxSessionsPerGroup ?? 6,
216
+ batchWindowMs: config.batchWindowMs ?? 25,
217
+ idleMs: config.idleMs ?? 30 * 60_000,
218
+ timeoutMs: config.timeoutMs ?? 30_000,
219
+ };
220
+ if (this._config.maxMembers < 1 || this._config.maxSessionsPerGroup < 1)
221
+ throw Error('Invalid subagent session bounds');
222
+ }
223
+
224
+ limits(): LimitSnapshot | undefined {
225
+ return this._limits;
226
+ }
227
+
228
+ snapshot(): PrimedSnapshot {
229
+ const sides = [...this._groups.values()].flatMap((g) => g.sides);
230
+ return {
231
+ runtime: 'claude',
232
+ closed: this._closed,
233
+ generation: this._generation,
234
+ processAlive: sides.some((s) => s.alive),
235
+ sessions: sides.length,
236
+ active: sides.reduce(
237
+ (n, s) => n + [...s.members.values()].filter((m) => !m.settled).length,
238
+ 0,
239
+ ),
240
+ waiting: [...this._groups.values()].reduce(
241
+ (n, g) => n + g.batch.length + g.waiting.reduce((m, c) => m + c.length, 0),
242
+ 0,
243
+ ),
244
+ ...(this._limits ? { limits: this._limits } : {}),
245
+ };
246
+ }
247
+
248
+ /** Nothing is primed per key: a member's definition lives with its group's side sessions. */
249
+ async evict(_key: string): Promise<void> {}
250
+
251
+ decide(spec: PrimeSpec, request: DecisionRequest): Promise<DecisionResult> {
252
+ if (this._closed)
253
+ return Promise.reject(
254
+ new DecisionError('Subagent sessions closed', 'closed', 'not-dispatched', true),
255
+ );
256
+ if (!spec.key || typeof spec.instructions !== 'string' || typeof request.input !== 'string')
257
+ return Promise.reject(
258
+ new DecisionError('Invalid decision request', 'admission', 'not-dispatched', true),
259
+ );
260
+ if (request.signal?.aborted)
261
+ return Promise.reject(
262
+ new DecisionError('Decision aborted before dispatch', 'aborted', 'not-dispatched', true),
263
+ );
264
+ this._idleTimer ??= setInterval(() => this._closeIdle(), Math.min(this._config.idleMs, 60_000));
265
+ (this._idleTimer as { unref?: () => void }).unref?.();
266
+ const group = this._group(request.group ?? 'default');
267
+ group.lastActive = Date.now();
268
+ const name = this._define(group, spec);
269
+ // A pre-started member holding this standby context serves the decision; otherwise it fans out
270
+ // with the standby context in front of its input.
271
+ const spare =
272
+ request.standby === undefined ? undefined : this._spareFor(group, name, request.standby);
273
+ if (request.standby !== undefined && !spare)
274
+ request = { ...request, input: `${request.standby}${request.input}` };
275
+ const started = Date.now();
276
+ let settle!: () => void;
277
+ const done = new Promise<void>((r) => {
278
+ settle = r;
279
+ });
280
+ return new Promise<DecisionResult>((resolve, reject) => {
281
+ const member: Member = {
282
+ done,
283
+ spec,
284
+ request,
285
+ name,
286
+ token: '',
287
+ started,
288
+ deadline: started + (request.timeoutMs ?? this._config.timeoutMs),
289
+ texts: [],
290
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
291
+ settled: false,
292
+ resolve: (result) => {
293
+ if (member.settled) return;
294
+ member.settled = true;
295
+ clearTimeout(timer);
296
+ settle();
297
+ resolve(result);
298
+ },
299
+ reject: (error) => {
300
+ if (member.settled) return;
301
+ member.settled = true;
302
+ clearTimeout(timer);
303
+ settle();
304
+ reject(error);
305
+ },
306
+ };
307
+ // Past its deadline a member is withdrawn: refused unsent, or its subagent is stopped.
308
+ const timer = setTimeout(
309
+ () => void this._withdraw(member, 'timeout'),
310
+ member.deadline - started,
311
+ );
312
+ request.signal?.addEventListener('abort', () => void this._withdraw(member, 'aborted'), {
313
+ once: true,
314
+ });
315
+ if (spare) {
316
+ member.spare = spare;
317
+ member.token = spare.token;
318
+ spare.member = member;
319
+ void this._reserve(spare, member);
320
+ return;
321
+ }
322
+ this._replenish(group);
323
+ group.batch.push(member);
324
+ group.firstAt ??= Date.now();
325
+ clearTimeout(group.timer);
326
+ const wait = Math.max(
327
+ 0,
328
+ Math.min(
329
+ this._config.batchWindowMs,
330
+ group.firstAt + 4 * this._config.batchWindowMs - Date.now(),
331
+ ),
332
+ );
333
+ group.timer = setTimeout(() => this._flush(group), wait);
334
+ });
335
+ }
336
+
337
+ /**
338
+ * Keep these roles pre-started with this standby context (the thread's current context, which
339
+ * precedes each decision's input). Replaces the group's spares; spares holding other context are
340
+ * ended once no decision uses them.
341
+ */
342
+ prestart(group: string, entries: ReadonlyArray<{ spec: PrimeSpec; standby: string }>): void {
343
+ if (this._closed) return;
344
+ const g = this._group(group);
345
+ g.lastActive = Date.now();
346
+ g.desired = new Map(
347
+ entries.map((e) => [
348
+ this._define(g, e.spec),
349
+ { standby: e.standby, standbyHash: hash(e.standby) },
350
+ ]),
351
+ );
352
+ for (const side of g.sides)
353
+ if (side.spares.size) {
354
+ const stale = [...side.spares.values()].some(
355
+ (sp) => g.desired.get(sp.name)?.standbyHash !== sp.standbyHash,
356
+ );
357
+ if (stale) {
358
+ side.retire = true;
359
+ this._endStandby(side);
360
+ }
361
+ }
362
+ this._replenish(g);
363
+ }
364
+
365
+ /** A group is done (its thread closed): its spares and side sessions stop. */
366
+ async releaseGroup(group: string): Promise<void> {
367
+ const g = this._groups.get(group);
368
+ if (!g) return;
369
+ g.desired = new Map();
370
+ clearTimeout(g.timer);
371
+ const closed = () =>
372
+ new DecisionError('Decision group released', 'closed', 'not-dispatched', true);
373
+ for (const m of g.batch.splice(0)) m.reject(closed());
374
+ for (const m of g.waiting.splice(0).flat()) m.reject(closed());
375
+ for (const side of g.sides) side.retire = true;
376
+ await Promise.all([...g.sides].map((side) => this._kill(side, 'group released')));
377
+ this._groups.delete(group);
378
+ }
379
+
380
+ async readLimits(): Promise<LimitSnapshot | undefined> {
381
+ return this._limits;
382
+ }
383
+
384
+ async close(): Promise<void> {
385
+ if (this._closed) return;
386
+ this._closed = true;
387
+ clearInterval(this._idleTimer);
388
+ const closed = () =>
389
+ new DecisionError('Subagent sessions closed', 'closed', 'not-dispatched', true);
390
+ for (const group of this._groups.values()) {
391
+ clearTimeout(group.timer);
392
+ for (const m of group.batch.splice(0)) m.reject(closed());
393
+ for (const m of group.waiting.splice(0).flat()) m.reject(closed());
394
+ }
395
+ await Promise.all(
396
+ [...this._groups.values()].flatMap((g) => [...g.sides]).map((s) => this._kill(s, 'close')),
397
+ );
398
+ }
399
+
400
+ // -------------------------------------------------------------------------
401
+
402
+ private _emit(event: PrimedEvent): void {
403
+ try {
404
+ this._config.onEvent?.(event);
405
+ } catch {
406
+ /* observers only */
407
+ }
408
+ }
409
+
410
+ /** Register a role's definition with its group; its name is the hash of its system prompt. */
411
+ private _define(group: Group, spec: PrimeSpec): string {
412
+ const prompt = [this._config.baseInstructions, spec.instructions, spec.context]
413
+ .filter(Boolean)
414
+ .join('\n\n');
415
+ const name = `d-${hash(prompt).slice(0, 16)}`;
416
+ group.defs.delete(name);
417
+ group.defs.set(name, { prompt, lastUsed: Date.now() });
418
+ // The least recently used definitions beyond the cap leave the next side session's agent file.
419
+ for (const old of group.defs.keys()) {
420
+ if (group.defs.size <= MAX_DEFINITIONS) break;
421
+ group.defs.delete(old);
422
+ }
423
+ return name;
424
+ }
425
+
426
+ private _spareFor(group: Group, name: string, standby: string): Spare | undefined {
427
+ const standbyHash = hash(standby);
428
+ for (const side of group.sides)
429
+ if (side.alive && !side.retire)
430
+ for (const spare of side.spares.values())
431
+ if (
432
+ spare.name === name &&
433
+ spare.standbyHash === standbyHash &&
434
+ !spare.member &&
435
+ !spare.done &&
436
+ !spare.violation
437
+ )
438
+ return spare;
439
+ return undefined;
440
+ }
441
+
442
+ /** Admit a decision onto its spare; the input goes out when the spare's moment call is held. */
443
+ private async _reserve(spare: Spare, member: Member): Promise<void> {
444
+ member.admissionId = crypto.randomUUID();
445
+ const ours = () => spare.member === member && member.spare === spare;
446
+ try {
447
+ await Promise.race([
448
+ Promise.resolve(
449
+ member.request.onAdmission?.({
450
+ admissionId: member.admissionId,
451
+ key: member.spec.key,
452
+ runtime: 'claude',
453
+ }),
454
+ ),
455
+ member.done,
456
+ ]);
457
+ } catch {
458
+ if (spare.member === member) spare.member = undefined;
459
+ member.reject(
460
+ new DecisionError(
461
+ 'Decision admission refused before dispatch',
462
+ 'admission',
463
+ 'not-dispatched',
464
+ true,
465
+ member.admissionId,
466
+ ),
467
+ );
468
+ return;
469
+ }
470
+ if (member.settled) {
471
+ if (spare.member === member && !member.deliveredAt) spare.member = undefined;
472
+ return;
473
+ }
474
+ member.admitted = true;
475
+ if (!ours()) return; // moved to a fan-out while it was being admitted
476
+ if (!this._serviceable(spare)) {
477
+ this._fallback(member);
478
+ return;
479
+ }
480
+ spare.side.members.set(member.token, member);
481
+ if (spare.pending) this._deliver(spare);
482
+ // A spare still starting gets a moment to call in; otherwise the decision goes by fan-out.
483
+ else
484
+ setTimeout(() => {
485
+ if (ours() && !member.deliveredAt && !member.settled) this._fallback(member);
486
+ }, RESERVE_START_MS);
487
+ }
488
+
489
+ /** The spare is still held by a live standby turn and can take a decision. */
490
+ private _serviceable(spare: Spare): boolean {
491
+ return (
492
+ spare.side.alive &&
493
+ spare.side.spares.get(spare.token) === spare &&
494
+ !spare.done &&
495
+ !spare.violation
496
+ );
497
+ }
498
+
499
+ /** A reserved decision its spare cannot serve: it fans out, its standby context in front of its input. */
500
+ private _fallback(member: Member): void {
501
+ const spare = member.spare;
502
+ if (!spare || member.settled || member.deliveredAt) return;
503
+ if (spare.member === member) spare.member = undefined;
504
+ spare.side.members.delete(member.token);
505
+ member.spare = undefined;
506
+ const { standby, ...rest } = member.request;
507
+ member.request = { ...rest, input: `${standby ?? ''}${member.request.input}` };
508
+ const group = this._groups.get(spare.side.group);
509
+ if (!group) return;
510
+ group.batch.push(member);
511
+ clearTimeout(group.timer);
512
+ group.timer = setTimeout(() => this._flush(group), 0);
513
+ }
514
+
515
+ private _reportStandby(spare: Spare): void {
516
+ if (spare.reported) return;
517
+ spare.reported = true;
518
+ this._emit({ type: 'standby', key: spare.name, ...spare.tokens });
519
+ }
520
+
521
+ private _deliver(spare: Spare): void {
522
+ const member = spare.member;
523
+ if (!member || member.settled || !spare.pending || member.deliveredAt || !member.admitted)
524
+ return;
525
+ if (spare.violation) {
526
+ this._fallback(member);
527
+ return;
528
+ }
529
+ member.deliveredAt = member.dispatchedAt = Date.now();
530
+ member.toolUseId = spare.toolUseId;
531
+ member.taskId = spare.taskId;
532
+ this._mcpReply(spare.side, spare.pending.requestId, {
533
+ jsonrpc: '2.0',
534
+ id: spare.pending.rpcId,
535
+ result: { content: [{ type: 'text', text: member.request.input }] },
536
+ });
537
+ }
538
+
539
+ /** Answer an MCP request from the runtime; true once written. */
540
+ private _mcpReply(side: Side, requestId: string, message: unknown): boolean {
541
+ this._write(side, {
542
+ type: 'control_response',
543
+ response: { subtype: 'success', request_id: requestId, response: { mcp_response: message } },
544
+ });
545
+ return true;
546
+ }
547
+
548
+ /** The host's MCP server: one tool, `moment`, whose call a pre-started member holds open. */
549
+ private _answerMcp(side: Side, requestId: string, message: Record<string, unknown>): boolean {
550
+ const id = message.id;
551
+ if (message.method === 'initialize')
552
+ return this._mcpReply(side, requestId, {
553
+ jsonrpc: '2.0',
554
+ id,
555
+ result: {
556
+ protocolVersion: (message.params as Record<string, unknown> | undefined)?.protocolVersion,
557
+ capabilities: { tools: {} },
558
+ serverInfo: { name: 'foundry', version: '1' },
559
+ },
560
+ });
561
+ if (message.method === 'tools/list')
562
+ return this._mcpReply(side, requestId, {
563
+ jsonrpc: '2.0',
564
+ id,
565
+ result: {
566
+ tools: [
567
+ {
568
+ name: 'moment',
569
+ description: 'Wait for the moment to assess. Its result is your input.',
570
+ inputSchema: {
571
+ type: 'object',
572
+ properties: { token: { type: 'string' } },
573
+ required: ['token'],
574
+ },
575
+ },
576
+ ],
577
+ },
578
+ });
579
+ if (message.method === 'tools/call') {
580
+ const params = (message.params ?? {}) as Record<string, unknown>;
581
+ const token = String(((params.arguments ?? {}) as Record<string, unknown>).token ?? '');
582
+ const spare = params.name === 'moment' ? side.spares.get(token) : undefined;
583
+ if (!spare || spare.pending || spare.done)
584
+ return this._mcpReply(side, requestId, {
585
+ jsonrpc: '2.0',
586
+ id,
587
+ result: { isError: true, content: [{ type: 'text', text: 'No moment for this token.' }] },
588
+ });
589
+ spare.pending = { requestId, rpcId: id };
590
+ this._reportStandby(spare);
591
+ this._deliver(spare);
592
+ return true;
593
+ }
594
+ if (id === undefined) return this._mcpReply(side, requestId, null);
595
+ return this._mcpReply(side, requestId, {
596
+ jsonrpc: '2.0',
597
+ id,
598
+ error: { code: -32601, message: 'method not found' },
599
+ });
600
+ }
601
+
602
+ /** Pre-start every desired role that has no spare holding its current standby context. */
603
+ private _replenish(group: Group): void {
604
+ if (this._closed || !group.desired.size || Date.now() < group.retryAt) return;
605
+ // Spares are created when their standby turn is assigned, so one still starting counts as held.
606
+ const held = new Set<string>();
607
+ for (const side of group.sides)
608
+ if (side.alive && !side.retire)
609
+ for (const spare of side.spares.values())
610
+ if (!spare.done && !spare.member && !spare.violation)
611
+ if (group.desired.get(spare.name)?.standbyHash === spare.standbyHash)
612
+ held.add(spare.name);
613
+ const missing = [...group.desired.keys()].filter((name) => !held.has(name));
614
+ if (!missing.length) return;
615
+ const chunks = Math.ceil(missing.length / this._config.maxMembers);
616
+ const size = Math.ceil(missing.length / chunks);
617
+ for (let i = 0; i < missing.length; i += size) {
618
+ const chunk = missing.slice(i, i + size);
619
+ const needs = chunk.map(standbyName);
620
+ let side = group.sides.find((s) => s.alive && !s.busy && needs.every((n) => s.names.has(n)));
621
+ if (!side) {
622
+ if (group.sides.length >= this._config.maxSessionsPerGroup) return; // retried when one frees
623
+ try {
624
+ side = this._spawn(group);
625
+ } catch {
626
+ this._startFailed(group);
627
+ return;
628
+ }
629
+ }
630
+ const owner = side;
631
+ owner.busy = true;
632
+ owner.spares = new Map(
633
+ chunk.map((name, index) => {
634
+ const desired = group.desired.get(name)!;
635
+ // A position in this standby turn: deterministic, so a recorded turn replays.
636
+ const token = `s${index + 1}`;
637
+ const spare: Spare = {
638
+ name,
639
+ standbyHash: desired.standbyHash,
640
+ prompt: standbyPrompt(desired.standby, token),
641
+ token,
642
+ side: owner,
643
+ done: false,
644
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
645
+ };
646
+ return [token, spare] as const;
647
+ }),
648
+ );
649
+ void this._standbyFanOut(owner, group)
650
+ .catch(() => undefined)
651
+ .finally(() => {
652
+ owner.busy = false;
653
+ owner.retire = false;
654
+ owner.lastUsed = Date.now();
655
+ this._drain(group);
656
+ this._replenish(group);
657
+ });
658
+ }
659
+ }
660
+
661
+ /** A standby side session that failed to start: pre-starting backs off (5 s, doubling to 5 min). */
662
+ private _startFailed(group: Group): void {
663
+ group.startFailures++;
664
+ group.retryAt = Date.now() + Math.min(5 * 60_000, 5_000 * 2 ** (group.startFailures - 1));
665
+ }
666
+
667
+ /** End a side session's standby turn once no decision is using its spares. */
668
+ private _endStandby(side: Side): void {
669
+ const inUse = [...side.spares.values()].some((sp) => sp.member && !sp.member.settled);
670
+ if (inUse || !side.spares.size) return;
671
+ void this._control(side, { subtype: 'interrupt' }, 5_000).catch(
672
+ () => void this._kill(side, 'standby interrupt unanswered'),
673
+ );
674
+ }
675
+
676
+ private _endStandbyNow(side: Side): void {
677
+ void this._control(side, { subtype: 'interrupt' }, 5_000).catch(
678
+ () => void this._kill(side, 'standby interrupt unanswered'),
679
+ );
680
+ }
681
+
682
+ /** One dispatcher turn holding pre-started members until each is used or the turn is ended. */
683
+ private async _standbyFanOut(side: Side, group: Group): Promise<void> {
684
+ try {
685
+ await side.ready;
686
+ } catch {
687
+ this._startFailed(group);
688
+ this._releaseSpares(side);
689
+ return;
690
+ }
691
+ group.startFailures = 0;
692
+ if (!side.alive) {
693
+ this._releaseSpares(side);
694
+ return;
695
+ }
696
+ const spares = [...side.spares.values()];
697
+ const uuid = crypto.randomUUID();
698
+ const turnEnded = new Promise<void>((resolve) => {
699
+ side.onResult = resolve;
700
+ });
701
+ try {
702
+ this._write(side, {
703
+ type: 'user',
704
+ uuid,
705
+ message: {
706
+ role: 'user',
707
+ content: spares.map((sp) => `${standbyName(sp.name)} ${sp.token}`).join('\n'),
708
+ },
709
+ parent_tool_use_id: null,
710
+ });
711
+ await turnEnded;
712
+ } finally {
713
+ side.onResult = undefined;
714
+ this._releaseSpares(side);
715
+ }
716
+ const rewound = side.alive
717
+ ? await this._control(side, { subtype: 'rewind_conversation', target_message_uuid: uuid })
718
+ .then((r) => r.rewound === true)
719
+ .catch(() => false)
720
+ : false;
721
+ if (!rewound) await this._kill(side, 'rewind refused');
722
+ }
723
+
724
+ /**
725
+ * The standby turn is over: a decision reserved on a spare that never ran fans out instead, and
726
+ * one that ran but did not answer fails.
727
+ */
728
+ private _releaseSpares(side: Side): void {
729
+ for (const sp of side.spares.values()) {
730
+ sp.done = true;
731
+ const member = sp.member;
732
+ if (!member || member.settled) continue;
733
+ if (!member.deliveredAt) this._fallback(member);
734
+ else
735
+ member.reject(
736
+ new DecisionError(
737
+ 'Subagent ended without an answer',
738
+ 'transport',
739
+ 'attempted',
740
+ true,
741
+ member.admissionId,
742
+ ),
743
+ );
744
+ }
745
+ side.spares = new Map();
746
+ side.members = new Map();
747
+ }
748
+
749
+ private _spareByToolUse(side: Side, id: unknown): Spare | undefined {
750
+ if (typeof id !== 'string') return undefined;
751
+ for (const sp of side.spares.values()) if (sp.toolUseId === id) return sp;
752
+ return undefined;
753
+ }
754
+
755
+ private _group(id: string): Group {
756
+ let group = this._groups.get(id);
757
+ if (!group) {
758
+ group = {
759
+ id,
760
+ defs: new Map(),
761
+ sides: [],
762
+ batch: [],
763
+ waiting: [],
764
+ desired: new Map(),
765
+ lastActive: Date.now(),
766
+ startFailures: 0,
767
+ retryAt: 0,
768
+ };
769
+ this._groups.set(id, group);
770
+ }
771
+ return group;
772
+ }
773
+
774
+ /** Split the batch evenly into fan-outs of at most `maxMembers`, one per side session. */
775
+ private _flush(group: Group): void {
776
+ group.timer = undefined;
777
+ group.firstAt = undefined;
778
+ const members = group.batch.splice(0).filter((m) => !m.settled);
779
+ if (!members.length) return;
780
+ const chunks = Math.ceil(members.length / this._config.maxMembers);
781
+ const size = Math.ceil(members.length / chunks);
782
+ for (let i = 0; i < members.length; i += size) group.waiting.push(members.slice(i, i + size));
783
+ this._drain(group);
784
+ }
785
+
786
+ private _drain(group: Group): void {
787
+ while (group.waiting.length && !this._closed) {
788
+ const chunk = group.waiting[0]!.filter((m) => !m.settled);
789
+ if (!chunk.length) {
790
+ group.waiting.shift();
791
+ continue;
792
+ }
793
+ const needs = new Set(chunk.map((m) => m.name));
794
+ let side = group.sides.find(
795
+ (s) => s.alive && !s.busy && [...needs].every((n) => s.names.has(n)),
796
+ );
797
+ if (!side) {
798
+ const idleLacking = group.sides.find((s) => !s.busy);
799
+ if (group.sides.length >= this._config.maxSessionsPerGroup) {
800
+ if (!idleLacking) return; // every side session is busy: the chunk waits
801
+ // A side session without these definitions is replaced by one that has them all.
802
+ group.sides.splice(group.sides.indexOf(idleLacking), 1);
803
+ void this._kill(idleLacking, 'definitions changed');
804
+ }
805
+ try {
806
+ side = this._spawn(group);
807
+ } catch (error) {
808
+ group.waiting.shift();
809
+ for (const m of chunk)
810
+ m.reject(
811
+ new DecisionError(
812
+ `Side session start failed: ${(error as Error).message}`,
813
+ 'transport',
814
+ 'not-dispatched',
815
+ true,
816
+ ),
817
+ );
818
+ continue;
819
+ }
820
+ }
821
+ group.waiting.shift();
822
+ side.busy = true;
823
+ void this._fanOut(side, chunk).finally(() => {
824
+ side!.busy = false;
825
+ side!.lastUsed = Date.now();
826
+ this._drain(group);
827
+ });
828
+ }
829
+ }
830
+
831
+ private _spawn(group: Group): Side {
832
+ const generation = ++this._generation;
833
+ const dir = mkdtempSync(join(tmpdir(), 'agent-session-agents-'));
834
+ try {
835
+ return this._launch(group, generation, dir);
836
+ } catch (error) {
837
+ rmSync(dir, { recursive: true, force: true });
838
+ throw error;
839
+ }
840
+ }
841
+
842
+ private _launch(group: Group, generation: number, dir: string): Side {
843
+ chmodSync(dir, 0o700);
844
+ const agents: Record<string, unknown> = {};
845
+ for (const [name, def] of group.defs) {
846
+ agents[name] = {
847
+ description: 'decision role',
848
+ prompt: def.prompt,
849
+ model: this._config.model,
850
+ tools: [],
851
+ };
852
+ agents[standbyName(name)] = {
853
+ description: 'decision role, pre-started',
854
+ prompt: def.prompt,
855
+ model: this._config.model,
856
+ tools: [MOMENT_TOOL],
857
+ };
858
+ }
859
+ const file = join(dir, 'agents.json');
860
+ writeFileSync(file, JSON.stringify(agents), { mode: 0o600 });
861
+ const argv = [
862
+ this._config.bin ?? 'claude',
863
+ '--print',
864
+ '--verbose',
865
+ '--input-format',
866
+ 'stream-json',
867
+ '--output-format',
868
+ 'stream-json',
869
+ '--model',
870
+ this._config.model,
871
+ ...(this._config.effort ? ['--effort', this._config.effort] : []),
872
+ '--system-prompt',
873
+ DISPATCHER,
874
+ '--tools',
875
+ 'Agent',
876
+ '--agents',
877
+ file,
878
+ '--strict-mcp-config',
879
+ '--mcp-config',
880
+ JSON.stringify({ mcpServers: { foundry: { type: 'sdk', name: 'foundry' } } }),
881
+ '--disable-slash-commands',
882
+ '--no-chrome',
883
+ '--no-session-persistence',
884
+ '--permission-mode',
885
+ 'bypassPermissions',
886
+ '--max-turns',
887
+ String(2 * this._config.maxMembers + 4),
888
+ '--forward-subagent-text',
889
+ ];
890
+ const env: Record<string, string | undefined> = {
891
+ ...process.env,
892
+ ...this._config.env,
893
+ DISABLE_AUTOUPDATER: '1',
894
+ // A pre-started member holds its moment call until the thread's next trigger.
895
+ MCP_TOOL_TIMEOUT: String(24 * 60 * 60_000),
896
+ };
897
+ delete env.ANTHROPIC_API_KEY;
898
+ delete env.ANTHROPIC_AUTH_TOKEN;
899
+ for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key];
900
+ const t0 = performance.now();
901
+ const proc = this._config.spawn
902
+ ? this._config.spawn(argv, { cwd: this._config.cwd, env })
903
+ : (Bun.spawn(argv, {
904
+ cwd: this._config.cwd,
905
+ env,
906
+ stdin: 'pipe',
907
+ stdout: 'pipe',
908
+ stderr: 'pipe',
909
+ }) as unknown as PipedSubprocess);
910
+ const side: Side = {
911
+ group: group.id,
912
+ names: new Set(group.defs.keys()),
913
+ proc,
914
+ dir,
915
+ generation,
916
+ alive: true,
917
+ gone: false,
918
+ ready: Promise.resolve(),
919
+ busy: false,
920
+ lastUsed: Date.now(),
921
+ members: new Map(),
922
+ spares: new Map(),
923
+ control: new Map(),
924
+ };
925
+ group.sides.push(side);
926
+ void this._read(side);
927
+ void proc.exited.then(
928
+ () => this._lost(side),
929
+ () => this._lost(side),
930
+ );
931
+ side.ready = this._control(side, {
932
+ subtype: 'initialize',
933
+ sdkMcpServers: ['foundry'],
934
+ hooks: { PreToolUse: [{ matcher: 'Agent', hookCallbackIds: ['members'] }] },
935
+ }).then(() => {
936
+ this._emit({ type: 'process-started', generation, ms: Math.round(performance.now() - t0) });
937
+ });
938
+ // A side session that never initialized serves nothing: it is stopped, not left to fail every fan-out.
939
+ side.ready.catch(() => void this._kill(side, 'start failed'));
940
+ return side;
941
+ }
942
+
943
+ private _write(side: Side, message: unknown): void {
944
+ side.proc.stdin.write(`${JSON.stringify(message)}\n`);
945
+ side.proc.stdin.flush();
946
+ }
947
+
948
+ private _control(side: Side, request: Record<string, unknown>, timeoutMs = 30_000) {
949
+ const id = `c-${crypto.randomUUID()}`;
950
+ return new Promise<Record<string, unknown>>((resolve, reject) => {
951
+ const timer = setTimeout(() => {
952
+ side.control.delete(id);
953
+ reject(Error(`${String(request.subtype)} unanswered`));
954
+ }, timeoutMs);
955
+ side.control.set(id, {
956
+ resolve: (r) => {
957
+ clearTimeout(timer);
958
+ resolve(r);
959
+ },
960
+ reject: (e) => {
961
+ clearTimeout(timer);
962
+ reject(e);
963
+ },
964
+ });
965
+ try {
966
+ this._write(side, { type: 'control_request', request_id: id, request });
967
+ } catch (error) {
968
+ side.control.delete(id);
969
+ clearTimeout(timer);
970
+ reject(error as Error);
971
+ }
972
+ });
973
+ }
974
+
975
+ private async _read(side: Side): Promise<void> {
976
+ const decoder = new TextDecoder();
977
+ let buffer = '';
978
+ try {
979
+ for await (const chunk of side.proc.stdout) {
980
+ buffer += decoder.decode(chunk, { stream: true });
981
+ for (let n = buffer.indexOf('\n'); n >= 0; n = buffer.indexOf('\n')) {
982
+ const line = buffer.slice(0, n);
983
+ buffer = buffer.slice(n + 1);
984
+ if (!line.trim()) continue;
985
+ let frame: Record<string, unknown>;
986
+ try {
987
+ frame = JSON.parse(line);
988
+ } catch {
989
+ continue;
990
+ }
991
+ this._frame(side, frame);
992
+ }
993
+ }
994
+ } catch {
995
+ /* the exit handler settles what is left */
996
+ }
997
+ }
998
+
999
+ private _byToolUse(side: Side, id: unknown): Member | undefined {
1000
+ if (typeof id !== 'string') return undefined;
1001
+ for (const m of side.members.values()) if (m.toolUseId === id) return m;
1002
+ return undefined;
1003
+ }
1004
+
1005
+ private _frame(side: Side, frame: Record<string, unknown>): void {
1006
+ const type = frame.type;
1007
+ if (typeof frame.session_id === 'string') side.sessionId ??= frame.session_id;
1008
+ if (type === 'control_response') {
1009
+ const response = frame.response as Record<string, unknown> | undefined;
1010
+ const id = response?.request_id as string | undefined;
1011
+ const pending = id ? side.control.get(id) : undefined;
1012
+ if (!pending) return;
1013
+ side.control.delete(id!);
1014
+ if (response?.subtype === 'error')
1015
+ pending.reject(Error(String(response.error ?? 'control error')));
1016
+ else pending.resolve((response?.response as Record<string, unknown>) ?? {});
1017
+ return;
1018
+ }
1019
+ if (type === 'control_request') {
1020
+ const request = frame.request as Record<string, unknown> | undefined;
1021
+ const requestId = frame.request_id as string;
1022
+ if (request?.subtype === 'mcp_message' && request.server_name === 'foundry') {
1023
+ this._answerMcp(side, requestId, (request.message ?? {}) as Record<string, unknown>);
1024
+ return;
1025
+ }
1026
+ if (request?.subtype === 'hook_callback' && request.callback_id === 'members') {
1027
+ this._answerHook(side, requestId, request.input as Record<string, unknown> | undefined);
1028
+ return;
1029
+ }
1030
+ // No other request is ours to grant.
1031
+ this._write(side, {
1032
+ type: 'control_response',
1033
+ response: {
1034
+ subtype: 'error',
1035
+ request_id: requestId,
1036
+ error: 'not supported by a decision host',
1037
+ },
1038
+ });
1039
+ return;
1040
+ }
1041
+ if (type === 'rate_limit_event') {
1042
+ const snapshot = claudeRateLimitSnapshot(frame.rate_limit_info);
1043
+ if (snapshot) {
1044
+ this._limits = mergeLimits(this._limits, snapshot);
1045
+ this._emit({ type: 'limits', limits: this._limits });
1046
+ }
1047
+ return;
1048
+ }
1049
+ if (type === 'system' && frame.subtype === 'task_started') {
1050
+ const spare = this._spareByToolUse(side, frame.tool_use_id);
1051
+ if (spare) {
1052
+ spare.taskId = frame.task_id as string;
1053
+ if (frame.prompt !== spare.prompt)
1054
+ spare.violation = 'standby input was not the composed input';
1055
+ return;
1056
+ }
1057
+ const member = this._byToolUse(side, frame.tool_use_id);
1058
+ if (!member) return;
1059
+ member.taskId = frame.task_id as string;
1060
+ // The hook decides what a subagent receives; anything else is a violation.
1061
+ if (frame.prompt !== member.request.input)
1062
+ member.violation = 'member input was not the composed input';
1063
+ return;
1064
+ }
1065
+ if (type === 'assistant' && frame.parent_tool_use_id) {
1066
+ const spare = this._spareByToolUse(side, frame.parent_tool_use_id);
1067
+ if (spare && !spare.member?.deliveredAt) {
1068
+ // Before its moment a pre-started member may only call the moment tool.
1069
+ const message = frame.message as Record<string, unknown> | undefined;
1070
+ for (const block of (message?.content as Array<Record<string, unknown>>) ?? [])
1071
+ if (block.type === 'tool_use' && block.name !== MOMENT_TOOL)
1072
+ spare.violation = 'tool activity before its moment';
1073
+ const usage = message?.usage as Record<string, number> | undefined;
1074
+ if (usage) {
1075
+ spare.tokens.input += usage.input_tokens ?? 0;
1076
+ spare.tokens.output += usage.output_tokens ?? 0;
1077
+ spare.tokens.cacheRead += usage.cache_read_input_tokens ?? 0;
1078
+ spare.tokens.cacheWrite += usage.cache_creation_input_tokens ?? 0;
1079
+ }
1080
+ return;
1081
+ }
1082
+ const member = spare?.member ?? this._byToolUse(side, frame.parent_tool_use_id);
1083
+ if (!member) return;
1084
+ const message = frame.message as Record<string, unknown> | undefined;
1085
+ for (const block of (message?.content as Array<Record<string, unknown>>) ?? []) {
1086
+ if (block.type === 'text' && typeof block.text === 'string') member.texts.push(block.text);
1087
+ if (block.type === 'tool_use') member.violation = 'tool activity';
1088
+ }
1089
+ if (typeof message?.model === 'string') member.model = message.model;
1090
+ const usage = message?.usage as Record<string, number> | undefined;
1091
+ if (usage) {
1092
+ member.tokens.input += usage.input_tokens ?? 0;
1093
+ member.tokens.output += usage.output_tokens ?? 0;
1094
+ member.tokens.cacheRead += usage.cache_read_input_tokens ?? 0;
1095
+ member.tokens.cacheWrite += usage.cache_creation_input_tokens ?? 0;
1096
+ }
1097
+ return;
1098
+ }
1099
+ if (type === 'system' && frame.subtype === 'task_notification') {
1100
+ const spare = this._spareByToolUse(side, frame.tool_use_id);
1101
+ if (spare) {
1102
+ spare.done = true;
1103
+ this._reportStandby(spare);
1104
+ const member = spare.member;
1105
+ if (member?.deliveredAt) this._settle(side, member, String(frame.status ?? 'unknown'));
1106
+ else if (member) this._fallback(member);
1107
+ // Every spare used or ended: the standby turn ends too.
1108
+ if ([...side.spares.values()].every((sp) => sp.done)) this._endStandbyNow(side);
1109
+ else if (side.retire) this._endStandby(side);
1110
+ const group = this._groups.get(side.group);
1111
+ if (group) this._replenish(group);
1112
+ return;
1113
+ }
1114
+ const member = this._byToolUse(side, frame.tool_use_id);
1115
+ if (member) this._settle(side, member, String(frame.status ?? 'unknown'));
1116
+ return;
1117
+ }
1118
+ if (type === 'result') side.onResult?.();
1119
+ }
1120
+
1121
+ private _answerHook(
1122
+ side: Side,
1123
+ requestId: string,
1124
+ input: Record<string, unknown> | undefined,
1125
+ ): void {
1126
+ const toolInput = (input?.tool_input ?? {}) as Record<string, unknown>;
1127
+ const spare = side.spares.get(String(toolInput.description ?? ''));
1128
+ if (spare) {
1129
+ const usable = !spare.toolUseId && standbyName(spare.name) === toolInput.subagent_type;
1130
+ if (usable) spare.toolUseId = input?.tool_use_id as string;
1131
+ this._write(side, {
1132
+ type: 'control_response',
1133
+ response: {
1134
+ subtype: 'success',
1135
+ request_id: requestId,
1136
+ response: {
1137
+ hookSpecificOutput: usable
1138
+ ? {
1139
+ hookEventName: 'PreToolUse',
1140
+ permissionDecision: 'allow',
1141
+ updatedInput: {
1142
+ subagent_type: toolInput.subagent_type,
1143
+ description: spare.token,
1144
+ prompt: spare.prompt,
1145
+ run_in_background: false,
1146
+ },
1147
+ }
1148
+ : {
1149
+ hookEventName: 'PreToolUse',
1150
+ permissionDecision: 'deny',
1151
+ permissionDecisionReason: 'not a member of this fan-out',
1152
+ },
1153
+ },
1154
+ },
1155
+ });
1156
+ return;
1157
+ }
1158
+ const member = side.members.get(String(toolInput.description ?? ''));
1159
+ const usable =
1160
+ member &&
1161
+ !member.settled &&
1162
+ !member.toolUseId &&
1163
+ member.name === toolInput.subagent_type &&
1164
+ Date.now() < member.deadline;
1165
+ const output = usable
1166
+ ? {
1167
+ hookEventName: 'PreToolUse',
1168
+ permissionDecision: 'allow',
1169
+ updatedInput: {
1170
+ subagent_type: member.name,
1171
+ description: member.token,
1172
+ prompt: member.request.input,
1173
+ run_in_background: false,
1174
+ },
1175
+ }
1176
+ : {
1177
+ hookEventName: 'PreToolUse',
1178
+ permissionDecision: 'deny',
1179
+ permissionDecisionReason: 'not a member of this fan-out',
1180
+ };
1181
+ if (usable) {
1182
+ member.toolUseId = input?.tool_use_id as string;
1183
+ member.dispatchedAt = Date.now();
1184
+ }
1185
+ this._write(side, {
1186
+ type: 'control_response',
1187
+ response: {
1188
+ subtype: 'success',
1189
+ request_id: requestId,
1190
+ response: { hookSpecificOutput: output },
1191
+ },
1192
+ });
1193
+ }
1194
+
1195
+ private _settle(side: Side, member: Member, status: string): void {
1196
+ if (member.settled) return;
1197
+ if (member.violation) {
1198
+ this._emit({ type: 'violation', key: member.spec.key, detail: member.violation });
1199
+ member.reject(
1200
+ new DecisionError(
1201
+ `Decision violated the decision policy (${member.violation})`,
1202
+ 'violation',
1203
+ 'attempted',
1204
+ true,
1205
+ member.admissionId,
1206
+ ),
1207
+ );
1208
+ return;
1209
+ }
1210
+ if (status !== 'completed') {
1211
+ member.reject(
1212
+ new DecisionError(
1213
+ `Claude subagent ${status}`,
1214
+ 'native-failed',
1215
+ 'attempted',
1216
+ true,
1217
+ member.admissionId,
1218
+ ),
1219
+ );
1220
+ return;
1221
+ }
1222
+ const ms = Date.now() - member.started;
1223
+ this._emit({
1224
+ type: 'decision',
1225
+ key: member.spec.key,
1226
+ prime: 'warm',
1227
+ ms,
1228
+ cacheRead: member.tokens.cacheRead,
1229
+ });
1230
+ member.resolve({
1231
+ key: member.spec.key,
1232
+ admissionId: member.admissionId!,
1233
+ content: member.texts.join(''),
1234
+ tokens: {
1235
+ input: member.tokens.input,
1236
+ output: member.tokens.output,
1237
+ cacheRead: member.tokens.cacheRead,
1238
+ cacheWrite: member.tokens.cacheWrite,
1239
+ },
1240
+ ...(member.model ? { model: member.model } : {}),
1241
+ ...(side.sessionId ? { threadId: side.sessionId } : {}),
1242
+ ...(member.toolUseId ? { turnId: member.toolUseId } : {}),
1243
+ prime: 'warm',
1244
+ timing: {
1245
+ waitMs: (member.dispatchedAt ?? Date.now()) - member.started,
1246
+ primeMs: 0,
1247
+ branchMs: 0,
1248
+ turnMs: Date.now() - (member.dispatchedAt ?? member.started),
1249
+ },
1250
+ });
1251
+ }
1252
+
1253
+ /** A member past its deadline or aborted: refused unsent, or rejected and its running subagent stopped. */
1254
+ private async _withdraw(member: Member, reason: 'timeout' | 'aborted'): Promise<void> {
1255
+ if (member.settled) return;
1256
+ const message = reason === 'timeout' ? 'Decision deadline passed' : 'Decision aborted';
1257
+ if (member.spare && !member.deliveredAt) {
1258
+ // Never sent: the spare stays pre-started for the next decision.
1259
+ member.spare.member = undefined;
1260
+ member.spare.side.members.delete(member.token);
1261
+ }
1262
+ if (!member.deliveredAt && (member.spare || !member.toolUseId)) {
1263
+ member.reject(
1264
+ new DecisionError(
1265
+ `${message} before dispatch`,
1266
+ reason,
1267
+ 'not-dispatched',
1268
+ true,
1269
+ member.admissionId,
1270
+ ),
1271
+ );
1272
+ return;
1273
+ }
1274
+ // Rejected at once, so the deadline is never extended by the stop. A stop that cannot be
1275
+ // confirmed recycles the side session.
1276
+ member.reject(
1277
+ new DecisionError(
1278
+ `${message}; subagent being stopped`,
1279
+ reason,
1280
+ 'attempted',
1281
+ false,
1282
+ member.admissionId,
1283
+ ),
1284
+ );
1285
+ const side =
1286
+ member.spare?.side ??
1287
+ [...this._groups.values()]
1288
+ .flatMap((g) => g.sides)
1289
+ .find((s) => [...s.members.values()].includes(member));
1290
+ if (!side) return;
1291
+ const stopped =
1292
+ side.alive && member.taskId
1293
+ ? await this._control(side, { subtype: 'stop_task', task_id: member.taskId }, 2_000).then(
1294
+ () => true,
1295
+ () => false,
1296
+ )
1297
+ : false;
1298
+ if (!stopped) await this._kill(side, `${reason}: subagent stop unconfirmed`);
1299
+ }
1300
+
1301
+ private async _fanOut(side: Side, chunk: Member[], retry = false): Promise<void> {
1302
+ try {
1303
+ await side.ready;
1304
+ } catch (error) {
1305
+ for (const m of chunk)
1306
+ m.reject(
1307
+ new DecisionError(
1308
+ `Side session start failed: ${(error as Error).message}`,
1309
+ 'transport',
1310
+ 'not-dispatched',
1311
+ true,
1312
+ ),
1313
+ );
1314
+ return;
1315
+ }
1316
+ // Registration precedes the native write, so a refused admission never reaches the runtime.
1317
+ // Admissions run together, and a member that settles meanwhile (its deadline) is not waited for.
1318
+ const admitted: Member[] = [];
1319
+ await Promise.all(
1320
+ chunk.map(async (m) => {
1321
+ if (m.settled) return;
1322
+ m.admissionId ??= crypto.randomUUID();
1323
+ try {
1324
+ if (!retry && !m.admitted)
1325
+ await Promise.race([
1326
+ Promise.resolve(
1327
+ m.request.onAdmission?.({
1328
+ admissionId: m.admissionId,
1329
+ key: m.spec.key,
1330
+ runtime: 'claude',
1331
+ }),
1332
+ ),
1333
+ m.done,
1334
+ ]);
1335
+ m.admitted = true;
1336
+ if (!m.settled) admitted.push(m);
1337
+ } catch {
1338
+ m.reject(
1339
+ new DecisionError(
1340
+ 'Decision admission refused before dispatch',
1341
+ 'admission',
1342
+ 'not-dispatched',
1343
+ true,
1344
+ m.admissionId,
1345
+ ),
1346
+ );
1347
+ }
1348
+ }),
1349
+ );
1350
+ if (!admitted.length || !side.alive) {
1351
+ for (const m of admitted)
1352
+ m.reject(
1353
+ new DecisionError(
1354
+ 'Side session lost before dispatch',
1355
+ 'transport',
1356
+ 'not-dispatched',
1357
+ true,
1358
+ m.admissionId,
1359
+ ),
1360
+ );
1361
+ return;
1362
+ }
1363
+ // Tokens are positions in this fan-out, so a recorded dispatcher turn replays with the same ones.
1364
+ admitted.forEach((m, i) => {
1365
+ m.token = `m${i + 1}`;
1366
+ });
1367
+ side.members = new Map(admitted.map((m) => [m.token, m]));
1368
+ const uuid = crypto.randomUUID();
1369
+ const turnEnded = new Promise<void>((resolve) => {
1370
+ side.onResult = resolve;
1371
+ });
1372
+ const deadline = Math.max(...admitted.map((m) => m.deadline)) + 5_000;
1373
+ let interrupted = false,
1374
+ ended = false,
1375
+ overdue: ReturnType<typeof setTimeout> | undefined;
1376
+ // Every member answered: the dispatcher's own wrap-up is not waited for.
1377
+ const allSettled = setInterval(() => {
1378
+ if (!interrupted && !ended && admitted.every((m) => m.settled)) {
1379
+ interrupted = true;
1380
+ void this._control(side, { subtype: 'interrupt' }, 5_000).catch(() => undefined);
1381
+ }
1382
+ }, 20);
1383
+ try {
1384
+ this._write(side, {
1385
+ type: 'user',
1386
+ uuid,
1387
+ message: { role: 'user', content: admitted.map((m) => `${m.name} ${m.token}`).join('\n') },
1388
+ parent_tool_use_id: null,
1389
+ });
1390
+ ended = await Promise.race([
1391
+ turnEnded.then(() => true),
1392
+ new Promise<false>((resolve) => {
1393
+ overdue = setTimeout(() => resolve(false), Math.max(0, deadline - Date.now()));
1394
+ }),
1395
+ ]);
1396
+ clearTimeout(overdue);
1397
+ clearInterval(allSettled);
1398
+ if (!ended) {
1399
+ await this._kill(side, 'fan-out turn did not end');
1400
+ return;
1401
+ }
1402
+ // Any member still unsettled ended with the turn; skipped members are sent once more.
1403
+ const skipped = admitted.filter((m) => !m.settled && !m.toolUseId);
1404
+ for (const m of admitted.filter((m) => !m.settled && m.toolUseId))
1405
+ m.reject(
1406
+ new DecisionError(
1407
+ 'Subagent ended without an answer',
1408
+ 'transport',
1409
+ 'attempted',
1410
+ true,
1411
+ m.admissionId,
1412
+ ),
1413
+ );
1414
+ side.members = new Map();
1415
+ const rewound = side.alive
1416
+ ? await this._control(side, { subtype: 'rewind_conversation', target_message_uuid: uuid })
1417
+ .then((r) => r.rewound === true)
1418
+ .catch(() => false)
1419
+ : false;
1420
+ if (!rewound) await this._kill(side, 'rewind refused');
1421
+ if (skipped.length) {
1422
+ if (!retry && side.alive) await this._fanOut(side, skipped, true);
1423
+ else
1424
+ for (const m of skipped)
1425
+ m.reject(
1426
+ new DecisionError(
1427
+ 'The dispatcher did not call this member',
1428
+ 'transport',
1429
+ 'not-dispatched',
1430
+ true,
1431
+ m.admissionId,
1432
+ ),
1433
+ );
1434
+ }
1435
+ } catch (error) {
1436
+ for (const m of admitted)
1437
+ m.reject(
1438
+ new DecisionError(
1439
+ `Fan-out failed: ${(error as Error).message}`,
1440
+ 'transport',
1441
+ m.toolUseId ? 'attempted' : 'not-dispatched',
1442
+ !m.toolUseId,
1443
+ m.admissionId,
1444
+ ),
1445
+ );
1446
+ await this._kill(side, 'fan-out failed');
1447
+ } finally {
1448
+ clearTimeout(overdue);
1449
+ clearInterval(allSettled);
1450
+ side.onResult = undefined;
1451
+ side.members = new Map();
1452
+ }
1453
+ }
1454
+
1455
+ /** The side session's exit: everything it held settles, and its group's waiting work moves on. */
1456
+ private _lost(side: Side): void {
1457
+ if (side.gone) return;
1458
+ side.gone = true;
1459
+ side.alive = false;
1460
+ try {
1461
+ rmSync(side.dir, { recursive: true, force: true });
1462
+ } catch {
1463
+ /* best effort */
1464
+ }
1465
+ for (const pending of side.control.values()) pending.reject(Error('side session exited'));
1466
+ side.control.clear();
1467
+ for (const sp of side.spares.values())
1468
+ if (sp.member && !sp.member.deliveredAt) this._fallback(sp.member);
1469
+ for (const m of side.members.values())
1470
+ m.reject(
1471
+ new DecisionError(
1472
+ 'Side session exited',
1473
+ 'transport',
1474
+ m.toolUseId ? 'attempted' : 'not-dispatched',
1475
+ true,
1476
+ m.admissionId,
1477
+ ),
1478
+ );
1479
+ side.onResult?.();
1480
+ const group = this._groups.get(side.group);
1481
+ if (group) {
1482
+ const i = group.sides.indexOf(side);
1483
+ if (i >= 0) group.sides.splice(i, 1);
1484
+ this._drain(group);
1485
+ }
1486
+ }
1487
+
1488
+ /** Stop a side session; from the first moment it takes no new fan-out. */
1489
+ private async _kill(side: Side, reason: string): Promise<void> {
1490
+ if (side.gone) return;
1491
+ if (side.alive && reason !== 'close')
1492
+ this._emit({ type: 'process-recycled', generation: side.generation, reason });
1493
+ side.alive = false;
1494
+ const group = this._groups.get(side.group);
1495
+ const i = group ? group.sides.indexOf(side) : -1;
1496
+ if (group && i >= 0) group.sides.splice(i, 1);
1497
+ try {
1498
+ side.proc.kill();
1499
+ } catch {
1500
+ /* exited */
1501
+ }
1502
+ let timer: ReturnType<typeof setTimeout> | undefined;
1503
+ await Promise.race([
1504
+ side.proc.exited.catch(() => 0),
1505
+ new Promise((resolve) => {
1506
+ timer = setTimeout(resolve, 2_000);
1507
+ }),
1508
+ ]);
1509
+ clearTimeout(timer);
1510
+ this._lost(side);
1511
+ }
1512
+
1513
+ private _closeIdle(): void {
1514
+ const cutoff = Date.now() - this._config.idleMs;
1515
+ for (const group of this._groups.values()) {
1516
+ // A thread with no decision for a while lets its spares go; they are pre-started again on demand.
1517
+ if (group.lastActive < cutoff && group.desired.size) {
1518
+ group.desired = new Map();
1519
+ for (const side of group.sides)
1520
+ if (side.spares.size) {
1521
+ side.retire = true;
1522
+ this._endStandby(side);
1523
+ }
1524
+ }
1525
+ for (const side of [...group.sides])
1526
+ if (!side.busy && side.lastUsed < cutoff) void this._kill(side, 'idle');
1527
+ for (const [name, def] of group.defs) if (def.lastUsed < cutoff) group.defs.delete(name);
1528
+ if (!group.sides.length && !group.defs.size && !group.batch.length)
1529
+ this._groups.delete(group.id);
1530
+ }
1531
+ }
1532
+ }