@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 +2 -2
- package/src/claude-subagents.ts +170 -72
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/agent-session",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
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"
|
package/src/claude-subagents.ts
CHANGED
|
@@ -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
|
|
92
|
+
/** Members per fan-out or standby turn; a larger set splits evenly across side sessions. Default 12. */
|
|
84
93
|
maxMembers?: number;
|
|
85
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const
|
|
617
|
-
for (let
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
795
|
-
|
|
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 =
|
|
799
|
-
if (
|
|
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)
|
|
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);
|