@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 +56 -14
- package/package.json +5 -4
- package/src/claude-code-session.ts +9 -1
- package/src/claude-in-session.ts +99 -28
- package/src/harness-session.ts +9 -1
- package/src/muse-session.ts +14 -11
- package/src/tool-gate.ts +19 -3
- package/src/transport.ts +2 -2
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
|
|
@@ -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.
|
|
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). `
|
|
104
|
-
the host default lets Muse's AI reviewer answer
|
|
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
|
|
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)
|
|
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` (
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
40
|
-
"@muse-code/sdk": "1.4.
|
|
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.
|
|
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' };
|
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. */
|
|
@@ -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/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/muse-session.ts
CHANGED
|
@@ -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
|
|
44
|
-
//
|
|
45
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
232
|
-
*
|
|
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
|
-
/**
|
|
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/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
|
|
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.
|
|
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': {
|