@inixiative/agent-session 0.11.0 → 0.12.0
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 +12 -4
- package/package.json +2 -2
- package/src/claude-agent-sdk-session.ts +1 -1
- package/src/claude-code-session.ts +12 -4
- package/src/claude-in-session.ts +100 -29
- package/src/claude-subagents.ts +5 -5
- package/src/codex-primed.ts +1 -1
- package/src/harness-session.ts +9 -1
- package/src/pool.ts +1 -1
- package/src/tool-gate.ts +19 -3
- package/src/turn-state.ts +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Part of [inixiative](https://www.inixiative.com), technology for cooperation. See [all the projects](https://github.com/inixiative).
|
|
4
4
|
|
|
5
|
-
Drive Claude Code
|
|
5
|
+
Drive Claude Code and Codex as persistent streaming sessions behind one
|
|
6
6
|
interface. Claude Code runs over its CLI or the Agent SDK, Codex over its
|
|
7
7
|
app-server and Muse over `muse serve`; Gemini and Grok are planned. The package
|
|
8
8
|
also holds warm **primed decision sessions**, **subscription pools** with
|
|
@@ -125,7 +125,10 @@ session.onToolRequest(async (call) => {
|
|
|
125
125
|
|
|
126
126
|
- `pre-use` (Claude only) runs before every call in every mode,
|
|
127
127
|
`bypassPermissions` included; only a deny counts. A PreToolUse callback over
|
|
128
|
-
the control protocol; one the session cannot read is denied.
|
|
128
|
+
the control protocol; one the session cannot read is denied. An allow may
|
|
129
|
+
carry `updatedInput`: the call runs with that input (the next gate sees it as
|
|
130
|
+
`toolInput`), and the runtime's own mode still decides whether it runs.
|
|
131
|
+
Foundry uses it to add the chorus's brief to a subagent's prompt.
|
|
129
132
|
- `permission` is an ask the runtime's mode raised; the answer is final (no gate:
|
|
130
133
|
denied). Claude: `--permission-prompt-tool stdio`; `decisionReason` says why it
|
|
131
134
|
asked, `agentId` marks a subagent's call. Codex app-server: command and
|
|
@@ -218,9 +221,14 @@ const result = await decisions.decide(spec, { input, standby });
|
|
|
218
221
|
own subagents go to the tool gate untouched.
|
|
219
222
|
- **Fresh per call.** Each member serves one decision. A used one is replaced
|
|
220
223
|
at once; a role with no held spare starts a member with its input inline.
|
|
224
|
+
- **Slices.** `dispatchers` (default 1) splits the roles into fixed slices,
|
|
225
|
+
each started by its own dispatcher, so refills are written at once. A
|
|
226
|
+
dispatcher writes one Agent call per member as output tokens: one
|
|
227
|
+
dispatcher refilled 75 members in ~20 s (2026-11-02). A role keeps its
|
|
228
|
+
slice while it is wanted; a new role joins the smallest.
|
|
221
229
|
- **Recycle.** A dispatcher's context grows ~700 tokens per member started;
|
|
222
|
-
past `recycleTokens` (100k) a new generation takes over and
|
|
223
|
-
ends into the root once empty.
|
|
230
|
+
past `recycleTokens` (100k) a new generation of its slice takes over and
|
|
231
|
+
the old one ends into the root once empty.
|
|
224
232
|
- **Orphans.** A nested agent whose parent ended reports its own end to the
|
|
225
233
|
main thread, which wakes the main model (Claude Code 2.1.300). Members of a
|
|
226
234
|
dispatcher that ended unasked are therefore never retired or stopped, and an
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/agent-session",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
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",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@biomejs/biome": "2.5.2",
|
|
44
|
-
"@inixiative/config": "^0.5.
|
|
44
|
+
"@inixiative/config": "^0.5.187",
|
|
45
45
|
"@types/bun": "1.4.4",
|
|
46
46
|
"lefthook": "2.1.9",
|
|
47
47
|
"typescript": "6.0.3"
|
|
@@ -187,7 +187,7 @@ function bridge(
|
|
|
187
187
|
const awaiting = new Map<string, (response: unknown) => void>();
|
|
188
188
|
const ask = (request: Record<string, unknown>, signal?: AbortSignal) =>
|
|
189
189
|
new Promise<unknown>((resolve) => {
|
|
190
|
-
const id = `sdk-bridge-${
|
|
190
|
+
const id = `sdk-bridge-${Bun.randomUUIDv7()}`;
|
|
191
191
|
awaiting.set(id, resolve);
|
|
192
192
|
signal?.addEventListener(
|
|
193
193
|
'abort',
|
|
@@ -379,7 +379,7 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
379
379
|
private _steerAcks = new Map<string, (state: SteerState) => void>();
|
|
380
380
|
/** Steers written and not yet ended natively: a late `completed` after their turn settled is theirs. */
|
|
381
381
|
private _steerUuids = new Set<string>();
|
|
382
|
-
private readonly _hookCallbackId = `agent-session-pre-tool-${
|
|
382
|
+
private readonly _hookCallbackId = `agent-session-pre-tool-${Bun.randomUUIDv7()}`;
|
|
383
383
|
private _limits?: LimitSnapshot;
|
|
384
384
|
private _controlSeq = 0;
|
|
385
385
|
private _control = new Map<
|
|
@@ -555,7 +555,7 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
555
555
|
if (!turn || !a || a.dispatch !== 'attempted' || a.nativeOutcome !== 'unknown')
|
|
556
556
|
return ignored('no owned in-flight turn');
|
|
557
557
|
if (!this._proc || !this._alive) return ignored('session not running');
|
|
558
|
-
const uuid =
|
|
558
|
+
const uuid = Bun.randomUUIDv7();
|
|
559
559
|
turn.steers ??= new Map();
|
|
560
560
|
const steers = turn.steers;
|
|
561
561
|
steers.set(uuid, 'written');
|
|
@@ -999,7 +999,7 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
999
999
|
timeoutMs: number,
|
|
1000
1000
|
): Promise<Record<string, unknown>> {
|
|
1001
1001
|
if (!this._proc || !this._alive) return Promise.reject(new Error('Session not running'));
|
|
1002
|
-
const requestId = `agent-session-${++this._controlSeq}-${
|
|
1002
|
+
const requestId = `agent-session-${++this._controlSeq}-${Bun.randomUUIDv7()}`;
|
|
1003
1003
|
return new Promise((resolve, reject) => {
|
|
1004
1004
|
const timer = setTimeout(() => {
|
|
1005
1005
|
this._control.delete(requestId);
|
|
@@ -1746,7 +1746,15 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1746
1746
|
permissionDecisionReason: decision.message,
|
|
1747
1747
|
},
|
|
1748
1748
|
}
|
|
1749
|
-
:
|
|
1749
|
+
: decision?.updatedInput
|
|
1750
|
+
? // No permission decision: the call runs with the new input under the session's own mode.
|
|
1751
|
+
{
|
|
1752
|
+
hookSpecificOutput: {
|
|
1753
|
+
hookEventName: 'PreToolUse',
|
|
1754
|
+
updatedInput: structuredClone(decision.updatedInput),
|
|
1755
|
+
},
|
|
1756
|
+
}
|
|
1757
|
+
: {};
|
|
1750
1758
|
return decision?.behavior === 'allow'
|
|
1751
1759
|
? { behavior: 'allow', updatedInput: structuredClone(request.toolInput) }
|
|
1752
1760
|
: { behavior: 'deny', message: decision?.message ?? 'Denied' };
|
package/src/claude-in-session.ts
CHANGED
|
@@ -19,9 +19,13 @@
|
|
|
19
19
|
// standby context matches answers that call with its input; the member answers
|
|
20
20
|
// through `answer`. A decision with no spare starts a member with its input inline.
|
|
21
21
|
// Each member serves one decision (fresh every call); a used one is started again.
|
|
22
|
+
// slices the roles are split into `dispatchers` fixed slices, each started by its own
|
|
23
|
+
// dispatcher generation under the root, so their refills are written at once: a
|
|
24
|
+
// dispatcher writes one Agent call per member as output tokens, so one dispatcher for a
|
|
25
|
+
// large roster is slow to refill (75 members took ~20 s; 2026-11-02).
|
|
22
26
|
// 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
|
|
24
|
-
// replacements hold, and it ends into the root once empty.
|
|
27
|
+
// starts a new generation of that slice; the old one's held spares are retired as
|
|
28
|
+
// their replacements hold, and it ends into the root once empty.
|
|
25
29
|
//
|
|
26
30
|
// Agent definitions are generic (no prompt of their own): every member's composed text rides in
|
|
27
31
|
// its pinned prompt, so an edit reaches the next spare without relaunching the session.
|
|
@@ -98,6 +102,11 @@ export interface ClaudeInSessionConfig {
|
|
|
98
102
|
setupDeadlineMs?: number;
|
|
99
103
|
/** A dispatcher whose context passes this many tokens is replaced. Default 100k. */
|
|
100
104
|
recycleTokens?: number;
|
|
105
|
+
/**
|
|
106
|
+
* Dispatchers running at once, each starting a fixed slice of the roles (a role keeps its slice
|
|
107
|
+
* while it is wanted; a new one joins the smallest). Default 1.
|
|
108
|
+
*/
|
|
109
|
+
dispatchers?: number;
|
|
101
110
|
/**
|
|
102
111
|
* Subagents this host may run on top of the user's own concurrent-subagent cap
|
|
103
112
|
* (`CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS`, 20 by default in Claude Code 2.1.300): the root, a
|
|
@@ -146,6 +155,8 @@ interface Starter {
|
|
|
146
155
|
ended?: boolean;
|
|
147
156
|
/** A dispatcher whose line went out to the root. */
|
|
148
157
|
issued?: boolean;
|
|
158
|
+
/** A dispatcher's slice of the roles. */
|
|
159
|
+
slice?: number;
|
|
149
160
|
}
|
|
150
161
|
|
|
151
162
|
interface Member {
|
|
@@ -214,6 +225,7 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
214
225
|
| 'recycleTokens'
|
|
215
226
|
| 'maxOrphans'
|
|
216
227
|
| 'maxSetups'
|
|
228
|
+
| 'dispatchers'
|
|
217
229
|
>
|
|
218
230
|
>;
|
|
219
231
|
private _host?: ClaudeSessionHost;
|
|
@@ -221,7 +233,12 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
221
233
|
private _seq = 0;
|
|
222
234
|
private _generation = 0;
|
|
223
235
|
private _root?: Starter;
|
|
224
|
-
|
|
236
|
+
/** The current dispatcher generation of each slice. */
|
|
237
|
+
private _current: Array<Starter | undefined> = [];
|
|
238
|
+
/** Each wanted role's slice. */
|
|
239
|
+
private readonly _sliceOf = new Map<string, number>();
|
|
240
|
+
/** Direct members of roles outside every slice go out round-robin. */
|
|
241
|
+
private _nextDirect = 0;
|
|
225
242
|
private readonly _gens = new Map<string, Starter>();
|
|
226
243
|
private readonly _spares = new Map<string, Spare>();
|
|
227
244
|
private readonly _members = new Set<Member>();
|
|
@@ -254,8 +271,14 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
254
271
|
recycleTokens: config.recycleTokens ?? 100_000,
|
|
255
272
|
maxOrphans: config.maxOrphans ?? 40,
|
|
256
273
|
maxSetups: config.maxSetups ?? 2,
|
|
274
|
+
dispatchers: config.dispatchers ?? 1,
|
|
257
275
|
};
|
|
258
|
-
if (
|
|
276
|
+
if (
|
|
277
|
+
this._config.sparesPerRole < 1 ||
|
|
278
|
+
this._config.recycleTokens < 10_000 ||
|
|
279
|
+
!Number.isSafeInteger(this._config.dispatchers) ||
|
|
280
|
+
this._config.dispatchers < 1
|
|
281
|
+
)
|
|
259
282
|
throw Error('Invalid in-session decision bounds');
|
|
260
283
|
// On top of the user's own cap (Claude Code's default 20), so the main agent keeps its slots.
|
|
261
284
|
const own = Number(process.env.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS) || 20;
|
|
@@ -368,7 +391,8 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
368
391
|
}
|
|
369
392
|
this._spares.clear();
|
|
370
393
|
this._gens.clear();
|
|
371
|
-
this._root =
|
|
394
|
+
this._root = undefined;
|
|
395
|
+
this._current = [];
|
|
372
396
|
}
|
|
373
397
|
|
|
374
398
|
mcp(_server: string, requestId: string, message: Record<string, unknown>): void {
|
|
@@ -591,7 +615,7 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
591
615
|
const waiting = this._awaitingSlot.shift();
|
|
592
616
|
if (waiting) {
|
|
593
617
|
this._reissue(waiting);
|
|
594
|
-
if (
|
|
618
|
+
if (waiting.gen) this._feed(waiting.gen);
|
|
595
619
|
}
|
|
596
620
|
}
|
|
597
621
|
return true;
|
|
@@ -621,10 +645,11 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
621
645
|
owner.context = add.input + add.cacheRead + add.cacheWrite;
|
|
622
646
|
if (
|
|
623
647
|
owner.kind === 'dispatch' &&
|
|
624
|
-
owner
|
|
648
|
+
owner.slice !== undefined &&
|
|
649
|
+
owner === this._current[owner.slice] &&
|
|
625
650
|
owner.context > this._config.recycleTokens
|
|
626
651
|
)
|
|
627
|
-
this._recycle();
|
|
652
|
+
this._recycle(owner.slice);
|
|
628
653
|
} else {
|
|
629
654
|
const target = owner.member?.deliveredAt ? owner.usage : owner.standby;
|
|
630
655
|
for (const key of Object.keys(add) as Array<keyof Usage>) target[key] += add[key];
|
|
@@ -740,6 +765,7 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
740
765
|
];
|
|
741
766
|
}),
|
|
742
767
|
);
|
|
768
|
+
this._assignSlices();
|
|
743
769
|
for (const spare of this._spares.values())
|
|
744
770
|
if (
|
|
745
771
|
!spare.direct &&
|
|
@@ -864,7 +890,8 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
864
890
|
lines: [],
|
|
865
891
|
context: 0,
|
|
866
892
|
};
|
|
867
|
-
this.
|
|
893
|
+
this._current = [];
|
|
894
|
+
for (let slice = 0; slice < this._config.dispatchers; slice++) this._newGeneration(slice);
|
|
868
895
|
clearTimeout(this._setupTimer);
|
|
869
896
|
this._setupTimer = setTimeout(() => {
|
|
870
897
|
if (this._setup.status === 'starting') this._publishSetup('degraded');
|
|
@@ -891,25 +918,37 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
891
918
|
[...this._spares.values()].filter((s) => s.held && !s.done && !s.retired).map((s) => s.name),
|
|
892
919
|
);
|
|
893
920
|
const all = [...this._desired.keys()].every((name) => held.has(name));
|
|
894
|
-
|
|
921
|
+
// Every slice's dispatcher has taken its lines (it waits in `next` again).
|
|
922
|
+
const dispatching = this._current.length > 0 && this._current.every((gen) => !!gen?.next);
|
|
923
|
+
if (this._rostered && all && dispatching) {
|
|
895
924
|
clearTimeout(this._setupTimer);
|
|
896
925
|
this._publishSetup('ready');
|
|
897
926
|
} else this._publishSetup('starting');
|
|
898
927
|
}
|
|
899
928
|
|
|
900
|
-
private _newGeneration(): Starter {
|
|
929
|
+
private _newGeneration(slice: number): Starter {
|
|
901
930
|
const token = `g${++this._generation}`;
|
|
902
|
-
const gen: Starter = {
|
|
931
|
+
const gen: Starter = {
|
|
932
|
+
token,
|
|
933
|
+
kind: 'dispatch',
|
|
934
|
+
prompt: STARTER(token),
|
|
935
|
+
lines: [],
|
|
936
|
+
context: 0,
|
|
937
|
+
slice,
|
|
938
|
+
};
|
|
903
939
|
this._gens.set(token, gen);
|
|
904
|
-
this.
|
|
940
|
+
this._current[slice] = gen;
|
|
905
941
|
this._root!.lines.push(`${IN_SESSION_AGENTS.dispatch} ${token}`);
|
|
906
942
|
this._feed(this._root!);
|
|
907
943
|
return gen;
|
|
908
944
|
}
|
|
909
945
|
|
|
910
|
-
/**
|
|
911
|
-
|
|
912
|
-
|
|
946
|
+
/**
|
|
947
|
+
* A slice's dispatcher past its bound: a new generation of that slice takes new spares; the old
|
|
948
|
+
* one's are retired as replacements hold.
|
|
949
|
+
*/
|
|
950
|
+
private _recycle(slice: number): void {
|
|
951
|
+
const old = this._current[slice];
|
|
913
952
|
if (!old || old.stopping || !this._root?.taskId) return;
|
|
914
953
|
old.stopping = true;
|
|
915
954
|
this._emit({
|
|
@@ -919,11 +958,33 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
919
958
|
});
|
|
920
959
|
for (const spare of this._spares.values())
|
|
921
960
|
if (spare.gen === old && !spare.member && !spare.done) spare.superseded = true;
|
|
922
|
-
this._newGeneration();
|
|
961
|
+
this._newGeneration(slice);
|
|
923
962
|
this._replenish();
|
|
924
963
|
this._endIfEmpty(old);
|
|
925
964
|
}
|
|
926
965
|
|
|
966
|
+
/** Keep every wanted role in a slice: a role keeps its slice; a new one joins the smallest. */
|
|
967
|
+
private _assignSlices(): void {
|
|
968
|
+
for (const name of [...this._sliceOf.keys()])
|
|
969
|
+
if (!this._desired.has(name)) this._sliceOf.delete(name);
|
|
970
|
+
const sizes = Array.from({ length: this._config.dispatchers }, () => 0);
|
|
971
|
+
for (const slice of this._sliceOf.values()) sizes[slice]!++;
|
|
972
|
+
for (const name of this._desired.keys()) {
|
|
973
|
+
if (this._sliceOf.has(name)) continue;
|
|
974
|
+
const slice = sizes.indexOf(Math.min(...sizes));
|
|
975
|
+
this._sliceOf.set(name, slice);
|
|
976
|
+
sizes[slice]!++;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** The dispatcher that starts this role's members now: its slice's, or round-robin for a role in none. */
|
|
981
|
+
private _genFor(name: string): Starter | undefined {
|
|
982
|
+
const slice = this._sliceOf.get(name);
|
|
983
|
+
if (slice !== undefined) return this._current[slice];
|
|
984
|
+
const live = this._current.filter((gen): gen is Starter => !!gen && !gen.stopping);
|
|
985
|
+
return live.length ? live[this._nextDirect++ % live.length] : undefined;
|
|
986
|
+
}
|
|
987
|
+
|
|
927
988
|
/** Answer a starter's held `next` with what it should start (or stop). */
|
|
928
989
|
private _feed(starter: Starter): void {
|
|
929
990
|
if (!starter.next) return;
|
|
@@ -1007,16 +1068,19 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
1007
1068
|
return best;
|
|
1008
1069
|
}
|
|
1009
1070
|
|
|
1010
|
-
/** Keep `sparesPerRole` free spares of every desired role, in
|
|
1071
|
+
/** Keep `sparesPerRole` free spares of every desired role, in its slice's current generation. */
|
|
1011
1072
|
private _replenish(): void {
|
|
1012
|
-
|
|
1013
|
-
if (this._closed || !gen || gen.stopping) return;
|
|
1073
|
+
if (this._closed) return;
|
|
1014
1074
|
const free = new Map<string, number>();
|
|
1015
1075
|
for (const spare of this._spares.values())
|
|
1016
1076
|
if (!spare.direct && !spare.member && !spare.done && !spare.superseded && !spare.retired)
|
|
1017
1077
|
if (this._desired.get(spare.name)?.standbyHash === spare.standbyHash)
|
|
1018
1078
|
free.set(spare.name, (free.get(spare.name) ?? 0) + 1);
|
|
1019
|
-
|
|
1079
|
+
const fed = new Set<Starter>();
|
|
1080
|
+
for (const [name, desired] of this._desired) {
|
|
1081
|
+
const gen = this._genFor(name);
|
|
1082
|
+
if (!gen || gen.stopping) continue;
|
|
1083
|
+
fed.add(gen);
|
|
1020
1084
|
for (let n = free.get(name) ?? 0; n < this._config.sparesPerRole; n++) {
|
|
1021
1085
|
const token = `w${++this._seq}`;
|
|
1022
1086
|
this._spares.set(token, {
|
|
@@ -1032,7 +1096,8 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
1032
1096
|
});
|
|
1033
1097
|
gen.lines.push(`${IN_SESSION_AGENTS.member} ${token}`);
|
|
1034
1098
|
}
|
|
1035
|
-
|
|
1099
|
+
}
|
|
1100
|
+
for (const gen of fed) this._feed(gen);
|
|
1036
1101
|
}
|
|
1037
1102
|
|
|
1038
1103
|
/** A spare's replacement holds: retire one superseded spare of the same role. */
|
|
@@ -1068,7 +1133,7 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
1068
1133
|
}
|
|
1069
1134
|
|
|
1070
1135
|
private async _admit(member: Member): Promise<boolean> {
|
|
1071
|
-
member.admissionId ??=
|
|
1136
|
+
member.admissionId ??= Bun.randomUUIDv7();
|
|
1072
1137
|
if (member.admitted) return true;
|
|
1073
1138
|
try {
|
|
1074
1139
|
await Promise.race([
|
|
@@ -1144,8 +1209,8 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
1144
1209
|
/** A decision with no spare: a member started with its whole input. */
|
|
1145
1210
|
private async _direct(member: Member, composed: string): Promise<void> {
|
|
1146
1211
|
if (!(await this._admit(member))) return;
|
|
1147
|
-
const gen = this.
|
|
1148
|
-
if (!gen) {
|
|
1212
|
+
const gen = this._genFor(member.name);
|
|
1213
|
+
if (!gen || gen.stopping) {
|
|
1149
1214
|
member.reject(
|
|
1150
1215
|
new DecisionError(
|
|
1151
1216
|
'No dispatcher running',
|
|
@@ -1230,7 +1295,7 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
1230
1295
|
if (this._closed) return;
|
|
1231
1296
|
const orphaned = [...this._spares.values()].filter((sp) => !sp.done);
|
|
1232
1297
|
this._gens.clear();
|
|
1233
|
-
this.
|
|
1298
|
+
this._current = [];
|
|
1234
1299
|
this._root = undefined;
|
|
1235
1300
|
this._publishSetup('degraded');
|
|
1236
1301
|
this._startSetup();
|
|
@@ -1241,7 +1306,13 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
1241
1306
|
// Its members outlive it (nested background agents are not stopped with their parent).
|
|
1242
1307
|
this._gens.delete(owner.token);
|
|
1243
1308
|
const planned = owner.stopping && this._empty(owner);
|
|
1244
|
-
if (
|
|
1309
|
+
if (
|
|
1310
|
+
owner.slice !== undefined &&
|
|
1311
|
+
owner === this._current[owner.slice] &&
|
|
1312
|
+
!this._closed &&
|
|
1313
|
+
this._root
|
|
1314
|
+
)
|
|
1315
|
+
this._newGeneration(owner.slice);
|
|
1245
1316
|
if (!planned && !this._closed)
|
|
1246
1317
|
for (const spare of this._spares.values())
|
|
1247
1318
|
if (spare.gen === owner && !spare.done) this._orphan(spare);
|
|
@@ -1293,8 +1364,8 @@ export class ClaudeInSessionDecisions implements PrimedSessions, ClaudeSessionEx
|
|
|
1293
1364
|
}
|
|
1294
1365
|
|
|
1295
1366
|
private _reissue(spare: Spare): void {
|
|
1296
|
-
const gen = this.
|
|
1297
|
-
if (this._closed || !gen || spare.done) return;
|
|
1367
|
+
const gen = this._genFor(spare.name);
|
|
1368
|
+
if (this._closed || !gen || gen.stopping || spare.done) return;
|
|
1298
1369
|
spare.gen = gen;
|
|
1299
1370
|
spare.issued = false;
|
|
1300
1371
|
const line = `${IN_SESSION_AGENTS.member} ${spare.token}`;
|
package/src/claude-subagents.ts
CHANGED
|
@@ -472,7 +472,7 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
472
472
|
|
|
473
473
|
/** Admit a decision onto its spare; the input goes out when the spare's moment call is held. */
|
|
474
474
|
private async _reserve(spare: Spare, member: Member): Promise<void> {
|
|
475
|
-
member.admissionId =
|
|
475
|
+
member.admissionId = Bun.randomUUIDv7();
|
|
476
476
|
const ours = () => spare.member === member && member.spare === spare;
|
|
477
477
|
try {
|
|
478
478
|
await Promise.race([
|
|
@@ -789,7 +789,7 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
789
789
|
return;
|
|
790
790
|
}
|
|
791
791
|
const spares = [...side.spares.values()];
|
|
792
|
-
const uuid =
|
|
792
|
+
const uuid = Bun.randomUUIDv7();
|
|
793
793
|
const turnEnded = new Promise<void>((resolve) => {
|
|
794
794
|
side.onResult = resolve;
|
|
795
795
|
});
|
|
@@ -1041,7 +1041,7 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
1041
1041
|
}
|
|
1042
1042
|
|
|
1043
1043
|
private _control(side: Side, request: Record<string, unknown>, timeoutMs = 30_000) {
|
|
1044
|
-
const id = `c-${
|
|
1044
|
+
const id = `c-${Bun.randomUUIDv7()}`;
|
|
1045
1045
|
return new Promise<Record<string, unknown>>((resolve, reject) => {
|
|
1046
1046
|
const timer = setTimeout(() => {
|
|
1047
1047
|
side.control.delete(id);
|
|
@@ -1417,7 +1417,7 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
1417
1417
|
await Promise.all(
|
|
1418
1418
|
chunk.map(async (m) => {
|
|
1419
1419
|
if (m.settled) return;
|
|
1420
|
-
m.admissionId ??=
|
|
1420
|
+
m.admissionId ??= Bun.randomUUIDv7();
|
|
1421
1421
|
try {
|
|
1422
1422
|
if (!retry && !m.admitted)
|
|
1423
1423
|
await Promise.race([
|
|
@@ -1463,7 +1463,7 @@ export class ClaudeSubagentSessions implements PrimedSessions {
|
|
|
1463
1463
|
m.token = `m${i + 1}`;
|
|
1464
1464
|
});
|
|
1465
1465
|
side.members = new Map(admitted.map((m) => [m.token, m]));
|
|
1466
|
-
const uuid =
|
|
1466
|
+
const uuid = Bun.randomUUIDv7();
|
|
1467
1467
|
const turnEnded = new Promise<void>((resolve) => {
|
|
1468
1468
|
side.onResult = resolve;
|
|
1469
1469
|
});
|
package/src/codex-primed.ts
CHANGED
|
@@ -1335,7 +1335,7 @@ export class CodexPrimedSessions implements PrimedSessions {
|
|
|
1335
1335
|
if (session) session.model ??= model;
|
|
1336
1336
|
const branchMs = Math.round(performance.now() - t1);
|
|
1337
1337
|
let branch = opened.branch;
|
|
1338
|
-
admissionId =
|
|
1338
|
+
admissionId = Bun.randomUUIDv7();
|
|
1339
1339
|
try {
|
|
1340
1340
|
await request.onAdmission?.({ admissionId, key: spec.key, runtime: 'codex', threadId });
|
|
1341
1341
|
} catch (cause) {
|
package/src/harness-session.ts
CHANGED
|
@@ -399,7 +399,15 @@ export interface ToolRequest {
|
|
|
399
399
|
readonly signal?: AbortSignal;
|
|
400
400
|
}
|
|
401
401
|
export type ToolDecision =
|
|
402
|
-
| {
|
|
402
|
+
| {
|
|
403
|
+
readonly behavior: 'allow';
|
|
404
|
+
/**
|
|
405
|
+
* `pre-use` only (Claude): the input the call runs with instead of the one the model wrote
|
|
406
|
+
* (e.g. a subagent's prompt with context appended). The runtime's own mode still decides
|
|
407
|
+
* whether the call runs. Later gates see it as their `toolInput`.
|
|
408
|
+
*/
|
|
409
|
+
readonly updatedInput?: Readonly<Record<string, unknown>>;
|
|
410
|
+
}
|
|
403
411
|
| { readonly behavior: 'deny'; readonly message: string };
|
|
404
412
|
/** Gates compose in registration order: the first deny wins; a thrown or rejected gate denies. */
|
|
405
413
|
export type ToolGate = (request: ToolRequest) => ToolDecision | Promise<ToolDecision>;
|
package/src/pool.ts
CHANGED
|
@@ -584,7 +584,7 @@ export class SubscriptionPool {
|
|
|
584
584
|
utilizationPercent: number | null,
|
|
585
585
|
rank: number,
|
|
586
586
|
): Lease {
|
|
587
|
-
const leaseId =
|
|
587
|
+
const leaseId = Bun.randomUUIDv7();
|
|
588
588
|
state.leases.add(leaseId);
|
|
589
589
|
let released = false;
|
|
590
590
|
const lease: Lease = {
|
package/src/tool-gate.ts
CHANGED
|
@@ -25,17 +25,22 @@ export class ToolGates {
|
|
|
25
25
|
return [...this._gates];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Never rejects. Undefined means no decision: only a `pre-use` request without a deny or a new
|
|
30
|
+
* input. A gate's `updatedInput` (pre-use) is the next gate's `toolInput` and the decision's.
|
|
31
|
+
*/
|
|
29
32
|
async decide(request: ToolRequest): Promise<ToolDecision | undefined> {
|
|
30
33
|
const gates = [...this._gates];
|
|
31
34
|
if (!gates.length)
|
|
32
35
|
return request.stage === 'permission'
|
|
33
36
|
? { behavior: 'deny', message: 'No approval handler' }
|
|
34
37
|
: undefined;
|
|
38
|
+
let current = request;
|
|
39
|
+
let updatedInput: Readonly<Record<string, unknown>> | undefined;
|
|
35
40
|
for (const gate of gates) {
|
|
36
41
|
let decision: ToolDecision;
|
|
37
42
|
try {
|
|
38
|
-
decision = await gate(
|
|
43
|
+
decision = await gate(current);
|
|
39
44
|
} catch (error) {
|
|
40
45
|
return {
|
|
41
46
|
behavior: 'deny',
|
|
@@ -46,8 +51,19 @@ export class ToolGates {
|
|
|
46
51
|
return { behavior: 'deny', message: String(decision.message ?? 'Denied') };
|
|
47
52
|
if (decision?.behavior !== 'allow')
|
|
48
53
|
return { behavior: 'deny', message: 'Tool gate failed: malformed decision' };
|
|
54
|
+
if (decision.updatedInput !== undefined && request.stage === 'pre-use') {
|
|
55
|
+
if (
|
|
56
|
+
!decision.updatedInput ||
|
|
57
|
+
typeof decision.updatedInput !== 'object' ||
|
|
58
|
+
Array.isArray(decision.updatedInput)
|
|
59
|
+
)
|
|
60
|
+
return { behavior: 'deny', message: 'Tool gate failed: malformed updatedInput' };
|
|
61
|
+
updatedInput = Object.freeze(structuredClone(decision.updatedInput));
|
|
62
|
+
current = { ...current, toolInput: updatedInput };
|
|
63
|
+
}
|
|
49
64
|
}
|
|
50
|
-
|
|
65
|
+
if (request.stage === 'permission') return { behavior: 'allow' };
|
|
66
|
+
return updatedInput ? { behavior: 'allow', updatedInput } : undefined;
|
|
51
67
|
}
|
|
52
68
|
}
|
|
53
69
|
|
package/src/turn-state.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { retainEvidence } from './retained-evidence';
|
|
10
10
|
/** Per-admission evidence. Caller settlement never transfers native ownership. */
|
|
11
11
|
export class TurnState {
|
|
12
|
-
readonly admissionId =
|
|
12
|
+
readonly admissionId = Bun.randomUUIDv7();
|
|
13
13
|
identity: SessionIdentity = {};
|
|
14
14
|
nativeOutcome: SessionAttempt['nativeOutcome'] = 'unknown';
|
|
15
15
|
localOutcome: SessionAttempt['localOutcome'] = 'pending';
|