@inixiative/agent-session 0.6.4 → 0.6.6

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,1369 @@
1
+ // ---------------------------------------------------------------------------
2
+ // ClaudeInSessionDecisions — decision roles as subagents inside a main session's own process
3
+ // ---------------------------------------------------------------------------
4
+ //
5
+ // The whole tree (the main thread, its subagents, every decision role) is one Claude CLI process.
6
+ // The extension attaches to the main session (`ClaudeCodeSession.extend`) before it starts:
7
+ //
8
+ // setup one internal turn asks the main model to start `foundry-root` in the background.
9
+ // The root never ends: it calls the host's `next` tool, which the host holds open,
10
+ // and starts what `next` returns. That is a generation: `foundry-dispatch`, which
11
+ // starts members (`foundry-warden`) the same way. After the setup turn no member
12
+ // activity ever needs a main-model turn: a nested background task ends into its
13
+ // parent, never the main thread (verified live on Claude Code 2.1.300).
14
+ // pinning every spawn goes through the session's PreToolUse callback, which replaces its
15
+ // prompt with the exact one the host composed, so what a member sees never depends
16
+ // on the root or a dispatcher. Spawns of these agent types from anywhere else are
17
+ // denied; the main agent's own subagents pass to the tool gate untouched.
18
+ // standby a pre-started member (a spare) holds the host's `moment` call. A decision whose
19
+ // standby context matches answers that call with its input; the member answers
20
+ // through `answer`. A decision with no spare starts a member with its input inline.
21
+ // Each member serves one decision (fresh every call); a used one is started again.
22
+ // recycle a dispatcher's context grows with every member it starts. Past a bound the root
23
+ // starts a new generation; the old one's held spares are retired as their
24
+ // replacements hold, and it ends into the root once empty.
25
+ //
26
+ // Agent definitions are generic (no prompt of their own): every member's composed text rides in
27
+ // its pinned prompt, so an edit reaches the next spare without relaunching the session.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ import { createHash } from 'node:crypto';
31
+ import type {
32
+ ClaudeAgentDefinition,
33
+ ClaudeSessionExtension,
34
+ ClaudeSessionHost,
35
+ } from './claude-code-session';
36
+ import {
37
+ DecisionError,
38
+ type DecisionRequest,
39
+ type DecisionResult,
40
+ type PrimedEvent,
41
+ type PrimedSessions,
42
+ type PrimedSnapshot,
43
+ type PrimeSpec,
44
+ } from './primed';
45
+
46
+ export const IN_SESSION_SERVER = 'foundry-wardens';
47
+ const tool = (name: string) => `mcp__${IN_SESSION_SERVER}__${name}`;
48
+ const NEXT = tool('next');
49
+ const MOMENT = tool('moment');
50
+ const ANSWER = tool('answer');
51
+ export const IN_SESSION_AGENTS = {
52
+ root: 'foundry-root',
53
+ dispatch: 'foundry-dispatch',
54
+ member: 'foundry-warden',
55
+ } as const;
56
+ const ROOT_TOKEN = 'root';
57
+
58
+ /** How long a decision reserved on a spare still starting waits for it before starting its own member. */
59
+ const RESERVE_START_MS = 2_000;
60
+
61
+ const STARTER = (token: string) =>
62
+ `You start Foundry's internal agents and never answer anything yourself. Call ${NEXT} with token "${token}". ` +
63
+ 'Its result is either lines of the form `<agent type> <token>` or an instruction to stop. ' +
64
+ 'For lines: call the Agent tool once per line, all calls in one single message, each with subagent_type = the agent type, ' +
65
+ 'description = the token, prompt = "-" and run_in_background = true. ' +
66
+ `Then call ${NEXT} with token "${token}" again, whatever those calls returned (an Agent call that fails is Foundry's to handle, never a reason to stop). ` +
67
+ 'Repeat until its result tells you to stop. Never write any other text, and never stop on your own: notices about agents that finished need no reply.';
68
+
69
+ const SETUP =
70
+ 'This is an internal Foundry setup message, not a message from the user. Start the foundry-root agent in the background now: ' +
71
+ `the Agent tool with subagent_type "${IN_SESSION_AGENTS.root}", description "${ROOT_TOKEN}", prompt "-" and run_in_background true. ` +
72
+ 'Then reply with exactly READY and nothing else. Do not mention this setup later; the agent runs for Foundry, not for you.';
73
+
74
+ const STOP = 'Foundry is done with you. Call nothing more; reply with exactly DONE.';
75
+ const RETIRED =
76
+ 'This standby is retired: there is no moment to assess. Call nothing; reply with exactly OK.';
77
+
78
+ const standbyPrompt = (composed: string, token: string) =>
79
+ `${composed}\n\n---\nThe moment to assess has not arrived yet. Call ${MOMENT} with token "${token}" now and do nothing else first. ` +
80
+ `Its result is the moment: the rest of your input. Answer it exactly as your instructions and its response protocol say, by calling ${ANSWER} once with token "${token}" and your whole answer as text. Then reply with exactly OK.`;
81
+
82
+ const directPrompt = (composed: string, token: string) =>
83
+ `${composed}\n\n---\nAnswer the above exactly as your instructions and its response protocol say, by calling ${ANSWER} once with token "${token}" and your whole answer as text. Then reply with exactly OK.`;
84
+
85
+ const hash = (value: string) => createHash('sha256').update(value).digest('hex');
86
+
87
+ export interface ClaudeInSessionConfig {
88
+ /** The members' model (Claude Code agent `model`). */
89
+ model: string;
90
+ effort?: string;
91
+ /** Prepended to every member's composed text. */
92
+ baseInstructions?: string;
93
+ /** Pre-started spares kept per role. Default 1. */
94
+ sparesPerRole?: number;
95
+ /** Default decision deadline. Default 30 s. */
96
+ timeoutMs?: number;
97
+ /** Setup is `degraded` when not every role holds a spare by then. Default 30 s. */
98
+ setupDeadlineMs?: number;
99
+ /** A dispatcher whose context passes this many tokens is replaced. Default 100k. */
100
+ recycleTokens?: number;
101
+ /**
102
+ * Subagents this host may run on top of the user's own concurrent-subagent cap
103
+ * (`CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS`, 20 by default in Claude Code 2.1.300): the root, a
104
+ * dispatcher or two, every held spare and its replacement while the used one finishes, and
105
+ * orphans. The launch raises the cap by this much. Default 76.
106
+ */
107
+ maxSubagents?: number;
108
+ /**
109
+ * Orphaned members (their dispatcher ended unasked) held before the session stops serving
110
+ * decisions in-session. Default 40.
111
+ */
112
+ maxOrphans?: number;
113
+ /** Setup turns per session (the first plus restarts after the root died). Default 2. */
114
+ maxSetups?: number;
115
+ onEvent?: (event: PrimedEvent) => void;
116
+ /** Setup progress: drives "setting up your thread" notices. */
117
+ onSetup?: (setup: InSessionSetup) => void;
118
+ }
119
+
120
+ export interface InSessionSetup {
121
+ readonly status: 'starting' | 'ready' | 'degraded';
122
+ readonly startedAt: number;
123
+ readonly readyAt?: number;
124
+ /** When the root started: what precedes it is the main session's own start and first turn. */
125
+ readonly rootStartedAt?: number;
126
+ readonly wardens: { readonly expected: number; readonly held: number; readonly failed: string[] };
127
+ readonly runtime: 'claude-in-session';
128
+ }
129
+
130
+ type Usage = { input: number; output: number; cacheRead: number; cacheWrite: number };
131
+ const zero = (): Usage => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
132
+
133
+ interface Starter {
134
+ readonly token: string;
135
+ readonly kind: 'root' | 'dispatch';
136
+ readonly prompt: string;
137
+ toolUseId?: string;
138
+ taskId?: string;
139
+ /** Its held `next` call. */
140
+ next?: { requestId: string; rpcId: unknown };
141
+ /** Lines waiting for its next `next`. */
142
+ lines: string[];
143
+ /** Its own context, from its latest usage. */
144
+ context: number;
145
+ stopping?: boolean;
146
+ ended?: boolean;
147
+ /** A dispatcher whose line went out to the root. */
148
+ issued?: boolean;
149
+ }
150
+
151
+ interface Member {
152
+ readonly spec: PrimeSpec;
153
+ readonly name: string;
154
+ request: DecisionRequest;
155
+ readonly started: number;
156
+ readonly deadline: number;
157
+ admissionId?: string;
158
+ admitted?: boolean;
159
+ spare?: Spare;
160
+ deliveredAt?: number;
161
+ settled: boolean;
162
+ done: Promise<void>;
163
+ resolve(result: DecisionResult): void;
164
+ reject(error: DecisionError): void;
165
+ }
166
+
167
+ interface Spare {
168
+ readonly token: string;
169
+ /** The role's definition name (hash of its composed text). */
170
+ readonly name: string;
171
+ readonly standbyHash: string;
172
+ readonly prompt: string;
173
+ readonly direct: boolean;
174
+ gen: Starter;
175
+ /** Its line went out in a `next` answer. */
176
+ issued?: boolean;
177
+ toolUseId?: string;
178
+ taskId?: string;
179
+ /** Its held `moment` call. */
180
+ held?: { requestId: string; rpcId: unknown };
181
+ heldAt?: number;
182
+ member?: Member;
183
+ violation?: string;
184
+ /**
185
+ * Its dispatcher ended unasked. It still serves, but it never ends: a nested agent whose parent
186
+ * is gone reports its end (completed or stopped) to the main thread, which wakes the main model
187
+ * (verified on Claude Code 2.1.300). So it is never retired or stopped, and its answer is held.
188
+ */
189
+ orphan?: boolean;
190
+ /** An orphan that answered: held in its `answer` call until the session ends. */
191
+ parked?: boolean;
192
+ /** Superseded by a newer generation or standby: retired once its replacement holds. */
193
+ superseded?: boolean;
194
+ retired?: boolean;
195
+ answered?: boolean;
196
+ done: boolean;
197
+ model?: string;
198
+ standby: Usage;
199
+ usage: Usage;
200
+ }
201
+
202
+ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionExtension {
203
+ readonly runtime = 'claude' as const;
204
+ readonly mcpServers = [IN_SESSION_SERVER] as const;
205
+ readonly agents: Readonly<Record<string, ClaudeAgentDefinition>>;
206
+ readonly env: Readonly<Record<string, string>>;
207
+ private readonly _config: ClaudeInSessionConfig &
208
+ Required<
209
+ Pick<
210
+ ClaudeInSessionConfig,
211
+ | 'sparesPerRole'
212
+ | 'timeoutMs'
213
+ | 'setupDeadlineMs'
214
+ | 'recycleTokens'
215
+ | 'maxOrphans'
216
+ | 'maxSetups'
217
+ >
218
+ >;
219
+ private _host?: ClaudeSessionHost;
220
+ private _closed = false;
221
+ private _seq = 0;
222
+ private _generation = 0;
223
+ private _root?: Starter;
224
+ private _gen?: Starter;
225
+ private readonly _gens = new Map<string, Starter>();
226
+ private readonly _spares = new Map<string, Spare>();
227
+ private readonly _members = new Set<Member>();
228
+ /** Spawns the CLI refused (its subagent cap): they go out again once one of ours ends. */
229
+ private _awaitingSlot: Spare[] = [];
230
+ /** Roles to keep pre-started: definition name → composed text and standby. */
231
+ private _desired = new Map<string, { composed: string; standbyHash: string }>();
232
+ private _setup: InSessionSetup;
233
+ private _setupTimer?: ReturnType<typeof setTimeout>;
234
+ private _settingUp = false;
235
+ /** A roster arrived (`prestart`): setup is ready once it holds. */
236
+ private _rostered = false;
237
+ /** Setup turns run so far (each one costs a main-model turn). */
238
+ private _setups = 0;
239
+ private _setupAgain = false;
240
+ /** Its session ended: a restarted process (`--resume`) is not set up again. */
241
+ private _detached = false;
242
+ /** Too many orphans: this session no longer serves decisions in-session. */
243
+ private _orphanedOut = false;
244
+ private _rootStartedAt?: number;
245
+ private _ready!: Promise<void>;
246
+ private _markReady!: () => void;
247
+
248
+ constructor(config: ClaudeInSessionConfig) {
249
+ this._config = {
250
+ ...config,
251
+ sparesPerRole: config.sparesPerRole ?? 1,
252
+ timeoutMs: config.timeoutMs ?? 30_000,
253
+ setupDeadlineMs: config.setupDeadlineMs ?? 30_000,
254
+ recycleTokens: config.recycleTokens ?? 100_000,
255
+ maxOrphans: config.maxOrphans ?? 40,
256
+ maxSetups: config.maxSetups ?? 2,
257
+ };
258
+ if (this._config.sparesPerRole < 1 || this._config.recycleTokens < 10_000)
259
+ throw Error('Invalid in-session decision bounds');
260
+ // On top of the user's own cap (Claude Code's default 20), so the main agent keeps its slots.
261
+ const own = Number(process.env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS) || 20;
262
+ this.env = { CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: String(own + (config.maxSubagents ?? 76)) };
263
+ const common = {
264
+ model: config.model,
265
+ ...(config.effort ? { effort: config.effort } : {}),
266
+ permissionMode: 'bypassPermissions',
267
+ omitClaudeMd: true,
268
+ };
269
+ // The main model sees these agent types listed; the hook refuses them from anywhere but here.
270
+ this.agents = {
271
+ [IN_SESSION_AGENTS.root]: {
272
+ description: 'Foundry internal. Never start it yourself.',
273
+ prompt: 'Foundry internal agent. Your task arrives as your first message.',
274
+ tools: ['Agent', NEXT],
275
+ background: true,
276
+ ...common,
277
+ },
278
+ [IN_SESSION_AGENTS.dispatch]: {
279
+ description: 'Foundry internal. Never start it yourself.',
280
+ prompt: 'Foundry internal agent. Your task arrives as your first message.',
281
+ tools: ['Agent', NEXT],
282
+ background: true,
283
+ ...common,
284
+ },
285
+ [IN_SESSION_AGENTS.member]: {
286
+ description: 'Foundry internal. Never start it yourself.',
287
+ prompt: "You are one of Foundry's Wardens. Your task arrives as your first message.",
288
+ tools: [MOMENT, ANSWER],
289
+ ...common,
290
+ },
291
+ };
292
+ this._setup = this._setupStatus('starting', Date.now());
293
+ this._resetReady();
294
+ }
295
+
296
+ /** Setup progress so far. */
297
+ get setup(): InSessionSetup {
298
+ return this._setup;
299
+ }
300
+
301
+ /** It can take decisions now: attached, not closed, and its root is running. */
302
+ serving(): boolean {
303
+ return (
304
+ !this._closed &&
305
+ !this._orphanedOut &&
306
+ !this._detached &&
307
+ !!this._host?.alive() &&
308
+ !!this._root?.taskId &&
309
+ !this._root.ended &&
310
+ // A dispatcher actually running (an old generation serves while a new one starts).
311
+ [...this._gens.values()].some((gen) => !!gen.taskId && !gen.ended)
312
+ );
313
+ }
314
+
315
+ /** Roles wanted, and how many hold a spare right now. */
316
+ holding(): { expected: number; held: number } {
317
+ const held = new Set(
318
+ [...this._spares.values()]
319
+ .filter((s) => s.held && !s.done && !s.retired && !s.superseded)
320
+ .map((s) => s.name),
321
+ );
322
+ return {
323
+ expected: this._desired.size,
324
+ held: [...this._desired.keys()].filter((n) => held.has(n)).length,
325
+ };
326
+ }
327
+
328
+ /** Resolves when setup is `ready` or `degraded`, or after `timeoutMs` at most. */
329
+ ready(timeoutMs = this._config.setupDeadlineMs): Promise<void> {
330
+ if (this._setup.status !== 'starting') return Promise.resolve();
331
+ let timer: ReturnType<typeof setTimeout> | undefined;
332
+ return Promise.race([
333
+ this._ready,
334
+ new Promise<void>((resolve) => {
335
+ timer = setTimeout(resolve, timeoutMs);
336
+ }),
337
+ ]).finally(() => clearTimeout(timer));
338
+ }
339
+
340
+ // -------------------------------------------------------------------------
341
+ // ClaudeSessionExtension
342
+ // -------------------------------------------------------------------------
343
+
344
+ attach(host: ClaudeSessionHost): void {
345
+ // A process restarted after its session ended (`--resume`) still answers the MCP connect, but
346
+ // nothing is set up in it again: whoever held this host already let it go.
347
+ this._host = host;
348
+ }
349
+
350
+ started(): void {
351
+ this._startSetup();
352
+ }
353
+
354
+ detach(reason: string): void {
355
+ this._detached = true;
356
+ this._host = undefined;
357
+ this._awaitingSlot = [];
358
+ this._desired = new Map();
359
+ clearTimeout(this._setupTimer);
360
+ const lost = () =>
361
+ new DecisionError(`Main session ended (${reason})`, 'transport', 'attempted', true);
362
+ for (const member of [...this._members]) {
363
+ if (member.deliveredAt) member.reject(lost());
364
+ else
365
+ member.reject(
366
+ new DecisionError(`Main session ended (${reason})`, 'transport', 'not-dispatched', true),
367
+ );
368
+ }
369
+ this._spares.clear();
370
+ this._gens.clear();
371
+ this._root = this._gen = undefined;
372
+ }
373
+
374
+ mcp(_server: string, requestId: string, message: Record<string, unknown>): void {
375
+ const reply = (m: unknown) => this._host?.mcpReply(requestId, m);
376
+ const id = message.id;
377
+ const text = (t: string, isError = false) =>
378
+ reply({
379
+ jsonrpc: '2.0',
380
+ id,
381
+ result: { ...(isError ? { isError: true } : {}), content: [{ type: 'text', text: t }] },
382
+ });
383
+ if (message.method === 'initialize') {
384
+ reply({
385
+ jsonrpc: '2.0',
386
+ id,
387
+ result: {
388
+ protocolVersion: (message.params as Record<string, unknown> | undefined)?.protocolVersion,
389
+ capabilities: { tools: {} },
390
+ serverInfo: { name: IN_SESSION_SERVER, version: '1' },
391
+ },
392
+ });
393
+ return;
394
+ }
395
+ if (message.method === 'tools/list') {
396
+ const token = { token: { type: 'string' } };
397
+ reply({
398
+ jsonrpc: '2.0',
399
+ id,
400
+ result: {
401
+ tools: [
402
+ {
403
+ name: 'next',
404
+ description: 'Wait for the agents to start.',
405
+ inputSchema: { type: 'object', properties: token, required: ['token'] },
406
+ },
407
+ {
408
+ name: 'moment',
409
+ description: 'Wait for the moment to assess. Its result is your input.',
410
+ inputSchema: { type: 'object', properties: token, required: ['token'] },
411
+ },
412
+ {
413
+ name: 'answer',
414
+ description: 'Give your whole answer.',
415
+ inputSchema: {
416
+ type: 'object',
417
+ properties: { ...token, text: { type: 'string' } },
418
+ required: ['token', 'text'],
419
+ },
420
+ },
421
+ ],
422
+ },
423
+ });
424
+ return;
425
+ }
426
+ if (message.method === 'tools/call') {
427
+ const params = (message.params ?? {}) as Record<string, unknown>;
428
+ const args = (params.arguments ?? {}) as Record<string, unknown>;
429
+ const token = String(args.token ?? '');
430
+ if (params.name === 'next') {
431
+ const starter = token === ROOT_TOKEN ? this._root : this._gens.get(token);
432
+ // A repeated call (the earlier one was abandoned) is held instead of it.
433
+ if (!starter || starter.ended) {
434
+ text('No agents for this token.', true);
435
+ return;
436
+ }
437
+ starter.next = { requestId, rpcId: id };
438
+ // Lines it was given and never started (skipped, or refused) go out again.
439
+ if (starter.kind === 'dispatch')
440
+ for (const spare of this._spares.values())
441
+ if (spare.gen === starter && spare.issued && !spare.toolUseId && !spare.done)
442
+ this._reissue(spare);
443
+ // A dispatcher the root was given and skipped goes out again.
444
+ if (starter.kind === 'root')
445
+ for (const gen of this._gens.values())
446
+ if (gen.issued && !gen.toolUseId && !gen.ended) {
447
+ gen.issued = false;
448
+ const line = `${IN_SESSION_AGENTS.dispatch} ${gen.token}`;
449
+ if (!starter.lines.includes(line)) starter.lines.push(line);
450
+ }
451
+ this._feed(starter);
452
+ if (starter.kind === 'dispatch') {
453
+ this._replenish();
454
+ this._checkSetup();
455
+ }
456
+ return;
457
+ }
458
+ if (params.name === 'moment') {
459
+ const spare = this._spares.get(token);
460
+ if (!spare || spare.done || spare.direct) {
461
+ text('No moment for this token.', true);
462
+ return;
463
+ }
464
+ // A retry after an interrupted call: the moment it was given goes out again.
465
+ const member = spare.member;
466
+ if (member?.deliveredAt && !member.settled && !spare.answered) {
467
+ text(member.request.input);
468
+ return;
469
+ }
470
+ // An earlier held call was abandoned: this one is held instead.
471
+ spare.held = { requestId, rpcId: id };
472
+ spare.heldAt = Date.now();
473
+ this._emit({ type: 'standby', key: spare.name, ...spare.standby });
474
+ if (spare.retired) {
475
+ this._retire(spare);
476
+ return;
477
+ }
478
+ this._deliver(spare);
479
+ this._retireSuperseded(spare);
480
+ this._checkSetup();
481
+ return;
482
+ }
483
+ if (params.name === 'answer') {
484
+ const spare = this._spares.get(token);
485
+ // An orphan (its dispatcher is gone) must never end: its end would reach the main thread.
486
+ // Its answer call is held, so it waits forever and never completes.
487
+ if (spare?.orphan) spare.parked = true;
488
+ else text('Recorded.');
489
+ if (!spare || spare.answered) return;
490
+ spare.answered = true;
491
+ const member = spare.member;
492
+ if (member?.deliveredAt && !member.settled)
493
+ this._settle(spare, member, String(args.text ?? ''));
494
+ return;
495
+ }
496
+ }
497
+ if (id === undefined) {
498
+ reply(null);
499
+ return;
500
+ }
501
+ reply({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found' } });
502
+ }
503
+
504
+ preToolUse(input: Record<string, unknown>): Record<string, unknown> | undefined {
505
+ const name = input.tool_name;
506
+ const agentId = typeof input.agent_id === 'string' ? input.agent_id : undefined;
507
+ const toolInput = (input.tool_input ?? {}) as Record<string, unknown>;
508
+ const type = toolInput.subagent_type;
509
+ const ours = agentId ? this._ownTask(agentId) : undefined;
510
+ const deny = (reason: string) => ({
511
+ hookEventName: 'PreToolUse',
512
+ permissionDecision: 'deny',
513
+ permissionDecisionReason: reason,
514
+ });
515
+ const allow = (updatedInput?: Record<string, unknown>) => ({
516
+ hookEventName: 'PreToolUse',
517
+ permissionDecision: 'allow',
518
+ ...(updatedInput ? { updatedInput } : {}),
519
+ });
520
+ if (name === 'Agent' || name === 'Task') {
521
+ const internal = Object.values(IN_SESSION_AGENTS).includes(type as never);
522
+ if (!internal) return ours ? deny('Not an agent this one may start.') : undefined;
523
+ const description = String(toolInput.description ?? '');
524
+ const pin = (prompt: string) =>
525
+ allow({ subagent_type: type, description, prompt, run_in_background: true });
526
+ if (type === IN_SESSION_AGENTS.root) {
527
+ const root = this._root;
528
+ if (agentId || !root || root.toolUseId || description !== ROOT_TOKEN)
529
+ return deny('Foundry starts this agent itself.');
530
+ root.toolUseId = String(input.tool_use_id ?? '');
531
+ return pin(root.prompt);
532
+ }
533
+ if (type === IN_SESSION_AGENTS.dispatch) {
534
+ const gen = this._gens.get(description);
535
+ if (!ours || ours !== this._root || !gen || gen.toolUseId)
536
+ return deny('Foundry starts this agent itself.');
537
+ gen.toolUseId = String(input.tool_use_id ?? '');
538
+ return pin(gen.prompt);
539
+ }
540
+ const spare = this._spares.get(description);
541
+ if (!ours || !spare || spare.gen !== ours || spare.toolUseId)
542
+ return deny('Foundry starts this agent itself.');
543
+ spare.toolUseId = String(input.tool_use_id ?? '');
544
+ return pin(spare.prompt);
545
+ }
546
+ // The main agent (or the user's own subagents) never stops or messages Foundry's agents.
547
+ if (!ours) {
548
+ const ids = this._taskIds();
549
+ const text = JSON.stringify(toolInput);
550
+ return ids.some((id) => text.includes(id))
551
+ ? deny('Foundry internal agent: not yours to stop or message.')
552
+ : undefined;
553
+ }
554
+ // Its agents may use only the host's own tools.
555
+ const starter = 'kind' in ours;
556
+ if ((starter ? [NEXT] : [MOMENT, ANSWER]).includes(String(name))) return allow();
557
+ if (!starter) ours.violation = `tool ${String(name)}`;
558
+ return deny('Not a tool this agent may use.');
559
+ }
560
+
561
+ permission(request: Record<string, unknown>): Record<string, unknown> | undefined {
562
+ const agentId = typeof request.agent_id === 'string' ? request.agent_id : undefined;
563
+ if (!agentId || !this._ownTask(agentId)) return undefined;
564
+ const name = String(request.tool_name ?? '');
565
+ return [NEXT, MOMENT, ANSWER, 'Agent'].includes(name)
566
+ ? { behavior: 'allow', updatedInput: request.input ?? {} }
567
+ : { behavior: 'deny', message: 'Not a tool this agent may use.' };
568
+ }
569
+
570
+ frame(raw: Record<string, unknown>): boolean {
571
+ const parent = typeof raw.parent_tool_use_id === 'string' ? raw.parent_tool_use_id : undefined;
572
+ if (
573
+ raw.type === 'system' &&
574
+ typeof raw.subtype === 'string' &&
575
+ raw.subtype.startsWith('task_')
576
+ ) {
577
+ const taskId = typeof raw.task_id === 'string' ? raw.task_id : undefined;
578
+ const toolUseId = typeof raw.tool_use_id === 'string' ? raw.tool_use_id : undefined;
579
+ const owner =
580
+ (toolUseId && this._byToolUse(toolUseId)) || (taskId && this._ownTask(taskId)) || undefined;
581
+ if (!owner) return false;
582
+ if (raw.subtype === 'task_started' && taskId) {
583
+ owner.taskId = taskId;
584
+ if (owner === this._root) this._rootStartedAt = Date.now();
585
+ if ('name' in owner && raw.prompt !== undefined && raw.prompt !== owner.prompt)
586
+ owner.violation = 'input was not the composed input';
587
+ }
588
+ if (raw.subtype === 'task_notification') {
589
+ this._ended(owner, String(raw.status ?? 'unknown'));
590
+ // A slot freed: one refused spawn goes out again.
591
+ const waiting = this._awaitingSlot.shift();
592
+ if (waiting) {
593
+ this._reissue(waiting);
594
+ if (this._gen) this._feed(this._gen);
595
+ }
596
+ }
597
+ return true;
598
+ }
599
+ if (!parent) return false;
600
+ const owner = this._byToolUse(parent);
601
+ if (!owner) return false;
602
+ const message = raw.message as Record<string, unknown> | undefined;
603
+ // A starter's Agent call the CLI refused (a concurrency cap, an error): its agent never started.
604
+ if (raw.type === 'user' && 'kind' in owner && Array.isArray(message?.content))
605
+ for (const block of message.content as Array<Record<string, unknown>>) {
606
+ if (block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue;
607
+ const spawned = this._byToolUse(block.tool_use_id);
608
+ const text = JSON.stringify(block.content ?? '');
609
+ if (spawned && !spawned.taskId && (block.is_error === true || !text.includes('launched')))
610
+ this._unstarted(spawned, owner);
611
+ }
612
+ const usage = message?.usage as Record<string, number> | undefined;
613
+ if (raw.type === 'assistant' && usage) {
614
+ const add = {
615
+ input: usage.input_tokens ?? 0,
616
+ output: usage.output_tokens ?? 0,
617
+ cacheRead: usage.cache_read_input_tokens ?? 0,
618
+ cacheWrite: usage.cache_creation_input_tokens ?? 0,
619
+ };
620
+ if ('kind' in owner) {
621
+ owner.context = add.input + add.cacheRead + add.cacheWrite;
622
+ if (
623
+ owner.kind === 'dispatch' &&
624
+ owner === this._gen &&
625
+ owner.context > this._config.recycleTokens
626
+ )
627
+ this._recycle();
628
+ } else {
629
+ const target = owner.member?.deliveredAt ? owner.usage : owner.standby;
630
+ for (const key of Object.keys(add) as Array<keyof Usage>) target[key] += add[key];
631
+ if (typeof message?.model === 'string') owner.model = message.model;
632
+ }
633
+ }
634
+ return true;
635
+ }
636
+
637
+ // -------------------------------------------------------------------------
638
+ // PrimedSessions
639
+ // -------------------------------------------------------------------------
640
+
641
+ limits() {
642
+ return undefined;
643
+ }
644
+
645
+ async readLimits() {
646
+ return undefined;
647
+ }
648
+
649
+ snapshot(): PrimedSnapshot {
650
+ return {
651
+ runtime: 'claude',
652
+ closed: this._closed,
653
+ generation: this._generation,
654
+ processAlive: !!this._host?.alive(),
655
+ sessions: this._gens.size,
656
+ active: [...this._members].filter((m) => m.deliveredAt && !m.settled).length,
657
+ waiting: [...this._members].filter((m) => !m.deliveredAt && !m.settled).length,
658
+ };
659
+ }
660
+
661
+ async evict(_key: string): Promise<void> {}
662
+
663
+ decide(spec: PrimeSpec, request: DecisionRequest): Promise<DecisionResult> {
664
+ if (this._closed || !this._host?.alive())
665
+ return Promise.reject(
666
+ new DecisionError('In-session decisions unavailable', 'closed', 'not-dispatched', true),
667
+ );
668
+ if (!spec.key || typeof spec.instructions !== 'string' || typeof request.input !== 'string')
669
+ return Promise.reject(
670
+ new DecisionError('Invalid decision request', 'admission', 'not-dispatched', true),
671
+ );
672
+ if (request.signal?.aborted)
673
+ return Promise.reject(
674
+ new DecisionError('Decision aborted before dispatch', 'aborted', 'not-dispatched', true),
675
+ );
676
+ const composed = this._compose(spec);
677
+ const name = `d-${hash(composed).slice(0, 16)}`;
678
+ const spare =
679
+ request.standby === undefined ? undefined : this._spareFor(name, hash(request.standby));
680
+ const started = Date.now();
681
+ let settle!: () => void;
682
+ const done = new Promise<void>((r) => {
683
+ settle = r;
684
+ });
685
+ return new Promise<DecisionResult>((resolve, reject) => {
686
+ const member: Member = {
687
+ spec,
688
+ name,
689
+ request,
690
+ started,
691
+ deadline: started + (request.timeoutMs ?? this._config.timeoutMs),
692
+ settled: false,
693
+ done,
694
+ resolve: (result) => {
695
+ if (member.settled) return;
696
+ member.settled = true;
697
+ clearTimeout(timer);
698
+ this._members.delete(member);
699
+ settle();
700
+ resolve(result);
701
+ },
702
+ reject: (error) => {
703
+ if (member.settled) return;
704
+ member.settled = true;
705
+ clearTimeout(timer);
706
+ this._members.delete(member);
707
+ settle();
708
+ reject(error);
709
+ },
710
+ };
711
+ this._members.add(member);
712
+ const timer = setTimeout(
713
+ () => void this._withdraw(member, 'timeout'),
714
+ member.deadline - started,
715
+ );
716
+ request.signal?.addEventListener('abort', () => void this._withdraw(member, 'aborted'), {
717
+ once: true,
718
+ });
719
+ if (spare) {
720
+ member.spare = spare;
721
+ spare.member = member;
722
+ void this._reserve(spare, member);
723
+ } else void this._direct(member, composed);
724
+ });
725
+ }
726
+
727
+ /**
728
+ * Keep these roles pre-started with this standby context (the thread as it now stands). Spares
729
+ * holding other context are retired once their replacements hold.
730
+ */
731
+ prestart(_group: string, entries: ReadonlyArray<{ spec: PrimeSpec; standby: string }>): void {
732
+ if (this._closed) return;
733
+ this._rostered = true;
734
+ this._desired = new Map(
735
+ entries.map((e) => {
736
+ const composed = this._compose(e.spec);
737
+ return [
738
+ `d-${hash(composed).slice(0, 16)}`,
739
+ { composed: `${composed}${e.standby}`, standbyHash: hash(e.standby) },
740
+ ];
741
+ }),
742
+ );
743
+ for (const spare of this._spares.values())
744
+ if (
745
+ !spare.direct &&
746
+ !spare.member &&
747
+ this._desired.get(spare.name)?.standbyHash !== spare.standbyHash
748
+ )
749
+ spare.superseded = true;
750
+ this._replenish();
751
+ this._checkSetup();
752
+ // A role no longer wanted holds a spare nobody replaces: retire it now.
753
+ for (const spare of this._spares.values())
754
+ if (spare.superseded && !this._desired.has(spare.name)) this._retire(spare);
755
+ }
756
+
757
+ async releaseGroup(_group: string): Promise<void> {
758
+ await this.close();
759
+ }
760
+
761
+ async close(): Promise<void> {
762
+ if (this._closed) return;
763
+ this._closed = true;
764
+ clearTimeout(this._setupTimer);
765
+ for (const member of [...this._members])
766
+ member.reject(
767
+ new DecisionError('In-session decisions closed', 'closed', 'not-dispatched', true),
768
+ );
769
+ this._desired = new Map();
770
+ // Drained the way a recycle is: members are retired (they end into their live dispatchers),
771
+ // and each dispatcher ends into the root once empty. The root is never ended: its end would
772
+ // reach the main thread, which may go on without in-session decisions.
773
+ for (const spare of this._spares.values()) this._retire(spare);
774
+ for (const gen of this._gens.values()) {
775
+ gen.stopping = true;
776
+ gen.lines = [];
777
+ this._endIfEmpty(gen);
778
+ }
779
+ }
780
+
781
+ // -------------------------------------------------------------------------
782
+
783
+ private _emit(event: PrimedEvent): void {
784
+ try {
785
+ this._config.onEvent?.(event);
786
+ } catch {
787
+ /* observers only */
788
+ }
789
+ }
790
+
791
+ private _compose(spec: PrimeSpec): string {
792
+ return [this._config.baseInstructions, spec.instructions, spec.context]
793
+ .filter(Boolean)
794
+ .join('\n\n');
795
+ }
796
+
797
+ private _setupStatus(
798
+ status: InSessionSetup['status'],
799
+ startedAt: number,
800
+ readyAt?: number,
801
+ ): InSessionSetup {
802
+ const held = new Set(
803
+ [...this._spares.values()]
804
+ .filter((s) => s.held && !s.done && !s.superseded && !s.retired)
805
+ .map((s) => s.name),
806
+ );
807
+ const expected = this._desired?.size ?? 0;
808
+ return {
809
+ status,
810
+ startedAt,
811
+ ...(readyAt ? { readyAt } : {}),
812
+ ...(this._rootStartedAt ? { rootStartedAt: this._rootStartedAt } : {}),
813
+ wardens: {
814
+ expected,
815
+ held: [...(this._desired?.keys() ?? [])].filter((n) => held.has(n)).length,
816
+ failed:
817
+ status === 'degraded'
818
+ ? [...(this._desired?.keys() ?? [])].filter((n) => !held.has(n))
819
+ : [],
820
+ },
821
+ runtime: 'claude-in-session',
822
+ };
823
+ }
824
+
825
+ private _publishSetup(status = this._setup.status): void {
826
+ const readyAt = status === 'starting' ? undefined : (this._setup.readyAt ?? Date.now());
827
+ this._setup = this._setupStatus(status, this._setup.startedAt, readyAt);
828
+ try {
829
+ this._config.onSetup?.(this._setup);
830
+ } catch {
831
+ /* observers only */
832
+ }
833
+ if (status !== 'starting') this._markReady();
834
+ }
835
+
836
+ private _resetReady(): void {
837
+ this._ready = new Promise<void>((resolve) => {
838
+ this._markReady = resolve;
839
+ });
840
+ }
841
+
842
+ /** The setup turn: the main model starts the root, which then serves everything else. */
843
+ private _startSetup(): void {
844
+ if (this._closed || this._detached || !this._host) return;
845
+ // A root that died while its setup turn is still running is set up again once that turn ends.
846
+ if (this._settingUp) {
847
+ this._setupAgain = true;
848
+ return;
849
+ }
850
+ // Each setup is a main-model turn: a root that keeps dying stops being restarted.
851
+ if (this._setups >= this._config.maxSetups) {
852
+ this._publishSetup('degraded');
853
+ return;
854
+ }
855
+ this._setups++;
856
+ this._settingUp = true;
857
+ this._setup = this._setupStatus('starting', Date.now());
858
+ this._resetReady();
859
+ this._publishSetup('starting');
860
+ this._root = {
861
+ token: ROOT_TOKEN,
862
+ kind: 'root',
863
+ prompt: STARTER(ROOT_TOKEN),
864
+ lines: [],
865
+ context: 0,
866
+ };
867
+ this._newGeneration();
868
+ clearTimeout(this._setupTimer);
869
+ this._setupTimer = setTimeout(() => {
870
+ if (this._setup.status === 'starting') this._publishSetup('degraded');
871
+ }, this._config.setupDeadlineMs);
872
+ void this._host
873
+ .sendInternal(SETUP)
874
+ .catch(() => undefined)
875
+ .finally(() => {
876
+ this._settingUp = false;
877
+ if (this._setupAgain) {
878
+ this._setupAgain = false;
879
+ this._startSetup();
880
+ return;
881
+ }
882
+ // The main model did not start the root: nothing in-session serves this thread.
883
+ if (!this._root?.toolUseId && this._setup.status === 'starting')
884
+ this._publishSetup('degraded');
885
+ });
886
+ }
887
+
888
+ private _checkSetup(): void {
889
+ if (this._setup.status !== 'starting') return;
890
+ const held = new Set(
891
+ [...this._spares.values()].filter((s) => s.held && !s.done && !s.retired).map((s) => s.name),
892
+ );
893
+ const all = [...this._desired.keys()].every((name) => held.has(name));
894
+ if (this._rostered && all && this._gen?.next) {
895
+ clearTimeout(this._setupTimer);
896
+ this._publishSetup('ready');
897
+ } else this._publishSetup('starting');
898
+ }
899
+
900
+ private _newGeneration(): Starter {
901
+ const token = `g${++this._generation}`;
902
+ const gen: Starter = { token, kind: 'dispatch', prompt: STARTER(token), lines: [], context: 0 };
903
+ this._gens.set(token, gen);
904
+ this._gen = gen;
905
+ this._root!.lines.push(`${IN_SESSION_AGENTS.dispatch} ${token}`);
906
+ this._feed(this._root!);
907
+ return gen;
908
+ }
909
+
910
+ /** A dispatcher past its bound: a new generation takes new spares; the old one's are retired as replacements hold. */
911
+ private _recycle(): void {
912
+ const old = this._gen;
913
+ if (!old || old.stopping || !this._root?.taskId) return;
914
+ old.stopping = true;
915
+ this._emit({
916
+ type: 'process-recycled',
917
+ generation: this._generation,
918
+ reason: 'dispatcher context bound',
919
+ });
920
+ for (const spare of this._spares.values())
921
+ if (spare.gen === old && !spare.member && !spare.done) spare.superseded = true;
922
+ this._newGeneration();
923
+ this._replenish();
924
+ this._endIfEmpty(old);
925
+ }
926
+
927
+ /** Answer a starter's held `next` with what it should start (or stop). */
928
+ private _feed(starter: Starter): void {
929
+ if (!starter.next) return;
930
+ let text: string | undefined;
931
+ if (starter.lines.length) {
932
+ const lines = starter.lines.splice(0);
933
+ for (const line of lines) {
934
+ const token = line.split(' ')[1] ?? '';
935
+ const spawned = this._spares.get(token) ?? this._gens.get(token);
936
+ if (spawned) spawned.issued = true;
937
+ }
938
+ text = lines.join('\n');
939
+ } else if (starter.stopping && starter.kind === 'dispatch' && this._empty(starter)) text = STOP;
940
+ if (!text) return;
941
+ const { requestId, rpcId } = starter.next;
942
+ starter.next = undefined;
943
+ this._host?.mcpReply(requestId, {
944
+ jsonrpc: '2.0',
945
+ id: rpcId,
946
+ result: { content: [{ type: 'text', text }] },
947
+ });
948
+ }
949
+
950
+ private _empty(gen: Starter): boolean {
951
+ return ![...this._spares.values()].some((s) => s.gen === gen && !s.done);
952
+ }
953
+
954
+ private _endIfEmpty(gen: Starter): void {
955
+ if (gen.stopping && this._empty(gen)) this._feed(gen);
956
+ }
957
+
958
+ /** Stop a starter at close: its next call ends it. */
959
+ private _stop(starter: Starter): void {
960
+ starter.stopping = true;
961
+ starter.lines = [];
962
+ if (starter.next) {
963
+ const { requestId, rpcId } = starter.next;
964
+ starter.next = undefined;
965
+ this._host?.mcpReply(requestId, {
966
+ jsonrpc: '2.0',
967
+ id: rpcId,
968
+ result: { content: [{ type: 'text', text: STOP }] },
969
+ });
970
+ }
971
+ }
972
+
973
+ private _taskIds(): string[] {
974
+ const ids: string[] = [];
975
+ if (this._root?.taskId) ids.push(this._root.taskId);
976
+ for (const gen of this._gens.values()) if (gen.taskId) ids.push(gen.taskId);
977
+ for (const spare of this._spares.values()) if (spare.taskId) ids.push(spare.taskId);
978
+ return ids;
979
+ }
980
+
981
+ private _ownTask(taskId: string): Starter | Spare | undefined {
982
+ if (this._root?.taskId === taskId) return this._root;
983
+ for (const gen of this._gens.values()) if (gen.taskId === taskId) return gen;
984
+ for (const spare of this._spares.values()) if (spare.taskId === taskId) return spare;
985
+ return undefined;
986
+ }
987
+
988
+ private _byToolUse(id: string): Starter | Spare | undefined {
989
+ if (this._root?.toolUseId === id) return this._root;
990
+ for (const gen of this._gens.values()) if (gen.toolUseId === id) return gen;
991
+ for (const spare of this._spares.values()) if (spare.toolUseId === id) return spare;
992
+ return undefined;
993
+ }
994
+
995
+ private _spareFor(name: string, standbyHash: string): Spare | undefined {
996
+ let best: Spare | undefined;
997
+ for (const spare of this._spares.values()) {
998
+ if (spare.direct || spare.name !== name || spare.standbyHash !== standbyHash) continue;
999
+ if (spare.member || spare.done || spare.violation || spare.retired) continue;
1000
+ if (
1001
+ !best ||
1002
+ (spare.held && !best.held) ||
1003
+ (!!spare.held === !!best.held && !spare.superseded && best.superseded)
1004
+ )
1005
+ best = spare;
1006
+ }
1007
+ return best;
1008
+ }
1009
+
1010
+ /** Keep `sparesPerRole` free spares of every desired role, in the current generation. */
1011
+ private _replenish(): void {
1012
+ const gen = this._gen;
1013
+ if (this._closed || !gen || gen.stopping) return;
1014
+ const free = new Map<string, number>();
1015
+ for (const spare of this._spares.values())
1016
+ if (!spare.direct && !spare.member && !spare.done && !spare.superseded && !spare.retired)
1017
+ if (this._desired.get(spare.name)?.standbyHash === spare.standbyHash)
1018
+ free.set(spare.name, (free.get(spare.name) ?? 0) + 1);
1019
+ for (const [name, desired] of this._desired)
1020
+ for (let n = free.get(name) ?? 0; n < this._config.sparesPerRole; n++) {
1021
+ const token = `w${++this._seq}`;
1022
+ this._spares.set(token, {
1023
+ token,
1024
+ name,
1025
+ standbyHash: desired.standbyHash,
1026
+ prompt: standbyPrompt(desired.composed, token),
1027
+ direct: false,
1028
+ gen,
1029
+ done: false,
1030
+ standby: zero(),
1031
+ usage: zero(),
1032
+ });
1033
+ gen.lines.push(`${IN_SESSION_AGENTS.member} ${token}`);
1034
+ }
1035
+ this._feed(gen);
1036
+ }
1037
+
1038
+ /** A spare's replacement holds: retire one superseded spare of the same role. */
1039
+ private _retireSuperseded(holder: Spare): void {
1040
+ if (holder.superseded || holder.direct) return;
1041
+ for (const spare of this._spares.values())
1042
+ if (
1043
+ spare !== holder &&
1044
+ spare.name === holder.name &&
1045
+ spare.superseded &&
1046
+ !spare.member &&
1047
+ !spare.retired
1048
+ ) {
1049
+ this._retire(spare);
1050
+ return;
1051
+ }
1052
+ }
1053
+
1054
+ /** End an unused spare: its held moment gets no input (or its moment, when it calls). */
1055
+ private _retire(spare: Spare): void {
1056
+ if (spare.member && !spare.member.settled) return;
1057
+ spare.retired = true;
1058
+ // An orphan's moment stays held: answering it would end it into the main thread.
1059
+ if (spare.orphan) return;
1060
+ if (!spare.held) return;
1061
+ const { requestId, rpcId } = spare.held;
1062
+ spare.held = undefined;
1063
+ this._host?.mcpReply(requestId, {
1064
+ jsonrpc: '2.0',
1065
+ id: rpcId,
1066
+ result: { content: [{ type: 'text', text: RETIRED }] },
1067
+ });
1068
+ }
1069
+
1070
+ private async _admit(member: Member): Promise<boolean> {
1071
+ member.admissionId ??= crypto.randomUUID();
1072
+ if (member.admitted) return true;
1073
+ try {
1074
+ await Promise.race([
1075
+ Promise.resolve(
1076
+ member.request.onAdmission?.({
1077
+ admissionId: member.admissionId,
1078
+ key: member.spec.key,
1079
+ runtime: 'claude',
1080
+ }),
1081
+ ),
1082
+ member.done,
1083
+ ]);
1084
+ } catch {
1085
+ member.reject(
1086
+ new DecisionError(
1087
+ 'Decision admission refused before dispatch',
1088
+ 'admission',
1089
+ 'not-dispatched',
1090
+ true,
1091
+ member.admissionId,
1092
+ ),
1093
+ );
1094
+ return false;
1095
+ }
1096
+ if (member.settled) return false;
1097
+ member.admitted = true;
1098
+ return true;
1099
+ }
1100
+
1101
+ private async _reserve(spare: Spare, member: Member): Promise<void> {
1102
+ if (!(await this._admit(member))) {
1103
+ if (spare.member === member) spare.member = undefined;
1104
+ return;
1105
+ }
1106
+ if (spare.member !== member) return;
1107
+ if (spare.held) {
1108
+ this._deliver(spare);
1109
+ return;
1110
+ }
1111
+ setTimeout(() => {
1112
+ if (spare.member === member && !member.deliveredAt && !member.settled) this._fallback(member);
1113
+ }, RESERVE_START_MS);
1114
+ }
1115
+
1116
+ /** A reserved decision its spare cannot serve in time: it starts its own member. */
1117
+ private _fallback(member: Member): void {
1118
+ const spare = member.spare;
1119
+ if (!spare || member.settled || member.deliveredAt) return;
1120
+ if (spare.member === member) spare.member = undefined;
1121
+ member.spare = undefined;
1122
+ void this._direct(member, this._compose(member.spec));
1123
+ }
1124
+
1125
+ private _deliver(spare: Spare): void {
1126
+ const member = spare.member;
1127
+ if (!member || member.settled || member.deliveredAt || !member.admitted || !spare.held) return;
1128
+ if (spare.violation) {
1129
+ this._fallback(member);
1130
+ return;
1131
+ }
1132
+ member.deliveredAt = Date.now();
1133
+ const { requestId, rpcId } = spare.held;
1134
+ spare.held = undefined;
1135
+ this._host?.mcpReply(requestId, {
1136
+ jsonrpc: '2.0',
1137
+ id: rpcId,
1138
+ result: { content: [{ type: 'text', text: member.request.input }] },
1139
+ });
1140
+ // The spare is used: start its replacement now.
1141
+ this._replenish();
1142
+ }
1143
+
1144
+ /** A decision with no spare: a member started with its whole input. */
1145
+ private async _direct(member: Member, composed: string): Promise<void> {
1146
+ if (!(await this._admit(member))) return;
1147
+ const gen = this._gen;
1148
+ if (!gen) {
1149
+ member.reject(
1150
+ new DecisionError(
1151
+ 'No dispatcher running',
1152
+ 'transport',
1153
+ 'not-dispatched',
1154
+ true,
1155
+ member.admissionId,
1156
+ ),
1157
+ );
1158
+ return;
1159
+ }
1160
+ const token = `w${++this._seq}`;
1161
+ const input = `${member.request.standby ?? ''}${member.request.input}`;
1162
+ const spare: Spare = {
1163
+ token,
1164
+ name: member.name,
1165
+ standbyHash: '',
1166
+ prompt: directPrompt(`${composed}${input}`, token),
1167
+ direct: true,
1168
+ gen,
1169
+ member,
1170
+ done: false,
1171
+ standby: zero(),
1172
+ usage: zero(),
1173
+ };
1174
+ member.spare = spare;
1175
+ // Written when the dispatcher takes it: delivered from here on.
1176
+ member.deliveredAt = Date.now();
1177
+ this._spares.set(token, spare);
1178
+ gen.lines.push(`${IN_SESSION_AGENTS.member} ${token}`);
1179
+ this._feed(gen);
1180
+ }
1181
+
1182
+ private _settle(spare: Spare, member: Member, content: string): void {
1183
+ if (spare.violation) {
1184
+ this._emit({ type: 'violation', key: member.spec.key, detail: spare.violation });
1185
+ member.reject(
1186
+ new DecisionError(
1187
+ `Decision violated the decision policy (${spare.violation})`,
1188
+ 'violation',
1189
+ 'attempted',
1190
+ true,
1191
+ member.admissionId,
1192
+ ),
1193
+ );
1194
+ return;
1195
+ }
1196
+ const now = Date.now();
1197
+ this._emit({
1198
+ type: 'decision',
1199
+ key: member.spec.key,
1200
+ prime: spare.direct ? 'cold' : 'warm',
1201
+ ms: now - member.started,
1202
+ cacheRead: spare.usage.cacheRead,
1203
+ });
1204
+ member.resolve({
1205
+ key: member.spec.key,
1206
+ admissionId: member.admissionId!,
1207
+ content,
1208
+ tokens: { ...spare.usage },
1209
+ ...(spare.model ? { model: spare.model } : {}),
1210
+ ...(spare.taskId ? { threadId: spare.taskId } : {}),
1211
+ ...(spare.toolUseId ? { turnId: spare.toolUseId } : {}),
1212
+ prime: spare.direct ? 'cold' : 'warm',
1213
+ timing: {
1214
+ waitMs: (member.deliveredAt ?? now) - member.started,
1215
+ primeMs: 0,
1216
+ branchMs: 0,
1217
+ turnMs: now - (member.deliveredAt ?? member.started),
1218
+ },
1219
+ });
1220
+ }
1221
+
1222
+ /** A task of ours ended. */
1223
+ private _ended(owner: Starter | Spare, status: string): void {
1224
+ if ('kind' in owner) {
1225
+ owner.ended = true;
1226
+ owner.next = undefined;
1227
+ if (owner.kind === 'root') {
1228
+ // The root ended unasked (that end reached the main thread once). Its dispatchers stay
1229
+ // blocked in `next` for good; their members are orphans; setup runs again.
1230
+ if (this._closed) return;
1231
+ const orphaned = [...this._spares.values()].filter((sp) => !sp.done);
1232
+ this._gens.clear();
1233
+ this._gen = undefined;
1234
+ this._root = undefined;
1235
+ this._publishSetup('degraded');
1236
+ this._startSetup();
1237
+ for (const spare of orphaned) this._orphan(spare);
1238
+ this._replenish();
1239
+ return;
1240
+ }
1241
+ // Its members outlive it (nested background agents are not stopped with their parent).
1242
+ this._gens.delete(owner.token);
1243
+ const planned = owner.stopping && this._empty(owner);
1244
+ if (owner === this._gen && !this._closed) this._newGeneration();
1245
+ if (!planned && !this._closed)
1246
+ for (const spare of this._spares.values())
1247
+ if (spare.gen === owner && !spare.done) this._orphan(spare);
1248
+ this._replenish();
1249
+ return;
1250
+ }
1251
+ this._lost(owner, status);
1252
+ this._replenish();
1253
+ }
1254
+
1255
+ /**
1256
+ * A member whose dispatcher ended unasked: one that never started goes to the current
1257
+ * dispatcher; one that runs is an orphan. Too many orphans and the session stops serving
1258
+ * in-session (its caller falls back), since each holds a subagent slot until the session ends.
1259
+ */
1260
+ private _orphan(spare: Spare): void {
1261
+ if (!spare.toolUseId) {
1262
+ this._reissue(spare);
1263
+ return;
1264
+ }
1265
+ if (spare.orphan) return;
1266
+ spare.orphan = true;
1267
+ const orphans = [...this._spares.values()].filter((sp) => sp.orphan && !sp.done).length;
1268
+ if (orphans > this._config.maxOrphans && !this._orphanedOut) {
1269
+ this._orphanedOut = true;
1270
+ this._emit({
1271
+ type: 'process-recycled',
1272
+ generation: this._generation,
1273
+ reason: `${orphans} orphaned members held; in-session decisions stop for this session`,
1274
+ });
1275
+ this._publishSetup('degraded');
1276
+ }
1277
+ }
1278
+
1279
+ /** A spawn that never started: its line goes out again. */
1280
+ private _unstarted(spawned: Starter | Spare, by: Starter): void {
1281
+ spawned.toolUseId = undefined;
1282
+ if ('kind' in spawned) {
1283
+ // A dispatcher the root could not start: ask again.
1284
+ if (!this._closed && this._gens.has(spawned.token))
1285
+ by.lines.push(`${IN_SESSION_AGENTS.dispatch} ${spawned.token}`);
1286
+ return;
1287
+ }
1288
+ spawned.issued = false;
1289
+ if (!this._awaitingSlot.includes(spawned)) this._awaitingSlot.push(spawned);
1290
+ // A reserved decision does not wait for a slot: it fans out like any other.
1291
+ if (spawned.member && !spawned.member.deliveredAt && !spawned.direct)
1292
+ this._fallback(spawned.member);
1293
+ }
1294
+
1295
+ private _reissue(spare: Spare): void {
1296
+ const gen = this._gen;
1297
+ if (this._closed || !gen || spare.done) return;
1298
+ spare.gen = gen;
1299
+ spare.issued = false;
1300
+ const line = `${IN_SESSION_AGENTS.member} ${spare.token}`;
1301
+ if (!gen.lines.includes(line)) gen.lines.push(line);
1302
+ }
1303
+
1304
+ private _lost(spare: Spare, status: string): void {
1305
+ spare.done = true;
1306
+ spare.held = undefined;
1307
+ this._spares.delete(spare.token);
1308
+ const member = spare.member;
1309
+ if (!member || member.settled) {
1310
+ this._endIfEmpty(spare.gen);
1311
+ return;
1312
+ }
1313
+ if (!member.deliveredAt && !spare.direct) this._fallback(member);
1314
+ else
1315
+ member.reject(
1316
+ new DecisionError(
1317
+ `Subagent ended without an answer (${status})`,
1318
+ spare.violation ? 'violation' : 'native-failed',
1319
+ 'attempted',
1320
+ true,
1321
+ member.admissionId,
1322
+ ),
1323
+ );
1324
+ this._endIfEmpty(spare.gen);
1325
+ }
1326
+
1327
+ /** Past its deadline or aborted: refused unsent, or rejected and its member stopped. */
1328
+ private async _withdraw(member: Member, reason: 'timeout' | 'aborted'): Promise<void> {
1329
+ if (member.settled) return;
1330
+ const message = reason === 'timeout' ? 'Decision deadline passed' : 'Decision aborted';
1331
+ const spare = member.spare;
1332
+ if (!member.deliveredAt) {
1333
+ if (spare && spare.member === member) spare.member = undefined;
1334
+ member.reject(
1335
+ new DecisionError(
1336
+ `${message} before dispatch`,
1337
+ reason,
1338
+ 'not-dispatched',
1339
+ true,
1340
+ member.admissionId,
1341
+ ),
1342
+ );
1343
+ return;
1344
+ }
1345
+ member.reject(
1346
+ new DecisionError(
1347
+ `${message}; subagent being stopped`,
1348
+ reason,
1349
+ 'attempted',
1350
+ false,
1351
+ member.admissionId,
1352
+ ),
1353
+ );
1354
+ if (!spare) return;
1355
+ // A direct member not yet started never starts.
1356
+ if (!spare.toolUseId) {
1357
+ spare.done = true;
1358
+ this._spares.delete(spare.token);
1359
+ spare.gen.lines = spare.gen.lines.filter((l) => !l.endsWith(` ${spare.token}`));
1360
+ return;
1361
+ }
1362
+ // A member is a leaf: stopping it ends into its dispatcher, never the main thread. An orphan
1363
+ // has no dispatcher, so it is left as it is.
1364
+ if (spare.taskId && !spare.orphan && this._host?.alive())
1365
+ await this._host
1366
+ .control({ subtype: 'stop_task', task_id: spare.taskId }, 2_000)
1367
+ .catch(() => undefined);
1368
+ }
1369
+ }