@inixiative/agent-session 0.6.3 → 0.6.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/agent-session",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
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.81",
44
44
  "@types/bun": "1.4.2",
45
45
  "lefthook": "2.1.9",
46
46
  "typescript": "6.0.3"
@@ -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);