@inixiative/agent-session 0.11.1 → 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 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, Codex and Muse as persistent streaming sessions behind one
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 the old one
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.11.1",
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.186",
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"
@@ -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' };
@@ -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 their
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
- private _gen?: Starter;
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 (this._config.sparesPerRole < 1 || this._config.recycleTokens < 10_000)
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 = this._gen = undefined;
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 (this._gen) this._feed(this._gen);
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 === this._gen &&
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._newGeneration();
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
- if (this._rostered && all && this._gen?.next) {
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 = { token, kind: 'dispatch', prompt: STARTER(token), lines: [], context: 0 };
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._gen = gen;
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
- /** A dispatcher past its bound: a new generation takes new spares; the old one's are retired as replacements hold. */
911
- private _recycle(): void {
912
- const old = this._gen;
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 the current generation. */
1071
+ /** Keep `sparesPerRole` free spares of every desired role, in its slice's current generation. */
1011
1072
  private _replenish(): void {
1012
- const gen = this._gen;
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
- for (const [name, desired] of this._desired)
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
- this._feed(gen);
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. */
@@ -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._gen;
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._gen = undefined;
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 (owner === this._gen && !this._closed) this._newGeneration();
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._gen;
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}`;
@@ -399,7 +399,15 @@ export interface ToolRequest {
399
399
  readonly signal?: AbortSignal;
400
400
  }
401
401
  export type ToolDecision =
402
- | { readonly behavior: 'allow' }
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/tool-gate.ts CHANGED
@@ -25,17 +25,22 @@ export class ToolGates {
25
25
  return [...this._gates];
26
26
  }
27
27
 
28
- /** Never rejects. Undefined means no decision: only a `pre-use` request without a deny. */
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(request);
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
- return request.stage === 'permission' ? { behavior: 'allow' } : undefined;
65
+ if (request.stage === 'permission') return { behavior: 'allow' };
66
+ return updatedInput ? { behavior: 'allow', updatedInput } : undefined;
51
67
  }
52
68
  }
53
69