@inixiative/agent-session 0.7.0 → 0.9.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 +24 -10
- package/package.json +4 -3
- package/src/claude-agent-sdk-session.ts +11 -0
- package/src/claude-code-session.ts +197 -10
- package/src/codex-session.ts +16 -11
- package/src/harness-session.ts +16 -10
- package/src/index.ts +20 -0
- package/src/limits.ts +38 -2
- package/src/muse-session.ts +2115 -0
- package/src/pool.ts +6 -1
- package/src/transport.ts +40 -3
- package/src/transports.ts +5 -1
package/README.md
CHANGED
|
@@ -74,24 +74,37 @@ arrive as `rate_limit` events.
|
|
|
74
74
|
```ts
|
|
75
75
|
import { createSession, TRANSPORTS } from "@inixiative/agent-session";
|
|
76
76
|
|
|
77
|
-
const kind = "codex-app-server"; // or claude-cli, claude-agent-sdk
|
|
77
|
+
const kind = "codex-app-server"; // or claude-cli, claude-agent-sdk, muse-serve
|
|
78
78
|
if (TRANSPORTS[kind].capabilities.interrupt === "acknowledged") { /* … */ }
|
|
79
79
|
const session = createSession(kind, { cwd, model: "gpt-6-luna", env: { CODEX_HOME: profile } });
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
-
| | claude-cli | codex-app-server | claude-agent-sdk | acp | api |
|
|
83
|
-
|
|
84
|
-
| Status | ✅ live | ✅ live | ✅ live | stub | stub |
|
|
85
|
-
| Resume / fork | native / native | native / – | native / native | – | – |
|
|
86
|
-
| Interrupt | acknowledged | acknowledged | acknowledged | – | – |
|
|
87
|
-
| Push |
|
|
88
|
-
| Approvals | callback | callback | callback | – | – |
|
|
89
|
-
| Limits stream / poll | ✅ / ✅ | ✅ / ✅ | ✅ / – | – | – |
|
|
90
|
-
| Primed reset | fork | fork | – | – | – |
|
|
82
|
+
| | claude-cli | codex-app-server | muse-serve | claude-agent-sdk | acp | api |
|
|
83
|
+
|---|---|---|---|---|---|---|
|
|
84
|
+
| Status | ✅ live | ✅ live | ✅ live | ✅ live | stub | stub |
|
|
85
|
+
| Resume / fork | native / native | native / – | native / native | native / native | – | – |
|
|
86
|
+
| Interrupt | acknowledged | acknowledged | acknowledged | acknowledged | – | – |
|
|
87
|
+
| Push | steer | steer | steer | – | – | – |
|
|
88
|
+
| Approvals | callback | callback | callback | callback | – | – |
|
|
89
|
+
| Limits stream / poll | ✅ / ✅ | ✅ / ✅ | ✅ / ✅ | ✅ / – | – | – |
|
|
90
|
+
| Primed reset | fork | fork | – | – | – | – |
|
|
91
91
|
|
|
92
92
|
Codex runs only over `codex app-server --listen stdio://` (`CodexAppServerSession`);
|
|
93
93
|
Codex CLI 0.155 has no `mcp-server` subcommand.
|
|
94
94
|
|
|
95
|
+
Muse Code runs over `muse serve` through Meta's `@muse-code/sdk` (`MuseSession`), pinned to
|
|
96
|
+
the CLI's version (1.4.14): a host serving another version is refused at start. It bills the
|
|
97
|
+
saved Muse login: metered keys and endpoints never reach it (nor Muse's own tool runs, which
|
|
98
|
+
inherit its environment: a repo command needing `OPENAI_API_KEY` will not see it under Muse),
|
|
99
|
+
and a resumed session must be on provider `meta`. Per-login isolation is `env: { XDG_CONFIG_HOME, XDG_DATA_HOME }` (login and
|
|
100
|
+
sessions). `musePermissions(mode)` maps the host modes to an explicit permission profile, because
|
|
101
|
+
the host default lets Muse's AI reviewer answer approvals instead of the gates. Questions arrive
|
|
102
|
+
through `onUserInput()`; unanswered ones are cancelled with a reason. An approval offering
|
|
103
|
+
no approve-once or reject choice is reported as an `error` event and left open (the turn ends
|
|
104
|
+
at its timeout). Work Muse starts itself
|
|
105
|
+
(cron firings, monitors, goals, reminder agents) is emitted too, marked
|
|
106
|
+
`unattributedReason: "task"` with a `task` lifecycle typed `muse-cron`, `muse-monitor`, ….
|
|
107
|
+
|
|
95
108
|
Full matrix, notes and what is stubbed: [docs/transports-pools-routing.md](docs/transports-pools-routing.md).
|
|
96
109
|
|
|
97
110
|
### Tool gate
|
|
@@ -254,6 +267,7 @@ snapshots in the artifact but do not fabricate a completed-turn total.
|
|
|
254
267
|
| Claude Code | ✅ shipped |
|
|
255
268
|
| Codex app-server | ✅ shipped (`CodexAppServerSession`, `CodexPrimedSessions`) |
|
|
256
269
|
| Claude Agent SDK | ✅ shipped (`ClaudeAgentSdkSession`; caller supplies `query`) |
|
|
270
|
+
| Muse Code | ✅ shipped (`MuseSession`, through `@muse-code/sdk`) |
|
|
257
271
|
| ACP agents / direct API | typed stubs |
|
|
258
272
|
| Gemini CLI | planned |
|
|
259
273
|
| Grok CLI | planned (when its headless/stream mode matures) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/agent-session",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -36,11 +36,12 @@
|
|
|
36
36
|
],
|
|
37
37
|
"license": "MIT",
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@inixiative/json-rules": "^3.16.0"
|
|
39
|
+
"@inixiative/json-rules": "^3.16.0",
|
|
40
|
+
"@muse-code/sdk": "1.4.14"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
42
43
|
"@biomejs/biome": "2.5.2",
|
|
43
|
-
"@inixiative/config": "^0.5.
|
|
44
|
+
"@inixiative/config": "^0.5.176",
|
|
44
45
|
"@types/bun": "1.4.4",
|
|
45
46
|
"lefthook": "2.1.9",
|
|
46
47
|
"typescript": "6.0.3"
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type ClaudeCodeSessionConfig,
|
|
24
24
|
type PipedSubprocess,
|
|
25
25
|
} from './claude-code-session';
|
|
26
|
+
import type { PushOutcome } from './harness-session';
|
|
26
27
|
import { TRANSPORTS } from './transport';
|
|
27
28
|
|
|
28
29
|
/** The subset of the SDK's Query this transport uses. */
|
|
@@ -429,6 +430,16 @@ export class ClaudeAgentSdkSession extends ClaudeCodeSession {
|
|
|
429
430
|
this._sdk = config;
|
|
430
431
|
}
|
|
431
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Not wired here: the bridge forwards a steer's `priority` to the SDK's input, but whether the SDK
|
|
435
|
+
* stream surfaces the `command_lifecycle` frames a steer is tracked by has not been verified.
|
|
436
|
+
*/
|
|
437
|
+
override async push(payload: { kind: string; text: string }): Promise<PushOutcome> {
|
|
438
|
+
const reason = 'the Agent SDK transport does not steer (not verified)';
|
|
439
|
+
this._emitPushIgnored(payload.kind, reason);
|
|
440
|
+
return { outcome: 'ignored', reason };
|
|
441
|
+
}
|
|
442
|
+
|
|
432
443
|
/** get_usage is not reachable through the SDK Query API. */
|
|
433
444
|
override async readLimits(): Promise<never> {
|
|
434
445
|
throw Error(
|
|
@@ -51,6 +51,7 @@ import type {
|
|
|
51
51
|
BeforeSendHook,
|
|
52
52
|
HarnessSession,
|
|
53
53
|
NativeInterruptOutcome,
|
|
54
|
+
PushOutcome,
|
|
54
55
|
SessionArtifact,
|
|
55
56
|
SessionEvent,
|
|
56
57
|
SessionEventHandler,
|
|
@@ -218,8 +219,40 @@ interface QueuedTurn {
|
|
|
218
219
|
timeout: number;
|
|
219
220
|
resolve: (result: SessionResult) => void;
|
|
220
221
|
reject: (error: Error) => void;
|
|
222
|
+
/**
|
|
223
|
+
* Messages push() wrote into this turn, by uuid, with their `command_lifecycle` state. One not yet
|
|
224
|
+
* `started` when a result arrives is answered after it, in a follow-on result of the same turn.
|
|
225
|
+
*/
|
|
226
|
+
steers?: Map<string, SteerState>;
|
|
227
|
+
/** The turn reached a result while a steer was still waiting: it settles on the next result. */
|
|
228
|
+
held?: { nativeOutcome: 'completed'; tokens?: SessionTokens };
|
|
221
229
|
}
|
|
222
230
|
|
|
231
|
+
/** `written` until the CLI's `command_lifecycle` names it; `unconfirmed` after push() gave up waiting. */
|
|
232
|
+
type SteerState = 'written' | 'queued' | 'started' | 'completed' | 'cancelled' | 'unconfirmed';
|
|
233
|
+
const STEER_ACK_MS = 10_000;
|
|
234
|
+
|
|
235
|
+
const TOKEN_KEYS = [
|
|
236
|
+
'input',
|
|
237
|
+
'output',
|
|
238
|
+
'cacheRead',
|
|
239
|
+
'cacheWrite',
|
|
240
|
+
'cacheWrite5m',
|
|
241
|
+
'cacheWrite1h',
|
|
242
|
+
'thinking',
|
|
243
|
+
] as const;
|
|
244
|
+
/** One turn's usage over several results (a steer answered after a result). Provider usage is the last result's. */
|
|
245
|
+
const addTokens = (
|
|
246
|
+
a: SessionTokens | undefined,
|
|
247
|
+
b: SessionTokens | undefined,
|
|
248
|
+
): SessionTokens | undefined => {
|
|
249
|
+
if (!a || !b) return b ?? a;
|
|
250
|
+
const sum: SessionTokens = { ...b };
|
|
251
|
+
for (const key of TOKEN_KEYS)
|
|
252
|
+
if (a[key] !== undefined || b[key] !== undefined) sum[key] = (a[key] ?? 0) + (b[key] ?? 0);
|
|
253
|
+
return sum;
|
|
254
|
+
};
|
|
255
|
+
|
|
223
256
|
// ---------------------------------------------------------------------------
|
|
224
257
|
// Session
|
|
225
258
|
// ---------------------------------------------------------------------------
|
|
@@ -342,6 +375,10 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
342
375
|
private _agentsDir?: string;
|
|
343
376
|
/** Inbound control requests awaiting a gate decision, by request id. */
|
|
344
377
|
private _inbound = new Map<string, AbortController>();
|
|
378
|
+
/** push() calls waiting for the CLI to name their message, by uuid. */
|
|
379
|
+
private _steerAcks = new Map<string, (state: SteerState) => void>();
|
|
380
|
+
/** Steers written and not yet ended natively: a late `completed` after their turn settled is theirs. */
|
|
381
|
+
private _steerUuids = new Set<string>();
|
|
345
382
|
private readonly _hookCallbackId = `agent-session-pre-tool-${crypto.randomUUID()}`;
|
|
346
383
|
private _limits?: LimitSnapshot;
|
|
347
384
|
private _controlSeq = 0;
|
|
@@ -501,23 +538,128 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
501
538
|
}
|
|
502
539
|
|
|
503
540
|
/**
|
|
504
|
-
* Mid-turn
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
* signal reaches the in-flight turn as a tool result the model must read.
|
|
541
|
+
* Mid-turn steer: a stream-json user message with `priority: "next"` and its own `uuid`, written
|
|
542
|
+
* while the owned turn runs. The CLI answers `command_lifecycle` queued → started → completed for
|
|
543
|
+
* that uuid. It takes the message in at the turn's next model request (after a tool result), or,
|
|
544
|
+
* when the turn's reply was already final, runs it right after that result as a follow-on result:
|
|
545
|
+
* either way it is this turn's, and send() settles on the last result (validated against claude
|
|
546
|
+
* 2.1.316, fixtures/s0/claude-steer-*.jsonl).
|
|
511
547
|
*/
|
|
512
|
-
async push(payload: { kind: string; text: string }): Promise<
|
|
548
|
+
async push(payload: { kind: string; text: string }): Promise<PushOutcome> {
|
|
549
|
+
const ignored = (reason: string): PushOutcome => {
|
|
550
|
+
this._emitPushIgnored(payload.kind, reason);
|
|
551
|
+
return { outcome: 'ignored', reason };
|
|
552
|
+
};
|
|
553
|
+
const turn = this._inflight;
|
|
554
|
+
const a = turn?.evidence;
|
|
555
|
+
if (!turn || !a || a.dispatch !== 'attempted' || a.nativeOutcome !== 'unknown')
|
|
556
|
+
return ignored('no owned in-flight turn');
|
|
557
|
+
if (!this._proc || !this._alive) return ignored('session not running');
|
|
558
|
+
const uuid = crypto.randomUUID();
|
|
559
|
+
turn.steers ??= new Map();
|
|
560
|
+
const steers = turn.steers;
|
|
561
|
+
steers.set(uuid, 'written');
|
|
562
|
+
this._steerUuids.add(uuid);
|
|
563
|
+
const acknowledged = new Promise<SteerState>((resolve) => this._steerAcks.set(uuid, resolve));
|
|
564
|
+
try {
|
|
565
|
+
this._proc.stdin.write(
|
|
566
|
+
`${JSON.stringify({
|
|
567
|
+
type: 'user',
|
|
568
|
+
message: { role: 'user', content: [{ type: 'text', text: payload.text }] },
|
|
569
|
+
uuid,
|
|
570
|
+
parent_tool_use_id: null,
|
|
571
|
+
priority: 'next',
|
|
572
|
+
})}\n`,
|
|
573
|
+
);
|
|
574
|
+
this._proc.stdin.flush();
|
|
575
|
+
} catch (error) {
|
|
576
|
+
steers.delete(uuid);
|
|
577
|
+
this._steerAcks.delete(uuid);
|
|
578
|
+
return ignored(`write failed: ${(error as Error).message.slice(0, 200)}`);
|
|
579
|
+
}
|
|
580
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
581
|
+
const state = await Promise.race([
|
|
582
|
+
acknowledged,
|
|
583
|
+
new Promise<'unconfirmed'>((resolve) => {
|
|
584
|
+
timer = setTimeout(() => resolve('unconfirmed'), STEER_ACK_MS);
|
|
585
|
+
}),
|
|
586
|
+
]).finally(() => clearTimeout(timer));
|
|
587
|
+
this._steerAcks.delete(uuid);
|
|
588
|
+
const identity = { ...a.identity, admissionId: a.admissionId };
|
|
589
|
+
if (state === 'cancelled')
|
|
590
|
+
return ignored('the runtime cancelled it before the turn took it in');
|
|
591
|
+
if (state === 'unconfirmed') {
|
|
592
|
+
// Never named by the CLI: it no longer holds the turn open.
|
|
593
|
+
if (steers.get(uuid) === 'written') steers.set(uuid, 'unconfirmed');
|
|
594
|
+
this._releaseHeld(turn);
|
|
595
|
+
this._emit({
|
|
596
|
+
...identity,
|
|
597
|
+
kind: 'native_status',
|
|
598
|
+
timestamp: Date.now(),
|
|
599
|
+
raw: { type: 'push-unknown', kind: payload.kind },
|
|
600
|
+
});
|
|
601
|
+
return { outcome: 'unknown' };
|
|
602
|
+
}
|
|
603
|
+
this._emit({
|
|
604
|
+
...identity,
|
|
605
|
+
kind: 'native_status',
|
|
606
|
+
timestamp: Date.now(),
|
|
607
|
+
raw: { type: 'push-steered', kind: payload.kind, uuid },
|
|
608
|
+
});
|
|
609
|
+
return { outcome: 'steered' };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
protected _emitPushIgnored(kind: string, reason: string): void {
|
|
513
613
|
this._emit({
|
|
514
614
|
kind: 'error',
|
|
515
615
|
timestamp: Date.now(),
|
|
516
|
-
text: `push_ignored: kind=${
|
|
517
|
-
raw:
|
|
616
|
+
text: `push_ignored: kind=${kind} — ${reason}`,
|
|
617
|
+
raw: { kind },
|
|
518
618
|
});
|
|
519
619
|
}
|
|
520
620
|
|
|
621
|
+
/** A steer of `turn` the CLI has not started yet (or not named yet). */
|
|
622
|
+
private _steerWaiting(turn: QueuedTurn): boolean {
|
|
623
|
+
for (const state of turn.steers?.values() ?? [])
|
|
624
|
+
if (state === 'written' || state === 'queued') return true;
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* A held turn settles with the result it already had once no steer can still produce a result:
|
|
630
|
+
* none waiting, and none started (a started steer of a held turn is its follow-on run).
|
|
631
|
+
*/
|
|
632
|
+
private _releaseHeld(turn: QueuedTurn): void {
|
|
633
|
+
if (!turn.held || this._inflight !== turn || this._steerWaiting(turn)) return;
|
|
634
|
+
for (const state of turn.steers?.values() ?? []) if (state === 'started') return;
|
|
635
|
+
const a = turn.evidence;
|
|
636
|
+
a.tokens = turn.held.tokens;
|
|
637
|
+
a.nativeOutcome = turn.held.nativeOutcome;
|
|
638
|
+
turn.held = undefined;
|
|
639
|
+
this._resolveTurn();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Withdraw `turn`'s steers the CLI has not started, so none outlives it as an unowned turn. */
|
|
643
|
+
private async _withdrawSteers(turn: QueuedTurn, timeoutMs: number): Promise<void> {
|
|
644
|
+
const waiting = [...(turn.steers ?? [])].filter(
|
|
645
|
+
([, state]) => state === 'queued' || state === 'written',
|
|
646
|
+
);
|
|
647
|
+
await Promise.all(
|
|
648
|
+
waiting.map(([uuid]) =>
|
|
649
|
+
this._controlRequest(
|
|
650
|
+
{ subtype: 'cancel_async_message', message_uuid: uuid },
|
|
651
|
+
timeoutMs,
|
|
652
|
+
).then(
|
|
653
|
+
(response) => {
|
|
654
|
+
if ((response.response as { cancelled?: unknown } | undefined)?.cancelled === true)
|
|
655
|
+
turn.steers?.set(uuid, 'cancelled');
|
|
656
|
+
},
|
|
657
|
+
() => {},
|
|
658
|
+
),
|
|
659
|
+
),
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
521
663
|
// ---------------------------------------------------------------------------
|
|
522
664
|
// start() — spawn the persistent process
|
|
523
665
|
// ---------------------------------------------------------------------------
|
|
@@ -822,6 +964,10 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
822
964
|
if (turn?.evidence.dispatch !== 'attempted' || turn.evidence.nativeOutcome !== 'unknown')
|
|
823
965
|
return 'no-turn';
|
|
824
966
|
const deadline = Date.now() + (opts?.timeoutMs ?? 5_000);
|
|
967
|
+
// A stop is for the whole turn: a steer still waiting dies with it, never runs after it.
|
|
968
|
+
await this._withdrawSteers(turn, Math.max(1, Math.min(2_000, deadline - Date.now())));
|
|
969
|
+
this._releaseHeld(turn);
|
|
970
|
+
if (turn.evidence.nativeOutcome !== 'unknown') return 'acknowledged';
|
|
825
971
|
try {
|
|
826
972
|
await this._controlRequest({ subtype: 'interrupt' }, Math.max(1, deadline - Date.now()));
|
|
827
973
|
} catch {
|
|
@@ -1278,6 +1424,30 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1278
1424
|
return;
|
|
1279
1425
|
}
|
|
1280
1426
|
|
|
1427
|
+
// A steer's lifecycle: the CLI naming a message push() wrote into the owned turn.
|
|
1428
|
+
if (raw.type === 'command_lifecycle' && typeof raw.command_uuid === 'string') {
|
|
1429
|
+
const turn = this._inflight;
|
|
1430
|
+
const state = raw.state;
|
|
1431
|
+
const ours = this._steerUuids.has(raw.command_uuid);
|
|
1432
|
+
if (ours && (state === 'completed' || state === 'cancelled'))
|
|
1433
|
+
this._steerUuids.delete(raw.command_uuid);
|
|
1434
|
+
if (
|
|
1435
|
+
turn?.steers?.has(raw.command_uuid) &&
|
|
1436
|
+
(state === 'queued' ||
|
|
1437
|
+
state === 'started' ||
|
|
1438
|
+
state === 'completed' ||
|
|
1439
|
+
state === 'cancelled')
|
|
1440
|
+
) {
|
|
1441
|
+
turn.steers.set(raw.command_uuid, state);
|
|
1442
|
+
this._steerAcks.get(raw.command_uuid)?.(state);
|
|
1443
|
+
if (state === 'cancelled') this._releaseHeld(turn);
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
// A steer's own end after its turn settled (its follow-on result settled the turn first).
|
|
1447
|
+
if (!ours) this._unattributed(raw, 'no-admission');
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1281
1451
|
// Account limits are account-level evidence, never part of an admission's output.
|
|
1282
1452
|
if (raw.type === 'rate_limit_event') {
|
|
1283
1453
|
const snapshot = claudeRateLimitSnapshot(raw.rate_limit_info);
|
|
@@ -1353,6 +1523,7 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1353
1523
|
correlation: 'ordered-stream',
|
|
1354
1524
|
};
|
|
1355
1525
|
const terminal = raw.type === 'result';
|
|
1526
|
+
let holding = false;
|
|
1356
1527
|
if (terminal) {
|
|
1357
1528
|
if (terminalKey) this._seenTerminals.add(terminalKey);
|
|
1358
1529
|
const subtype = typeof raw.subtype === 'string' ? raw.subtype : undefined;
|
|
@@ -1369,6 +1540,12 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1369
1540
|
(status !== undefined && status >= 400) ||
|
|
1370
1541
|
reason === 'api_error';
|
|
1371
1542
|
a.nativeOutcome = failed ? 'failed' : subtype === 'success' ? 'completed' : 'unknown';
|
|
1543
|
+
// A steer the CLI has not started yet runs after this result, still in this turn: the turn
|
|
1544
|
+
// stays open (and this result reads as an intermediate one) until the steer's own result.
|
|
1545
|
+
if (a.nativeOutcome === 'completed' && this._steerWaiting(turn)) {
|
|
1546
|
+
holding = true;
|
|
1547
|
+
a.nativeOutcome = 'unknown';
|
|
1548
|
+
}
|
|
1372
1549
|
a.terminal = {
|
|
1373
1550
|
type: 'result',
|
|
1374
1551
|
eventId: terminalKey,
|
|
@@ -1405,6 +1582,16 @@ export class ClaudeCodeSession implements HarnessSession {
|
|
|
1405
1582
|
this._emit(e);
|
|
1406
1583
|
}
|
|
1407
1584
|
if (terminal) {
|
|
1585
|
+
// Each result reports its own run's usage: the turn's is their sum.
|
|
1586
|
+
const tokens = addTokens(turn.held?.tokens, a.tokens);
|
|
1587
|
+
if (holding) {
|
|
1588
|
+
turn.held = { nativeOutcome: 'completed', ...(tokens ? { tokens } : {}) };
|
|
1589
|
+
a.tokens = tokens;
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
turn.held = undefined;
|
|
1593
|
+
a.tokens = tokens;
|
|
1594
|
+
if (a.nativeOutcome === 'failed') void this._withdrawSteers(turn, 2_000);
|
|
1408
1595
|
if (a.nativeOutcome !== 'unknown') this._resolveTurn();
|
|
1409
1596
|
else
|
|
1410
1597
|
this._rejectInflight(
|
package/src/codex-session.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
BeforeSendHook,
|
|
21
21
|
HarnessSession,
|
|
22
22
|
NativeInterruptOutcome,
|
|
23
|
+
PushOutcome,
|
|
23
24
|
SessionArtifact,
|
|
24
25
|
SessionEvent,
|
|
25
26
|
SessionEventHandler,
|
|
@@ -886,15 +887,17 @@ export class CodexAppServerSession implements HarnessSession {
|
|
|
886
887
|
}
|
|
887
888
|
|
|
888
889
|
/** Mid-turn steer (turn/steer) into the owned in-flight turn; otherwise records push_ignored. */
|
|
889
|
-
async push(payload: { kind: string; text: string }): Promise<
|
|
890
|
+
async push(payload: { kind: string; text: string }): Promise<PushOutcome> {
|
|
890
891
|
const a = this._inflight?.evidence;
|
|
891
|
-
const ignored = (reason: string) =>
|
|
892
|
+
const ignored = (reason: string): PushOutcome => {
|
|
892
893
|
this._emit({
|
|
893
894
|
kind: 'error',
|
|
894
895
|
timestamp: Date.now(),
|
|
895
896
|
text: `push_ignored: kind=${payload.kind} — ${reason}`,
|
|
896
897
|
raw: { kind: payload.kind },
|
|
897
898
|
});
|
|
899
|
+
return { outcome: 'ignored', reason };
|
|
900
|
+
};
|
|
898
901
|
if (
|
|
899
902
|
a?.dispatch !== 'attempted' ||
|
|
900
903
|
a.nativeOutcome !== 'unknown' ||
|
|
@@ -925,17 +928,19 @@ export class CodexAppServerSession implements HarnessSession {
|
|
|
925
928
|
timestamp: Date.now(),
|
|
926
929
|
raw: { type: 'push-steered', kind: payload.kind },
|
|
927
930
|
});
|
|
931
|
+
return { outcome: 'steered' };
|
|
928
932
|
} catch (error) {
|
|
929
933
|
// A refusal is the runtime's answer; no answer leaves delivery unknown, never "ignored".
|
|
930
|
-
if (timedOut
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
}
|
|
938
|
-
|
|
934
|
+
if (!timedOut && this._alive)
|
|
935
|
+
return ignored(`turn/steer refused: ${(error as Error).message.slice(0, 200)}`);
|
|
936
|
+
this._emit({
|
|
937
|
+
...a.identity,
|
|
938
|
+
admissionId: a.admissionId,
|
|
939
|
+
kind: 'native_status',
|
|
940
|
+
timestamp: Date.now(),
|
|
941
|
+
raw: { type: 'push-unknown', kind: payload.kind },
|
|
942
|
+
});
|
|
943
|
+
return { outcome: 'unknown' };
|
|
939
944
|
} finally {
|
|
940
945
|
clearTimeout(timer);
|
|
941
946
|
}
|
package/src/harness-session.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// captures every event for Oracle, and supports fork/interrupt.
|
|
12
12
|
//
|
|
13
13
|
// Implementations: ClaudeCodeSession, CodexAppServerSession,
|
|
14
|
-
// ClaudeAgentSdkSession. How a model is reached is the session's transport
|
|
14
|
+
// ClaudeAgentSdkSession, MuseSession. How a model is reached is the session's transport
|
|
15
15
|
// (see transport.ts); every transport implements this one contract.
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
@@ -84,12 +84,13 @@ export class SessionTurnError extends Error {
|
|
|
84
84
|
* session's cwd and config home: Claude found no transcript ("No conversation found with session
|
|
85
85
|
* ID": cleaned up, deleted, or kept under another project directory or `CLAUDE_CONFIG_DIR`), or
|
|
86
86
|
* Codex found no rollout ("no rollout found for thread id": deleted, or under another
|
|
87
|
-
* `CODEX_HOME`)
|
|
87
|
+
* `CODEX_HOME`), or Muse answered `sessionNotFound` (deleted, under another `XDG_DATA_HOME`, or
|
|
88
|
+
* an ephemeral host that died with it). No turn ran. The caller decides what comes next, typically a fresh session seeded
|
|
88
89
|
* from its own record of the conversation; `cwd` says where the lookup was made.
|
|
89
90
|
*/
|
|
90
91
|
export class NativeBindingMissingError extends Error {
|
|
91
92
|
constructor(
|
|
92
|
-
readonly runtime: 'claude-code' | 'codex-app-server',
|
|
93
|
+
readonly runtime: 'claude-code' | 'codex-app-server' | 'muse',
|
|
93
94
|
readonly binding: string,
|
|
94
95
|
message: string,
|
|
95
96
|
readonly cwd?: string,
|
|
@@ -410,6 +411,11 @@ export type ToolGate = (request: ToolRequest) => ToolDecision | Promise<ToolDeci
|
|
|
410
411
|
/** `acknowledged`: the runtime confirmed the stop and the turn reached a native terminal. */
|
|
411
412
|
export type NativeInterruptOutcome = 'acknowledged' | 'no-turn' | 'unacknowledged';
|
|
412
413
|
|
|
414
|
+
/** What became of a push(): `steered` (the runtime took it into the owned turn), `unknown` (sent, never confirmed), `ignored` (not delivered; `reason` says why). */
|
|
415
|
+
export type PushOutcome =
|
|
416
|
+
| { readonly outcome: 'steered' | 'unknown' }
|
|
417
|
+
| { readonly outcome: 'ignored'; readonly reason: string };
|
|
418
|
+
|
|
413
419
|
export interface HarnessSession {
|
|
414
420
|
/** How this session reaches its model, with the capabilities that transport declares. */
|
|
415
421
|
readonly transport?: TransportDescriptor;
|
|
@@ -483,15 +489,15 @@ export interface HarnessSession {
|
|
|
483
489
|
onToolRequest?(gate: ToolGate): () => void;
|
|
484
490
|
|
|
485
491
|
/**
|
|
486
|
-
* Mid-turn push:
|
|
487
|
-
*
|
|
488
|
-
*
|
|
492
|
+
* Mid-turn push (steer): add input to the owned in-flight turn, where the transport declares
|
|
493
|
+
* `push: "steer"`. It never opens a turn of its own: the pushed text is answered inside the turn
|
|
494
|
+
* it was pushed into, whose send() settles once that turn (and everything pushed into it) ends.
|
|
489
495
|
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
496
|
+
* Never throws. `ignored` (with an emitted "push_ignored" error event) means nothing was
|
|
497
|
+
* delivered: no owned in-flight turn, the runtime refused, or the transport cannot steer.
|
|
498
|
+
* `unknown` means it was written but the runtime never confirmed it.
|
|
493
499
|
*/
|
|
494
|
-
push(payload: { kind: string; text: string }): Promise<
|
|
500
|
+
push(payload: { kind: string; text: string }): Promise<PushOutcome>;
|
|
495
501
|
|
|
496
502
|
/** Full session record for Oracle evaluation. */
|
|
497
503
|
artifact(): SessionArtifact;
|
package/src/index.ts
CHANGED
|
@@ -67,8 +67,28 @@ export {
|
|
|
67
67
|
type LimitWindow,
|
|
68
68
|
limitUtilization,
|
|
69
69
|
mergeLimits,
|
|
70
|
+
museLimitSnapshot,
|
|
70
71
|
} from './limits';
|
|
71
72
|
export { type LimitProbeOptions, probeClaudeLimits, probeCodexLimits } from './limits-probe';
|
|
73
|
+
// Muse Code adapter: one `muse serve` host per session through Meta's @muse-code/sdk.
|
|
74
|
+
export {
|
|
75
|
+
MUSE_SDK_VERSION,
|
|
76
|
+
type MuseApprovalMode,
|
|
77
|
+
type MuseConnect,
|
|
78
|
+
type MuseHost,
|
|
79
|
+
type MuseInitializeResult,
|
|
80
|
+
type MuseLaunch,
|
|
81
|
+
type MusePermissionProfile,
|
|
82
|
+
type MusePermissions,
|
|
83
|
+
type MuseReasoningEffort,
|
|
84
|
+
MuseSession,
|
|
85
|
+
type MuseSessionConfig,
|
|
86
|
+
type MuseUserInputAnswer,
|
|
87
|
+
type MuseUserInputHandler,
|
|
88
|
+
type MuseUserInputQuestion,
|
|
89
|
+
type MuseUserInputRequest,
|
|
90
|
+
musePermissions,
|
|
91
|
+
} from './muse-session';
|
|
72
92
|
export {
|
|
73
93
|
ContinuityError,
|
|
74
94
|
type ContinuityRefusal,
|
package/src/limits.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Subscription usage limits, normalized across runtimes. Account identity
|
|
2
2
|
// (email, account/organization IDs) is never read into a snapshot.
|
|
3
3
|
|
|
4
|
-
export type LimitRuntime = 'claude' | 'codex';
|
|
4
|
+
export type LimitRuntime = 'claude' | 'codex' | 'muse';
|
|
5
5
|
|
|
6
6
|
export interface LimitWindow {
|
|
7
|
-
/** Stable per runtime: Claude `five_hour`/`seven_day`/…, Codex `primary`/`secondary`. */
|
|
7
|
+
/** Stable per runtime: Claude `five_hour`/`seven_day`/…, Codex `primary`/`secondary`, Muse `window`/`weekly`. */
|
|
8
8
|
readonly id: string;
|
|
9
9
|
/** 0–100. */
|
|
10
10
|
readonly usedPercent: number;
|
|
@@ -104,6 +104,42 @@ export function codexLimitSnapshot(
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Muse `usage/read` result (`{ usage }`) or `usage/changed` params (the usage itself): the plan's
|
|
109
|
+
* rolling `window` (five hours) and `weekly` windows, in percent used. Meta reports the windows from
|
|
110
|
+
* its latest model response, so a host that has not served a turn yet reports none. `tier` is not read:
|
|
111
|
+
* on the wire it is a numeric id (the CLI shows the plan's name), not a label known to be account-free.
|
|
112
|
+
*/
|
|
113
|
+
export function museLimitSnapshot(
|
|
114
|
+
value: unknown,
|
|
115
|
+
source: LimitSnapshot['source'],
|
|
116
|
+
observedAt = Date.now(),
|
|
117
|
+
): LimitSnapshot | undefined {
|
|
118
|
+
const outer = object(value);
|
|
119
|
+
const usage = object(outer?.usage) ?? outer;
|
|
120
|
+
const rolling = object(usage?.window),
|
|
121
|
+
weekly = object(usage?.weekly);
|
|
122
|
+
const ms = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
|
|
123
|
+
const windows = [
|
|
124
|
+
...window(
|
|
125
|
+
'window',
|
|
126
|
+
percent(rolling?.usedPercent),
|
|
127
|
+
ms(rolling?.resetsAtMs),
|
|
128
|
+
typeof rolling?.windowDurationMins === 'number' ? rolling.windowDurationMins : undefined,
|
|
129
|
+
),
|
|
130
|
+
...window('weekly', percent(weekly?.usedPercent), ms(weekly?.resetsAtMs), 10_080),
|
|
131
|
+
];
|
|
132
|
+
if (!windows.length) return undefined;
|
|
133
|
+
return {
|
|
134
|
+
runtime: 'muse',
|
|
135
|
+
source,
|
|
136
|
+
observedAt: ms(usage?.observedAtMs) ?? observedAt,
|
|
137
|
+
windows,
|
|
138
|
+
// Explicit both ways, so a later report clears a block an earlier one (or a quota stop) set.
|
|
139
|
+
blocked: windows.some((w) => w.usedPercent >= 100),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
107
143
|
/** Claude CLI stream-json `rate_limit_event.rate_limit_info`. Utilization is a 0–1 fraction. */
|
|
108
144
|
export function claudeRateLimitSnapshot(
|
|
109
145
|
info: unknown,
|