@inixiative/agent-session 0.6.3 → 0.6.5

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.
package/README.md CHANGED
@@ -114,6 +114,30 @@ session.onToolRequest(async (call) => {
114
114
  A turn timeout does not withdraw a pending Claude ask; `interruptNative()` does.
115
115
  - A fork starts with a snapshot of the parent's gates.
116
116
 
117
+ ## Subagents
118
+
119
+ A subagent's activity reaches `onEvent`, but never becomes part of a turn:
120
+
121
+ - **Outside every admission.** Every subagent event, lifecycle included,
122
+ carries `unattributedReason: "subagent"`. It is never in a turn's
123
+ `result.events` and never its `content`. This holds whichever turn is in
124
+ flight, because a background subagent outlives the turn that started it.
125
+ - **`parentToolUseId`** names the parent's tool call that spawned it. On
126
+ Claude this is the frame's `parent_tool_use_id`, with the native envelope
127
+ kept in `raw`. On Codex it is the collab `spawnAgent` item.
128
+ - **`kind: "subagent"`** carries its lifecycle in `subagent`:
129
+ - `phase`: started, progress, updated or ended;
130
+ - identity and shape: `taskId`, `toolUseId`, `parentTaskId`, `agentType`,
131
+ `depth`, `backgrounded`;
132
+ - outcome: `status`, `summary`, `prompt`, and running `usage`.
133
+ - **Claude** builds the lifecycle from its `system` task frames for
134
+ `task_type: "local_agent"`. Background shells, monitors and workflows are
135
+ tasks too, and they are not reported as subagents.
136
+ - **Codex** reports `started` on the spawn and `ended` on `closeAgent`, plus
137
+ `updated` (with the turn's status) as each child turn ends. Child threads
138
+ carry their final messages as text; their other items stay diagnostics.
139
+ Approval requests from child threads are not routed to gates yet.
140
+
117
141
  ## Primed decision sessions
118
142
 
119
143
  One live, warm session per middleware role, primed once with its role
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/agent-session",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "description": "Drive coding-agent CLIs as persistent, streaming, event-captured sessions over interchangeable transports, with primed decision sessions, subscription pools, routing, limit polling and continuity.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@biomejs/biome": "2.5.2",
43
- "@inixiative/config": "^0.5.78",
43
+ "@inixiative/config": "^0.5.83",
44
44
  "@types/bun": "1.4.2",
45
45
  "lefthook": "2.1.9",
46
46
  "typescript": "6.0.3"
@@ -57,6 +57,7 @@ import type {
57
57
  SessionResult,
58
58
  SessionSendOptions,
59
59
  SessionTokens,
60
+ SubagentLifecycle,
60
61
  ToolDecision,
61
62
  ToolGate,
62
63
  ToolRequest,
@@ -314,6 +315,8 @@ export class ClaudeCodeSession implements HarnessSession {
314
315
  * are distinct compactions.
315
316
  */
316
317
  private _seenBoundaries = new Set<string>();
318
+ /** Task ids whose task_started was a subagent (local_agent); later task frames carry no type. */
319
+ private _agentTasks = new Set<string>();
317
320
 
318
321
  // -- Turn queue --
319
322
  private _queue: QueuedTurn[] = [];
@@ -1104,6 +1107,34 @@ export class ClaudeCodeSession implements HarnessSession {
1104
1107
  return;
1105
1108
  }
1106
1109
 
1110
+ // A subagent's frames and lifecycle are never part of an admission, whichever turn is in flight
1111
+ // (a background subagent outlives the turn that started it): they reach handlers tagged
1112
+ // `subagent`, with the spawning call in `parentToolUseId`.
1113
+ const parentToolUseId =
1114
+ typeof raw.parent_tool_use_id === 'string' && raw.parent_tool_use_id
1115
+ ? raw.parent_tool_use_id
1116
+ : undefined;
1117
+ const lifecycle = this._subagentLifecycle(raw);
1118
+ if (parentToolUseId || lifecycle) {
1119
+ const message = raw.message as Record<string, unknown> | undefined;
1120
+ const events: SessionEvent[] = lifecycle
1121
+ ? [{ kind: 'subagent', timestamp: Date.now(), subagent: lifecycle, raw }]
1122
+ : this._classify(raw, parentToolUseId);
1123
+ if (!events.length) {
1124
+ this._unattributed(raw, 'subagent');
1125
+ return;
1126
+ }
1127
+ for (const event of events)
1128
+ this._emit({
1129
+ ...event,
1130
+ correlation: 'unknown',
1131
+ unattributedReason: 'subagent',
1132
+ nativeSessionId: typeof raw.session_id === 'string' ? raw.session_id : undefined,
1133
+ messageId: typeof message?.id === 'string' ? message.id : undefined,
1134
+ });
1135
+ return;
1136
+ }
1137
+
1107
1138
  const turn = this._inflight;
1108
1139
  if (!turn) {
1109
1140
  if (terminalKey) this._seenTerminals.add(terminalKey);
@@ -1373,7 +1404,34 @@ export class ClaudeCodeSession implements HarnessSession {
1373
1404
  // Private — event classification
1374
1405
  // ---------------------------------------------------------------------------
1375
1406
 
1376
- private _classify(msg: Record<string, unknown>): SessionEvent[] {
1407
+ /**
1408
+ * A Claude Code `system` task frame for a subagent (`task_type: local_agent`), as lifecycle. Other
1409
+ * task types (a background shell, a monitor, a workflow) are not subagents and return nothing.
1410
+ * Later phases carry no task type, so the session remembers which task ids are subagents.
1411
+ */
1412
+ private _subagentLifecycle(msg: Record<string, unknown>): SubagentLifecycle | undefined {
1413
+ if (msg.type !== 'system' || typeof msg.subtype !== 'string') return undefined;
1414
+ const phase = CLAUDE_TASK_PHASES[msg.subtype];
1415
+ const taskId = typeof msg.task_id === 'string' && msg.task_id ? msg.task_id : undefined;
1416
+ if (!phase || !taskId) return undefined;
1417
+ if (phase === 'started') {
1418
+ const agent =
1419
+ msg.task_type === 'local_agent' ||
1420
+ (msg.task_type === undefined && typeof msg.subagent_type === 'string');
1421
+ if (!agent) return undefined;
1422
+ this._agentTasks.add(taskId);
1423
+ } else if (!this._agentTasks.has(taskId)) return undefined;
1424
+ if (phase === 'ended') this._agentTasks.delete(taskId);
1425
+ return claudeSubagentLifecycle(msg, phase, taskId);
1426
+ }
1427
+
1428
+ private _classify(msg: Record<string, unknown>, parentToolUseId?: string): SessionEvent[] {
1429
+ return this._classifyFrame(msg).map((event) =>
1430
+ parentToolUseId ? { ...event, parentToolUseId } : event,
1431
+ );
1432
+ }
1433
+
1434
+ private _classifyFrame(msg: Record<string, unknown>): SessionEvent[] {
1377
1435
  const events: SessionEvent[] = [];
1378
1436
  const ts = Date.now();
1379
1437
 
@@ -1558,3 +1616,51 @@ function processAlive(pid: number): boolean {
1558
1616
  return (error as NodeJS.ErrnoException).code === 'EPERM';
1559
1617
  }
1560
1618
  }
1619
+
1620
+ const CLAUDE_TASK_PHASES: Readonly<Record<string, SubagentLifecycle['phase']>> = {
1621
+ task_started: 'started',
1622
+ task_progress: 'progress',
1623
+ task_updated: 'updated',
1624
+ task_notification: 'ended',
1625
+ };
1626
+
1627
+ function claudeSubagentLifecycle(
1628
+ msg: Record<string, unknown>,
1629
+ phase: SubagentLifecycle['phase'],
1630
+ taskId: string,
1631
+ ): SubagentLifecycle {
1632
+ const text = (value: unknown) => (typeof value === 'string' && value ? value : undefined);
1633
+ const record = (value: unknown) =>
1634
+ value && typeof value === 'object' && !Array.isArray(value)
1635
+ ? (value as Record<string, unknown>)
1636
+ : undefined;
1637
+ const patch = record(msg.patch);
1638
+ const usage = record(msg.usage);
1639
+ const status = text(msg.status) ?? text(patch?.status);
1640
+ const count = (value: unknown) =>
1641
+ typeof value === 'number' && Number.isFinite(value) ? value : undefined;
1642
+ return {
1643
+ phase,
1644
+ taskId,
1645
+ ...(text(msg.tool_use_id) ? { toolUseId: text(msg.tool_use_id) } : {}),
1646
+ ...(text(msg.parent_task_id) ? { parentTaskId: text(msg.parent_task_id) } : {}),
1647
+ ...(text(msg.subagent_type) ? { agentType: text(msg.subagent_type) } : {}),
1648
+ ...(text(msg.description) ? { description: text(msg.description) } : {}),
1649
+ ...(typeof msg.spawn_depth === 'number' ? { depth: msg.spawn_depth } : {}),
1650
+ ...(typeof msg.is_backgrounded === 'boolean' ? { backgrounded: msg.is_backgrounded } : {}),
1651
+ ...(status ? { status } : {}),
1652
+ ...(text(msg.summary) ? { summary: text(msg.summary) } : {}),
1653
+ ...(text(msg.prompt) ? { prompt: text(msg.prompt) } : {}),
1654
+ ...(usage && count(usage.total_tokens) !== undefined
1655
+ ? {
1656
+ usage: {
1657
+ totalTokens: count(usage.total_tokens)!,
1658
+ ...(count(usage.tool_uses) !== undefined ? { toolUses: count(usage.tool_uses) } : {}),
1659
+ ...(count(usage.duration_ms) !== undefined
1660
+ ? { durationMs: count(usage.duration_ms) }
1661
+ : {}),
1662
+ },
1663
+ }
1664
+ : {}),
1665
+ };
1666
+ }
@@ -30,6 +30,15 @@
30
30
  // Each spare serves one decision; `prestart` with new standby
31
31
  // context replaces the spares (a turn landed), and a consumed one
32
32
  // is pre-started again.
33
+ // pairs each role keeps `sparesPerRole` spares (default 2), each copy in
34
+ // a different standby side session. A decision takes the spare
35
+ // whose side session is already most used, so a trigger empties
36
+ // whole side sessions; a side session whose spares were partly
37
+ // used ends its standby turn once nothing it serves is in flight,
38
+ // and is pre-started again while the other copy serves.
39
+ // sides standby side sessions hold spares only; fan-outs (decisions with
40
+ // no spare) run in their own side sessions, so they never take a
41
+ // spare's place.
33
42
  //
34
43
  // Every member is a fresh subagent conversation: nothing of one decision
35
44
  // reaches another. Groups above `maxMembers` split evenly across more side
@@ -80,10 +89,12 @@ export interface ClaudeSubagentConfig {
80
89
  argv: string[],
81
90
  options: { cwd: string; env: Record<string, string | undefined> },
82
91
  ) => PipedSubprocess;
83
- /** Members per fan-out; a larger batch splits evenly across side sessions. Default 12. */
92
+ /** Members per fan-out or standby turn; a larger set splits evenly across side sessions. Default 12. */
84
93
  maxMembers?: number;
85
- /** Side sessions per group (pre-started spares hold some). Default 6. */
94
+ /** Fan-out side sessions per group. Standby side sessions are sized by the spares they hold. Default 6. */
86
95
  maxSessionsPerGroup?: number;
96
+ /** Pre-started spares kept per role, each copy in its own standby side session. Default 2. */
97
+ sparesPerRole?: number;
87
98
  /** Decisions arriving within this window of each other fan out together. Default 25 ms. */
88
99
  batchWindowMs?: number;
89
100
  /** Close a side session idle this long. Default 30 min. */
@@ -144,6 +155,8 @@ interface Spare {
144
155
 
145
156
  interface Side {
146
157
  readonly group: string;
158
+ /** Holds pre-started spares; never takes a fan-out. */
159
+ readonly standby: boolean;
147
160
  readonly names: ReadonlySet<string>;
148
161
  readonly proc: PipedSubprocess;
149
162
  readonly dir: string;
@@ -162,6 +175,8 @@ interface Side {
162
175
  spares: Map<string, Spare>;
163
176
  /** Its standby context was replaced: end its standby turn once no decision uses it. */
164
177
  retire?: boolean;
178
+ /** Its standby turn is being ended; it is pre-started again once rewound. */
179
+ ending?: boolean;
165
180
  onResult?: () => void;
166
181
  control: Map<string, { resolve(r: Record<string, unknown>): void; reject(e: Error): void }>;
167
182
  }
@@ -197,7 +212,12 @@ export class ClaudeSubagentSessions implements PrimedSessions {
197
212
  Required<
198
213
  Pick<
199
214
  ClaudeSubagentConfig,
200
- 'maxMembers' | 'maxSessionsPerGroup' | 'batchWindowMs' | 'idleMs' | 'timeoutMs'
215
+ | 'maxMembers'
216
+ | 'maxSessionsPerGroup'
217
+ | 'sparesPerRole'
218
+ | 'batchWindowMs'
219
+ | 'idleMs'
220
+ | 'timeoutMs'
201
221
  >
202
222
  >;
203
223
  private _groups = new Map<string, Group>();
@@ -213,11 +233,16 @@ export class ClaudeSubagentSessions implements PrimedSessions {
213
233
  ...config,
214
234
  maxMembers: config.maxMembers ?? 12,
215
235
  maxSessionsPerGroup: config.maxSessionsPerGroup ?? 6,
236
+ sparesPerRole: config.sparesPerRole ?? 2,
216
237
  batchWindowMs: config.batchWindowMs ?? 25,
217
238
  idleMs: config.idleMs ?? 30 * 60_000,
218
239
  timeoutMs: config.timeoutMs ?? 30_000,
219
240
  };
220
- if (this._config.maxMembers < 1 || this._config.maxSessionsPerGroup < 1)
241
+ if (
242
+ this._config.maxMembers < 1 ||
243
+ this._config.maxSessionsPerGroup < 1 ||
244
+ this._config.sparesPerRole < 1
245
+ )
221
246
  throw Error('Invalid subagent session bounds');
222
247
  }
223
248
 
@@ -423,20 +448,26 @@ export class ClaudeSubagentSessions implements PrimedSessions {
423
448
  return name;
424
449
  }
425
450
 
451
+ /**
452
+ * A free spare for this role and standby context. A held one beats one still starting; among
453
+ * those, the one whose side session has the fewest free spares left, so a trigger empties whole
454
+ * side sessions and the other copies stay held.
455
+ */
426
456
  private _spareFor(group: Group, name: string, standby: string): Spare | undefined {
427
457
  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;
458
+ const free = (sp: Spare) => !sp.member && !sp.done && !sp.violation;
459
+ let best: { spare: Spare; held: boolean; left: number } | undefined;
460
+ for (const side of group.sides) {
461
+ if (!side.alive || side.retire) continue;
462
+ const left = [...side.spares.values()].filter(free).length;
463
+ for (const spare of side.spares.values()) {
464
+ if (spare.name !== name || spare.standbyHash !== standbyHash || !free(spare)) continue;
465
+ const held = !!spare.pending;
466
+ if (!best || (held && !best.held) || (held === best.held && left < best.left))
467
+ best = { spare, held, left };
468
+ }
469
+ }
470
+ return best?.spare;
440
471
  }
441
472
 
442
473
  /** Admit a decision onto its spare; the input goes out when the spare's moment call is held. */
@@ -589,6 +620,9 @@ export class ClaudeSubagentSessions implements PrimedSessions {
589
620
  spare.pending = { requestId, rpcId: id };
590
621
  this._reportStandby(spare);
591
622
  this._deliver(spare);
623
+ // A copy now holds: a partly used side session elsewhere may be free to refill.
624
+ const held = this._groups.get(side.group);
625
+ if (held && !spare.member) this._releaseSpent(held);
592
626
  return true;
593
627
  }
594
628
  if (id === undefined) return this._mcpReply(side, requestId, null);
@@ -599,62 +633,121 @@ export class ClaudeSubagentSessions implements PrimedSessions {
599
633
  });
600
634
  }
601
635
 
602
- /** Pre-start every desired role that has no spare holding its current standby context. */
636
+ /**
637
+ * Keep `sparesPerRole` spares of every desired role holding its current standby context. Copies
638
+ * of one role always go to different standby side sessions: each round of missing copies is its
639
+ * own set of standby turns.
640
+ */
603
641
  private _replenish(group: Group): void {
604
642
  if (this._closed || !group.desired.size || Date.now() < group.retryAt) return;
605
643
  // Spares are created when their standby turn is assigned, so one still starting counts as held.
606
- const held = new Set<string>();
644
+ const held = new Map<string, number>();
607
645
  for (const side of group.sides)
608
646
  if (side.alive && !side.retire)
609
647
  for (const spare of side.spares.values())
610
648
  if (!spare.done && !spare.member && !spare.violation)
611
649
  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;
650
+ held.set(spare.name, (held.get(spare.name) ?? 0) + 1);
651
+ const copies = this._config.sparesPerRole;
652
+ // Standby sessions this group may hold: every copy of every role, plus two for refills while
653
+ // partly used side sessions keep serving their unused spares.
654
+ const cap = copies * Math.ceil(group.desired.size / this._config.maxMembers) + 2;
655
+ for (let round = 0; round < copies; round++) {
656
+ const missing = [...group.desired.keys()].filter((name) => (held.get(name) ?? 0) <= round);
657
+ if (!missing.length) continue;
658
+ const chunks = Math.ceil(missing.length / this._config.maxMembers);
659
+ const size = Math.ceil(missing.length / chunks);
660
+ for (let i = 0; i < missing.length; i += size) {
661
+ const chunk = missing.slice(i, i + size);
662
+ // A side session launched with these roles' definitions (each has its standby variant).
663
+ let side = group.sides.find(
664
+ (s) => s.standby && s.alive && !s.busy && chunk.every((n) => s.names.has(n)),
665
+ );
666
+ if (!side) {
667
+ // A standby turn being ended, or with every spare used or taken, frees its side session in
668
+ // a moment: no new process meanwhile.
669
+ if (
670
+ group.sides.some(
671
+ (s) =>
672
+ s.standby &&
673
+ s.alive &&
674
+ (s.ending ||
675
+ (s.spares.size > 0 &&
676
+ [...s.spares.values()].every((sp) => sp.done || sp.member))),
677
+ )
678
+ )
679
+ return;
680
+ if (group.sides.filter((s) => s.standby).length >= cap) return; // retried when one frees
681
+ try {
682
+ side = this._spawn(group, true);
683
+ } catch {
684
+ this._startFailed(group);
685
+ return;
686
+ }
628
687
  }
688
+ const owner = side;
689
+ owner.busy = true;
690
+ owner.spares = new Map(
691
+ chunk.map((name, index) => {
692
+ const desired = group.desired.get(name)!;
693
+ // A position in this standby turn: deterministic, so a recorded turn replays.
694
+ const token = `s${index + 1}`;
695
+ const spare: Spare = {
696
+ name,
697
+ standbyHash: desired.standbyHash,
698
+ prompt: standbyPrompt(desired.standby, token),
699
+ token,
700
+ side: owner,
701
+ done: false,
702
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
703
+ };
704
+ return [token, spare] as const;
705
+ }),
706
+ );
707
+ for (const name of chunk) held.set(name, (held.get(name) ?? 0) + 1);
708
+ void this._standbyFanOut(owner, group)
709
+ .catch(() => undefined)
710
+ .finally(() => {
711
+ owner.busy = false;
712
+ owner.retire = false;
713
+ owner.ending = false;
714
+ owner.lastUsed = Date.now();
715
+ this._replenish(group);
716
+ });
629
717
  }
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
- });
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Refills are staggered so one copy of every role is always serving. A partly used standby turn
723
+ * keeps serving its unused spares (the used copies refill elsewhere) and ends only once nothing it
724
+ * serves is in flight and every role it still holds has another held copy: ending it then frees
725
+ * its process without leaving any role uncovered.
726
+ */
727
+ private _releaseSpent(group: Group): void {
728
+ if (this._config.sparesPerRole < 2) return;
729
+ const free = (sp: Spare) => !sp.member && !sp.done && !sp.violation;
730
+ for (const side of group.sides) {
731
+ if (!side.standby || !side.alive || side.ending || side.retire || !side.busy) continue;
732
+ const spares = [...side.spares.values()];
733
+ if (!spares.some((sp) => sp.done || sp.member)) continue; // untouched
734
+ if (spares.some((sp) => sp.member && !sp.member.settled)) continue; // still serving
735
+ const covered = spares
736
+ .filter(free)
737
+ .every((sp) =>
738
+ group.sides.some(
739
+ (other) =>
740
+ other !== side &&
741
+ other.alive &&
742
+ !other.ending &&
743
+ !other.retire &&
744
+ [...other.spares.values()].some(
745
+ (o) =>
746
+ o.name === sp.name && o.standbyHash === sp.standbyHash && free(o) && !!o.pending,
747
+ ),
748
+ ),
749
+ );
750
+ if (covered) this._endStandbyNow(side);
658
751
  }
659
752
  }
660
753
 
@@ -668,12 +761,14 @@ export class ClaudeSubagentSessions implements PrimedSessions {
668
761
  private _endStandby(side: Side): void {
669
762
  const inUse = [...side.spares.values()].some((sp) => sp.member && !sp.member.settled);
670
763
  if (inUse || !side.spares.size) return;
764
+ side.ending = true;
671
765
  void this._control(side, { subtype: 'interrupt' }, 5_000).catch(
672
766
  () => void this._kill(side, 'standby interrupt unanswered'),
673
767
  );
674
768
  }
675
769
 
676
770
  private _endStandbyNow(side: Side): void {
771
+ side.ending = true;
677
772
  void this._control(side, { subtype: 'interrupt' }, 5_000).catch(
678
773
  () => void this._kill(side, 'standby interrupt unanswered'),
679
774
  );
@@ -791,19 +886,18 @@ export class ClaudeSubagentSessions implements PrimedSessions {
791
886
  continue;
792
887
  }
793
888
  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
- );
889
+ const fanOuts = group.sides.filter((s) => !s.standby);
890
+ let side = fanOuts.find((s) => s.alive && !s.busy && [...needs].every((n) => s.names.has(n)));
797
891
  if (!side) {
798
- const idleLacking = group.sides.find((s) => !s.busy);
799
- if (group.sides.length >= this._config.maxSessionsPerGroup) {
892
+ const idleLacking = fanOuts.find((s) => !s.busy);
893
+ if (fanOuts.length >= this._config.maxSessionsPerGroup) {
800
894
  if (!idleLacking) return; // every side session is busy: the chunk waits
801
895
  // A side session without these definitions is replaced by one that has them all.
802
896
  group.sides.splice(group.sides.indexOf(idleLacking), 1);
803
897
  void this._kill(idleLacking, 'definitions changed');
804
898
  }
805
899
  try {
806
- side = this._spawn(group);
900
+ side = this._spawn(group, false);
807
901
  } catch (error) {
808
902
  group.waiting.shift();
809
903
  for (const m of chunk)
@@ -828,18 +922,18 @@ export class ClaudeSubagentSessions implements PrimedSessions {
828
922
  }
829
923
  }
830
924
 
831
- private _spawn(group: Group): Side {
925
+ private _spawn(group: Group, standby: boolean): Side {
832
926
  const generation = ++this._generation;
833
927
  const dir = mkdtempSync(join(tmpdir(), 'agent-session-agents-'));
834
928
  try {
835
- return this._launch(group, generation, dir);
929
+ return this._launch(group, generation, dir, standby);
836
930
  } catch (error) {
837
931
  rmSync(dir, { recursive: true, force: true });
838
932
  throw error;
839
933
  }
840
934
  }
841
935
 
842
- private _launch(group: Group, generation: number, dir: string): Side {
936
+ private _launch(group: Group, generation: number, dir: string, standby: boolean): Side {
843
937
  chmodSync(dir, 0o700);
844
938
  const agents: Record<string, unknown> = {};
845
939
  for (const [name, def] of group.defs) {
@@ -909,6 +1003,7 @@ export class ClaudeSubagentSessions implements PrimedSessions {
909
1003
  }) as unknown as PipedSubprocess);
910
1004
  const side: Side = {
911
1005
  group: group.id,
1006
+ standby,
912
1007
  names: new Set(group.defs.keys()),
913
1008
  proc,
914
1009
  dir,
@@ -1108,7 +1203,10 @@ export class ClaudeSubagentSessions implements PrimedSessions {
1108
1203
  if ([...side.spares.values()].every((sp) => sp.done)) this._endStandbyNow(side);
1109
1204
  else if (side.retire) this._endStandby(side);
1110
1205
  const group = this._groups.get(side.group);
1111
- if (group) this._replenish(group);
1206
+ if (group) {
1207
+ this._releaseSpent(group);
1208
+ this._replenish(group);
1209
+ }
1112
1210
  return;
1113
1211
  }
1114
1212
  const member = this._byToolUse(side, frame.tool_use_id);
@@ -944,6 +944,8 @@ export class CodexAppServerSession implements HarnessSession {
944
944
  private _approvals = new Map<string, AbortController>();
945
945
  /** The owned turn's in-progress fileChange items' changes, by item id, for their approvals. */
946
946
  private _fileChanges = new Map<string, readonly CodexFileChange[]>();
947
+ /** Child threads this session's turns spawned (collab `spawnAgent`), to the spawning item's id. */
948
+ private _children = new Map<string, string>();
947
949
  private _items = new Map<
948
950
  string,
949
951
  {
@@ -1266,6 +1268,8 @@ export class CodexAppServerSession implements HarnessSession {
1266
1268
  thread.turns.some((value) => !this._turn(value)))
1267
1269
  )
1268
1270
  this._refuse('configuration-missing-or-incompatible');
1271
+ // Sub-agents belong to the thread that spawned them.
1272
+ if (this._externalSessionId !== id) this._children.clear();
1269
1273
  this._externalSessionId = id;
1270
1274
  this._nativeSessionId =
1271
1275
  typeof thread?.sessionId === 'string' && thread.sessionId ? thread.sessionId : undefined;
@@ -1430,6 +1434,9 @@ export class CodexAppServerSession implements HarnessSession {
1430
1434
  },
1431
1435
  ];
1432
1436
  const a = this._inflight?.evidence;
1437
+ const spawnedBy = threadId ? this._children.get(threadId) : undefined;
1438
+ if (threadId && spawnedBy)
1439
+ return this._childEvents(method, threadId, spawnedBy, turn, item, unowned);
1433
1440
  if (!threadId || threadId !== this._externalSessionId) return unowned('foreign-session');
1434
1441
  if (!a || this._appAdmission !== a.admissionId) return unowned('no-admission');
1435
1442
  if (turnId && this._seenTerminals.has(turnId))
@@ -1519,6 +1526,50 @@ export class CodexAppServerSession implements HarnessSession {
1519
1526
  else this._fileChanges.delete(itemId);
1520
1527
  return unowned('unrecognized-event');
1521
1528
  }
1529
+ if (item.type === 'collabAgentToolCall') {
1530
+ // A sub-agent this turn spawned (its thread is named once known, on start or completion),
1531
+ // or closed. Other collab calls (send input, wait) are diagnostics.
1532
+ const receivers = Array.isArray(item.receiverThreadIds)
1533
+ ? item.receiverThreadIds.filter((id): id is string => typeof id === 'string' && !!id)
1534
+ : [];
1535
+ const prompt = typeof item.prompt === 'string' && item.prompt ? item.prompt : undefined;
1536
+ if (item.tool === 'spawnAgent') {
1537
+ const fresh = receivers.filter((child) => !this._children.has(child));
1538
+ if (!fresh.length) return status();
1539
+ return fresh.map((child): SessionEvent => {
1540
+ this._children.set(child, itemId);
1541
+ return {
1542
+ ...identity,
1543
+ kind: 'subagent',
1544
+ timestamp: Date.now(),
1545
+ subagent: {
1546
+ phase: 'started',
1547
+ taskId: child,
1548
+ toolUseId: itemId,
1549
+ agentType: 'spawnAgent',
1550
+ depth: 1,
1551
+ backgrounded: true,
1552
+ ...(prompt ? { prompt } : {}),
1553
+ },
1554
+ };
1555
+ });
1556
+ }
1557
+ if (item.tool === 'closeAgent' && method === 'item/completed') {
1558
+ const closed = receivers.filter((child) => this._children.has(child));
1559
+ if (!closed.length) return status();
1560
+ return closed.map((child): SessionEvent => {
1561
+ const toolUseId = this._children.get(child)!;
1562
+ this._children.delete(child);
1563
+ return {
1564
+ ...identity,
1565
+ kind: 'subagent',
1566
+ timestamp: Date.now(),
1567
+ subagent: { phase: 'ended', taskId: child, toolUseId, status: 'closed' },
1568
+ };
1569
+ });
1570
+ }
1571
+ return status();
1572
+ }
1522
1573
  if (item.type === 'agentMessage') {
1523
1574
  if (method !== 'item/completed') return status();
1524
1575
  if (this._items.get(itemId)?.completed) return unowned('after-terminal');
@@ -1638,6 +1689,43 @@ export class CodexAppServerSession implements HarnessSession {
1638
1689
  }
1639
1690
  return method === 'turn/started' ? status() : unowned('unrecognized-event');
1640
1691
  }
1692
+
1693
+ /**
1694
+ * A spawned sub-agent's thread, outside any admission of this session: its final messages as
1695
+ * text and its turns' ends as lifecycle, tagged with the spawning item. Anything else stays a
1696
+ * diagnostic, tagged the same way.
1697
+ */
1698
+ private _childEvents(
1699
+ method: string,
1700
+ threadId: string,
1701
+ spawnedBy: string,
1702
+ turn: { id: string; status: string; error?: unknown } | undefined,
1703
+ item: Record<string, unknown> | undefined,
1704
+ unowned: (reason: NonNullable<SessionEvent['unattributedReason']>) => SessionEvent[],
1705
+ ): SessionEvent[] {
1706
+ const tag = { parentToolUseId: spawnedBy, unattributedReason: 'subagent' as const };
1707
+ const base = { threadId, correlation: 'unknown' as const, timestamp: Date.now(), ...tag };
1708
+ if (
1709
+ method === 'item/completed' &&
1710
+ item?.type === 'agentMessage' &&
1711
+ typeof item.text === 'string'
1712
+ )
1713
+ return [{ ...base, kind: 'text', text: item.text }];
1714
+ if (method === 'turn/completed' && turn && turn.status !== 'inProgress')
1715
+ return [
1716
+ {
1717
+ ...base,
1718
+ kind: 'subagent',
1719
+ subagent: {
1720
+ phase: 'updated',
1721
+ taskId: threadId,
1722
+ toolUseId: spawnedBy,
1723
+ status: turn.status,
1724
+ },
1725
+ },
1726
+ ];
1727
+ return unowned('subagent').map((event) => ({ ...event, ...tag }));
1728
+ }
1641
1729
  }
1642
1730
 
1643
1731
  // ---------------------------------------------------------------------------
@@ -92,7 +92,38 @@ export type SessionEventKind =
92
92
  | 'usage'
93
93
  | 'rate_limit'
94
94
  | 'result'
95
- | 'error';
95
+ | 'error'
96
+ | 'subagent';
97
+
98
+ /**
99
+ * A subagent's lifecycle as the native runtime reports it (kind `subagent`). Claude Code: its
100
+ * `system` task frames for an Agent/Task call. Codex: a collab `spawnAgent` item and its child thread.
101
+ */
102
+ export interface SubagentLifecycle {
103
+ readonly phase: 'started' | 'progress' | 'updated' | 'ended';
104
+ /** The native id of the subagent run (Claude task id, Codex child thread id). */
105
+ readonly taskId: string;
106
+ /** The tool call that spawned it, in its parent's stream. */
107
+ readonly toolUseId?: string;
108
+ /** The task that spawned it, when a subagent spawned it (Claude). */
109
+ readonly parentTaskId?: string;
110
+ readonly agentType?: string;
111
+ readonly description?: string;
112
+ /** 1 for a child of the main thread. */
113
+ readonly depth?: number;
114
+ readonly backgrounded?: boolean;
115
+ /** The native status at the end (completed, stopped, failed, killed). */
116
+ readonly status?: string;
117
+ readonly summary?: string;
118
+ /** The task the subagent was given. */
119
+ readonly prompt?: string;
120
+ /** Its own running totals, when the runtime reports them (Claude progress and end frames). */
121
+ readonly usage?: {
122
+ readonly totalTokens: number;
123
+ readonly toolUses?: number;
124
+ readonly durationMs?: number;
125
+ };
126
+ }
96
127
 
97
128
  /** Input excludes cache reads/writes when those disjoint counters are supplied.
98
129
  * Thinking is a subset of output; TTL counters are subsets of cacheWrite.
@@ -130,7 +161,8 @@ export interface SessionEvent extends SessionIdentity {
130
161
  | 'duplicate-tool'
131
162
  | 'unmatched-tool'
132
163
  | 'malformed-tool'
133
- | 'account-status';
164
+ | 'account-status'
165
+ | 'subagent';
134
166
  /** Local transport observation, not a native terminal acknowledgment. */
135
167
  readonly transportOutcome?: SessionAttempt['transportOutcome'];
136
168
  readonly nativeOutcome?: NativeOutcome;
@@ -174,6 +206,14 @@ export interface SessionEvent extends SessionIdentity {
174
206
  readonly limits?: LimitSnapshot;
175
207
  /** Owned, deeply frozen native JSON data (for Oracle introspection). */
176
208
  readonly raw?: unknown;
209
+ /**
210
+ * The tool call (in its parent's stream) that spawned the subagent this event came from; absent
211
+ * for the main thread's own events. Every subagent event, its lifecycle included, is outside any
212
+ * admission (`unattributedReason: "subagent"`), so it never touches a turn's content or results.
213
+ */
214
+ readonly parentToolUseId?: string;
215
+ /** A subagent's lifecycle (kind `subagent`). */
216
+ readonly subagent?: SubagentLifecycle;
177
217
  }
178
218
 
179
219
  /** Result of a single send() — the turn's content plus all classified events. */
package/src/turn-state.ts CHANGED
@@ -63,8 +63,11 @@ export class TurnState {
63
63
  record(event: SessionEvent): SessionEvent {
64
64
  const e = retainEvidence({ ...this.identity, ...event, admissionId: this.admissionId });
65
65
  this.events.push(e);
66
- if ((e.kind === 'text' || e.kind === 'result') && e.text) this.content = e.text;
67
- if (e.kind === 'text_delta' && e.text) this.content += e.text;
66
+ // A subagent's text is its own; the parent turn's content is only the parent's.
67
+ if (!e.parentToolUseId) {
68
+ if ((e.kind === 'text' || e.kind === 'result') && e.text) this.content = e.text;
69
+ if (e.kind === 'text_delta' && e.text) this.content += e.text;
70
+ }
68
71
  // Usage events are per-request snapshots; the terminal result carries the turn total.
69
72
  if (e.tokens && e.kind !== 'usage') this.tokens = e.tokens;
70
73
  return e;