@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 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 | – | steer | – | – | – |
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.7.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.166",
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 push. Claude Code's stream-json stdin accepts user messages
505
- * only; there is no dedicated out-of-band signal channel. So the current
506
- * behavior is to emit a "push_ignored" error event — callers observe it
507
- * but the model does not see the payload until the next turn.
508
- *
509
- * A future improvement: push via the MCP bridge (FLOW.md Loop 4) so the
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<void> {
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=${payload.kind} — stream-json stdin has no OOB channel`,
517
- raw: payload,
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(
@@ -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<void> {
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 || !this._alive)
931
- this._emit({
932
- ...a.identity,
933
- admissionId: a.admissionId,
934
- kind: 'native_status',
935
- timestamp: Date.now(),
936
- raw: { type: 'push-unknown', kind: payload.kind },
937
- });
938
- else ignored(`turn/steer refused: ${(error as Error).message.slice(0, 200)}`);
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
  }
@@ -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`). No turn ran. The caller decides what comes next, typically a fresh session seeded
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: out-of-band signal injected into an in-flight turn.
487
- * Used by guards and Herald to deliver urgent feedback. Best-effort —
488
- * the underlying runtime may or may not honor the push.
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
- * Implementations that cannot support mid-turn push should emit a
491
- * "push_ignored" error event rather than throw, so callers can observe
492
- * that the push was attempted but not delivered.
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<void>;
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,