@inixiative/agent-session 0.11.1 → 0.12.1

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
@@ -51,7 +51,8 @@ session.kill();
51
51
  blocks in the same user message, Codex `image` inputs with a `data:` URL. Anything else is
52
52
  refused before a byte is written.
53
53
  - **fork / resume / interrupt** and pre-send hooks (rewrite the outgoing
54
- message, for example to add context for that turn).
54
+ message, for example to add context for that turn). Resume is configuration, not a
55
+ method: build the session with `externalSessionId`.
55
56
  - **A resume whose native conversation is not found is typed.** Claude finding no transcript
56
57
  ("No conversation found with session ID": cleaned up, deleted, or under another project
57
58
  directory or config home) and Codex finding no rollout ("no rollout found for thread id") both
@@ -96,12 +97,13 @@ Codex runs only over `codex app-server --listen stdio://` (`CodexAppServerSessio
96
97
  Codex CLI 0.155 has no `mcp-server` subcommand.
97
98
 
98
99
  Muse Code runs over `muse serve` through Meta's `@muse-code/sdk` (`MuseSession`), pinned to
99
- the CLI's version (1.4.14): a host serving another version is refused at start. It bills the
100
+ the CLI's version (1.4.20): a host serving another version is refused at start. It bills the
100
101
  saved Muse login: metered keys and endpoints never reach it (nor Muse's own tool runs, which
101
102
  inherit its environment: a repo command needing `OPENAI_API_KEY` will not see it under Muse),
102
103
  and a resumed session must be on provider `meta`. Per-login isolation is `env: { XDG_CONFIG_HOME, XDG_DATA_HOME }` (login and
103
- sessions). `musePermissions(mode)` maps the host modes to an explicit permission profile, because
104
- the host default lets Muse's AI reviewer answer approvals instead of the gates. Questions arrive
104
+ sessions). The session's `permissionMode` (default `default`, gated) goes through `musePermissions(mode)`
105
+ to an explicit permission profile, because the host default lets Muse's AI reviewer answer
106
+ approvals instead of the gates. Questions arrive
105
107
  through `onUserInput()`; unanswered ones are cancelled with a reason. An approval offering
106
108
  no approve-once or reject choice is reported as an `error` event and left open (the turn ends
107
109
  at its timeout). Work Muse starts itself
@@ -125,7 +127,10 @@ session.onToolRequest(async (call) => {
125
127
 
126
128
  - `pre-use` (Claude only) runs before every call in every mode,
127
129
  `bypassPermissions` included; only a deny counts. A PreToolUse callback over
128
- the control protocol; one the session cannot read is denied.
130
+ the control protocol; one the session cannot read is denied. An allow may
131
+ carry `updatedInput`: the call runs with that input (the next gate sees it as
132
+ `toolInput`), and the runtime's own mode still decides whether it runs.
133
+ Foundry uses it to add the chorus's brief to a subagent's prompt.
129
134
  - `permission` is an ask the runtime's mode raised; the answer is final (no gate:
130
135
  denied). Claude: `--permission-prompt-tool stdio`; `decisionReason` says why it
131
136
  asked, `agentId` marks a subagent's call. Codex app-server: command and
@@ -218,16 +223,23 @@ const result = await decisions.decide(spec, { input, standby });
218
223
  own subagents go to the tool gate untouched.
219
224
  - **Fresh per call.** Each member serves one decision. A used one is replaced
220
225
  at once; a role with no held spare starts a member with its input inline.
226
+ - **Slices.** `dispatchers` (default 1) splits the roles into fixed slices,
227
+ each started by its own dispatcher, so refills are written at once. A
228
+ dispatcher writes one Agent call per member as output tokens: one
229
+ dispatcher refilled 75 members in ~20 s (2026-11-02). A role keeps its
230
+ slice while it is wanted; a new role joins the smallest.
221
231
  - **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.
232
+ past `recycleTokens` (100k) a new generation of its slice takes over and
233
+ the old one ends into the root once empty.
224
234
  - **Orphans.** A nested agent whose parent ended reports its own end to the
225
235
  main thread, which wakes the main model (Claude Code 2.1.300). Members of a
226
236
  dispatcher that ended unasked are therefore never retired or stopped, and an
227
237
  orphan's `answer` call is held for the session's life. Past `maxOrphans`
228
- (40) the host stops serving and its caller falls back.
238
+ (40) `serving()` turns false and `setup` reports `degraded`; `decide()` does
239
+ not refuse on its own, so the caller checks `serving()` and falls back.
229
240
  - **Limits.** Claude Code runs 20 subagents at once by default; the launch
230
- raises `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` (`maxSubagents`, 96). The
241
+ raises `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` to the user's own cap (20 when
242
+ unset) plus `maxSubagents` (76), so 96 by default. The
231
243
  session declares `perTaskStopAffordance`, so the user's Stop spares
232
244
  background agents.
233
245
 
@@ -243,9 +255,12 @@ const pool = new SubscriptionPool({ instances: [
243
255
  const { session, lease } = await pool.open({ runtime: "claude", model: "sonnet", organizationId: "org", preferredInstanceId: "work" });
244
256
  ```
245
257
 
246
- Routing is deterministic (quartile-balanced, owner-first or pinned) over
247
- observed limits, leases and health; stale or unknown limits exclude an instance
248
- and every exclusion has a reason. `continueOn` moves a thread only between
258
+ Routing is deterministic over observed limits, priority, leases and health, by a
259
+ named strategy (`quartile-balanced` by default, `owner-first`, `priority-balanced`,
260
+ `priority-strict`, `least-used`) or an explicit term list; `pinned` restricts it to
261
+ the preferred instance. By default stale or unknown limits exclude an instance
262
+ (`unknownUtilization: 'rank-last'` ranks it last instead), and every exclusion has a
263
+ reason. `continueOn` moves a thread only between
249
264
  instances that share native history, and says why when it cannot.
250
265
 
251
266
  ## Usage telemetry
@@ -263,6 +278,33 @@ model and tags) in `raw`; they may repeat across content blocks. Do not sum
263
278
  request snapshots together with result totals. Interrupted turns retain request
264
279
  snapshots in the artifact but do not fabricate a completed-turn total.
265
280
 
281
+ ## Secret scratch pad
282
+
283
+ A person can hand an agent a secret as `$$word`. `SecretScratchPad(dir)` lifts it out
284
+ of the message before the agent sees it (`lift`), into a private pad per thread
285
+ (`<thread>.pad`, 0600 in a 0700 directory), and leaves a reference in its place:
286
+ `†<message>.<n>` inside its thread, `†<thread>/<message>.<n>` from anywhere else.
287
+ `lift(text, at, { patterns })` also lifts matches of the caller's global regexes, for
288
+ tokens the person forgot to mark, and counts them as `recognised`.
289
+
290
+ The agent uses a value without reading it. `env` sets `KEY=value` in an env file,
291
+ `write` puts it in a file, and `pipe` runs a command with the value on its stdin,
292
+ from a reference or a key already in an env file; only the exit code comes back, and
293
+ a timeout kills the whole process group. `consume` (or `consume: true` on a use)
294
+ removes a block, `list` shows what is pending without values, `scrub` swaps held
295
+ values in text back to references, and `clear` deletes a pad.
296
+
297
+ Files `env` and `write` touched are recorded (`heldFiles()`). `privatePathGate`
298
+ (or `privatePathDenial` inside your own gate) denies a tool call naming one of them,
299
+ a `.private` path, a `privatePaths` glob or the pad directory; `isOwnTool` exempts the
300
+ scratch tool's own calls. A `privatePathGate` keeps the `heldFiles` list it was built
301
+ with: rebuild it after `env` or `write`, or call
302
+ `privatePathDenial(request, { scratchDir: pad.dir, heldFiles: pad.heldFiles() })` in your
303
+ own gate on each request. `SECRET_SCRATCH_GUIDANCE` is the text to give the agent.
304
+ The gate reads names, not contents, and a pipe command can still copy a value out:
305
+ it keeps honest agents from reading secrets, it is not a sandbox. Design and
306
+ limits: [tickets/AS-009](tickets/AS-009-secret-scratch-pad.md).
307
+
266
308
  ## Roadmap
267
309
 
268
310
  | Runtime | Status |
@@ -291,4 +333,4 @@ MIT
291
333
 
292
334
  ## Runtime
293
335
 
294
- Development and new continuation captures use Bun 1.4.2, pinned in `.bun-version` and `package.json`. Historical S0 recordings retain their original Bun 1.3.14 evidence; they are not new 1.4.2 captures.
336
+ Development and new continuation captures use Bun 1.4.4, pinned in `.bun-version` and `package.json`. Historical S0 recordings retain their original Bun 1.3.14 evidence; they are not new 1.4.4 captures.
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.1",
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",
@@ -18,6 +18,7 @@
18
18
  "check": "bun run typecheck && bun run lint && bun run test",
19
19
  "prepublishOnly": "bun run check",
20
20
  "measure:primed": "bun scripts/measure-primed.ts",
21
+ "record:muse": "bun scripts/record-muse.ts",
21
22
  "lint": "biome check .",
22
23
  "prepare": "if git rev-parse --git-dir >/dev/null 2>&1; then lefthook install; fi"
23
24
  },
@@ -36,12 +37,12 @@
36
37
  ],
37
38
  "license": "MIT",
38
39
  "dependencies": {
39
- "@inixiative/json-rules": "^3.16.0",
40
- "@muse-code/sdk": "1.4.14"
40
+ "@inixiative/json-rules": "^3.22.0",
41
+ "@muse-code/sdk": "1.4.20"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@biomejs/biome": "2.5.2",
44
- "@inixiative/config": "^0.5.186",
45
+ "@inixiative/config": "^0.5.212",
45
46
  "@types/bun": "1.4.4",
46
47
  "lefthook": "2.1.9",
47
48
  "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>;
@@ -40,17 +40,18 @@ import { TurnState } from './turn-state';
40
40
  // ---------------------------------------------------------------------------
41
41
  // MuseSession — one `muse serve` host per session, driven through Meta's
42
42
  // `@muse-code/sdk` MuseClient (Muse Session Protocol). The facade opens the
43
- // session, submits turns, answers approvals and folds the view; the calls it
44
- // lacks (turn/interrupt, turn/steer, session/fork, session/setApprovalMode,
45
- // usage/read, userInput/cancel) go through the same connection's `command`.
43
+ // session, submits turns, answers approvals and folds the view. turn/interrupt,
44
+ // turn/steer, session/fork, session/setApprovalMode, usage/read and
45
+ // userInput/cancel go through the same connection's `command`, written before
46
+ // the facade had them (1.4.14) and kept on the raw wire, whose types 1.4.20 keeps.
46
47
  // Every view notification is classified into the shared SessionEvent taxonomy.
47
48
  // The Muse session id is the externalSessionId used for cold resume.
48
49
  //
49
- // Wire contract: Muse Code 1.4.14 / @muse-code/sdk 1.4.14 (lockstep).
50
+ // Wire contract: Muse Code 1.4.20 / @muse-code/sdk 1.4.20 (lockstep).
50
51
  // ---------------------------------------------------------------------------
51
52
 
52
53
  /** The SDK version this session is built against; the served CLI must report the same. */
53
- export const MUSE_SDK_VERSION = '1.4.14';
54
+ export const MUSE_SDK_VERSION = '1.4.20';
54
55
 
55
56
  export type MuseApprovalMode = 'allowAll' | 'promptUnmatched';
56
57
  export type MusePermissionProfile = ':ask-me' | ':read-only' | ':unrestricted';
@@ -226,13 +227,15 @@ const CLIENT_INFO = { name: 'agent_session', title: 'agent-session', version: '1
226
227
 
227
228
  /**
228
229
  * Inherited variables that would bill, route or loosen Muse away from its saved subscription login
229
- * and the gates, by the names the 1.4.14 binary reads: metered keys and endpoints (META_, LLM_,
230
+ * and the gates, by the names the 1.4.20 binary reads: metered keys and endpoints (META_, LLM_,
230
231
  * MODEL_, LLAMA_, OPENROUTER_, ANTHROPIC_, OPENAI_), Meta's auth and key-mint endpoints, and MUSE_
231
- * switches for the model, experiments, contributor mode, the approval judge and a fail-open
232
- * profile. Muse's own tool runs inherit this environment, so they do not see these either.
232
+ * switches for the model, experiments, contributor mode, the approval judge, a fail-open profile,
233
+ * request routing and custom headers, the computer-use dev policy and identity skip, the launcher's
234
+ * channel and install switches, the credential backend, and the eval and test hooks (prompt injection,
235
+ * policy and grant overrides). The CA and TLS roots stay: a machine behind a proxy needs them. Muse's own tool runs inherit this environment, so they do not see these either.
233
236
  */
234
237
  const COMPETING_ENV =
235
- /^(META_|LLM_|MODEL_|LLAMA_|OPENROUTER_|ANTHROPIC_|OPENAI_|TBH_(AUTH|MINT)_BASE_URL$|MUSE_EXPERIMENTAL_|MUSE_(MODEL|CONTRIBUTOR_MODE|DISABLE_APPROVAL_JUDGE|PERMISSION_PROFILE_FAIL_OPEN)$)/;
238
+ /^(META_|LLM_|MODEL_|LLAMA_|OPENROUTER_|ANTHROPIC_|OPENAI_|TBH_(AUTH|MINT)_BASE_URL$|MUSE_EXPERIMENTAL_|TBH_(CREDENTIAL_BACKEND$|EVAL_|TEST_)|MUSE_CUA_DEV_|MUSE_(MODEL|CHANNEL|LAUNCHER_INSTALL|CONTRIBUTOR_MODE|DISABLE_APPROVAL_JUDGE|PERMISSION_PROFILE_FAIL_OPEN|WWW_ROUTING|CUSTOM_HEADERS)$)/;
236
239
 
237
240
  export class MuseSession implements HarnessSession {
238
241
  get transport() {
@@ -1914,10 +1917,10 @@ export class MuseSession implements HarnessSession {
1914
1917
  }
1915
1918
 
1916
1919
  // ---------------------------------------------------------------------------
1917
- // Default host: `muse serve` through the SDK's owned-process handshake
1920
+ // Default host: `muse serve` through the SDK's owned-process handshake; exported for scripts/record-muse.ts
1918
1921
  // ---------------------------------------------------------------------------
1919
1922
 
1920
- async function spawnMuseHost(launch: MuseLaunch): Promise<MuseHost> {
1923
+ export async function spawnMuseHost(launch: MuseLaunch): Promise<MuseHost> {
1921
1924
  const handshake = spawnMspConnection({
1922
1925
  command: launch.museBin,
1923
1926
  args: launch.args,
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
 
package/src/transport.ts CHANGED
@@ -143,12 +143,12 @@ export const TRANSPORTS: Readonly<Record<TransportKind, TransportDescriptor>> =
143
143
  },
144
144
  notes: [
145
145
  "`muse serve --trust-workspace` (plus `--disable-sandbox` under bypassPermissions) driven through Meta's `@muse-code/sdk` MuseClient, pinned to the installed CLI's version.",
146
- 'Billing is the saved Muse Code login: metered keys and endpoints (META_, LLM_, MODEL_, LLAMA_, OPENROUTER_, ANTHROPIC_, OPENAI_), Meta auth endpoints (TBH_) and the MUSE_ model, experiment, contributor, approval-judge and fail-open switches are removed from the child environment; a resumed or forked session must report provider `meta`.',
146
+ 'Billing is the saved Muse Code login: metered keys and endpoints (META_, LLM_, MODEL_, LLAMA_, OPENROUTER_, ANTHROPIC_, OPENAI_), Meta auth endpoints (TBH_) and the MUSE_ model, experiment, contributor, approval-judge, fail-open, routing, custom-header, computer-use dev, channel and install switches, and the TBH_ credential backend and eval/test hooks are removed from the child environment; a resumed or forked session must report provider `meta`.',
147
147
  'Approvals use an explicit permission profile (`:ask-me` supervised, `:read-only` plan, `:unrestricted` bypass), never the host default `:auto-review`, whose AI reviewer would decide instead of the gates; the committed profile, approval mode and (gated) a human reviewer are read back before any turn.',
148
148
  "Work Muse starts itself (cron firings, monitor and background-terminal wakes, goals, reminder agents, workflows, another client's messages) is folded like a turn's and emitted with `unattributedReason: 'task'`, plus a `task` lifecycle typed `muse-cron`, `muse-monitor`, `muse-goal`, `muse-reminder`, `muse-workflow` or `muse-turn`; it is seen only while this host has the session loaded.",
149
149
  "interruptNative() sends turn/interrupt; push() sends turn/steer; fork() runs session/fork on the new session's own host; readLimits() sends usage/read.",
150
150
  'userInput/request questions go to onUserInput(); without an answer they are cancelled with a reason, so a turn never waits on them.',
151
- 'Live-verified (muse 1.4.14): start, profile read-back, multi-stage shell and network approvals, text and tool events, token usage, usage/changed and usage/read (recorded in fixtures/muse); fork, resume with setApprovalMode and sessionNotFound were checked live without a model turn (not recorded). A steer (`turn/steer` into a running shell turn, answered in that turn) was checked live 2026-11-02 (not recorded). Images, interrupt, questions and Muse-initiated turns follow the SDK wire types without a live run.',
151
+ 'Live-verified (muse 1.4.20, 2026-11-15): start, profile read-back, multi-stage shell and network approvals, text and tool events, token usage, usage/changed and usage/read (recorded in fixtures/muse); fork, resume with setApprovalMode and sessionNotFound were checked live on 1.4.14 without a model turn (not recorded). A steer (`turn/steer` into a running shell turn, answered in that turn) and an interrupt (`turn/interrupt`, terminal `cancelled`) were checked live on 1.4.20, 2026-11-15 (not recorded). Images, questions and Muse-initiated turns follow the SDK wire types without a live run.',
152
152
  ],
153
153
  },
154
154
  'claude-agent-sdk': {