@inixiative/agent-session 0.6.8 → 0.8.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.
@@ -0,0 +1,2110 @@
1
+ import {
2
+ type ApprovalDecisionInput,
3
+ type ApprovalFailure,
4
+ type ApprovalHandler,
5
+ type Connection,
6
+ MspError,
7
+ MuseClient,
8
+ MuseSessionDiscardedError,
9
+ type Session as NativeSession,
10
+ PermissionProfileNotApplied,
11
+ readSessionDurability,
12
+ type SessionDurabilityProfile,
13
+ spawnMspConnection,
14
+ } from '@muse-code/sdk';
15
+ import type { HostPermissionMode } from './codex-session';
16
+ import {
17
+ type BeforeSendHook,
18
+ type HarnessSession,
19
+ NativeBindingMissingError,
20
+ type NativeInterruptOutcome,
21
+ type SessionArtifact,
22
+ type SessionAttempt,
23
+ type SessionEvent,
24
+ type SessionEventHandler,
25
+ type SessionImage,
26
+ type SessionResult,
27
+ type SessionSendOptions,
28
+ type SessionTokens,
29
+ sendableImages,
30
+ type TaskLifecycle,
31
+ type ToolGate,
32
+ } from './harness-session';
33
+ import { type LimitSnapshot, mergeLimits, museLimitSnapshot } from './limits';
34
+ import { retainEvidence } from './retained-evidence';
35
+ import { mcpToolParts, ToolGates } from './tool-gate';
36
+ import { TRANSPORTS } from './transport';
37
+ import { TurnState } from './turn-state';
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // MuseSession — one `muse serve` host per session, driven through Meta's
41
+ // `@muse-code/sdk` MuseClient (Muse Session Protocol). The facade opens the
42
+ // session, submits turns, answers approvals and folds the view; the calls it
43
+ // lacks (turn/interrupt, turn/steer, session/fork, session/setApprovalMode,
44
+ // usage/read, userInput/cancel) go through the same connection's `command`.
45
+ // Every view notification is classified into the shared SessionEvent taxonomy.
46
+ // The Muse session id is the externalSessionId used for cold resume.
47
+ //
48
+ // Wire contract: Muse Code 1.4.14 / @muse-code/sdk 1.4.14 (lockstep).
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /** The SDK version this session is built against; the served CLI must report the same. */
52
+ export const MUSE_SDK_VERSION = '1.4.14';
53
+
54
+ export type MuseApprovalMode = 'allowAll' | 'promptUnmatched';
55
+ export type MusePermissionProfile = ':ask-me' | ':read-only' | ':unrestricted';
56
+ export type MuseReasoningEffort =
57
+ | 'none'
58
+ | 'minimal'
59
+ | 'low'
60
+ | 'medium'
61
+ | 'high'
62
+ | 'xhigh'
63
+ | 'max'
64
+ | 'ultra';
65
+ const EFFORTS: readonly MuseReasoningEffort[] = [
66
+ 'none',
67
+ 'minimal',
68
+ 'low',
69
+ 'medium',
70
+ 'high',
71
+ 'xhigh',
72
+ 'max',
73
+ 'ultra',
74
+ ];
75
+
76
+ export interface MusePermissions {
77
+ readonly approvalMode: MuseApprovalMode;
78
+ readonly permissionProfile: MusePermissionProfile;
79
+ /** `muse serve --disable-sandbox`: only where nothing is gated. */
80
+ readonly disableSandbox: boolean;
81
+ }
82
+
83
+ /**
84
+ * The Muse permissions for a host permission mode (Claude's mode ids). The profile is always chosen
85
+ * explicitly: the host default (`:auto-review`) lets Muse's AI reviewer decide approvals instead of
86
+ * the gates. `plan` maps to `:read-only` like Codex's read-only sandbox.
87
+ */
88
+ export function musePermissions(mode: HostPermissionMode): MusePermissions {
89
+ switch (mode) {
90
+ case 'default':
91
+ case 'acceptEdits':
92
+ return {
93
+ approvalMode: 'promptUnmatched',
94
+ permissionProfile: ':ask-me',
95
+ disableSandbox: false,
96
+ };
97
+ case 'plan':
98
+ return {
99
+ approvalMode: 'promptUnmatched',
100
+ permissionProfile: ':read-only',
101
+ disableSandbox: false,
102
+ };
103
+ case 'bypassPermissions':
104
+ return {
105
+ approvalMode: 'allowAll',
106
+ permissionProfile: ':unrestricted',
107
+ disableSandbox: true,
108
+ };
109
+ }
110
+ }
111
+
112
+ /** What the SDK handshake answered, as this session reads it. */
113
+ export type MuseInitializeResult = MuseClient['initializeResult'];
114
+ type ApprovalRequest = Parameters<ApprovalHandler>[0];
115
+ /** One answer to one question of a `userInput/request` (SDK `UserInputAnswer`). */
116
+ export type MuseUserInputAnswer = Parameters<NativeSession['answerUserInput']>[1][number];
117
+ /** One question of a `userInput/request` (SDK `UserInputQuestion`). */
118
+ export type MuseUserInputQuestion = Readonly<{
119
+ id: string;
120
+ header: string;
121
+ question: string;
122
+ options: readonly Readonly<{ label: string; description?: string }>[];
123
+ selection: Readonly<{ mode: string; minSelections?: number; maxSelections?: number }>;
124
+ }>;
125
+
126
+ /** A question Muse asks during a turn (`userInput/request`). */
127
+ export interface MuseUserInputRequest {
128
+ readonly userInputId: string;
129
+ readonly turnId?: string;
130
+ /** The tool that asked (Muse's ask-user tool). */
131
+ readonly toolName?: string;
132
+ readonly questions: readonly MuseUserInputQuestion[];
133
+ /** The in-flight admission, when one is attributable. */
134
+ readonly admissionId?: string;
135
+ /** Aborted when Muse settles the question itself or the session ends; a later answer is discarded. */
136
+ readonly signal: AbortSignal;
137
+ }
138
+ /** Answers in question order, or undefined to decline (the question is cancelled with a reason). */
139
+ export type MuseUserInputHandler = (
140
+ request: MuseUserInputRequest,
141
+ ) =>
142
+ | readonly MuseUserInputAnswer[]
143
+ | undefined
144
+ | Promise<readonly MuseUserInputAnswer[] | undefined>;
145
+
146
+ /** An initialized MSP host: the seam tests replace. */
147
+ export interface MuseHost {
148
+ readonly connection: Connection;
149
+ readonly initializeResult: MuseInitializeResult;
150
+ /** Settles when the host process has ended. */
151
+ readonly exited: Promise<unknown>;
152
+ /** Bounded stderr tail, for error messages. */
153
+ stderrTail?(): readonly string[];
154
+ /** Orderly shutdown (stdin EOF, then signals). */
155
+ close(): Promise<unknown>;
156
+ }
157
+
158
+ export interface MuseLaunch {
159
+ readonly museBin: string;
160
+ readonly args: readonly string[];
161
+ readonly cwd: string;
162
+ readonly env: Record<string, string>;
163
+ readonly initialize: Parameters<ReturnType<typeof spawnMspConnection>['initialize']>[0];
164
+ }
165
+
166
+ export type MuseConnect = (launch: MuseLaunch) => Promise<MuseHost>;
167
+
168
+ export interface MuseSessionConfig {
169
+ /** Path to the `muse` launcher. Defaults to "muse". */
170
+ museBin?: string;
171
+ /** Model id (`modelId`). Omitted → Muse's default. Recorded per run. */
172
+ model?: string;
173
+ /** Reasoning effort for every turn. Omitted → Muse's default. */
174
+ effort?: MuseReasoningEffort;
175
+ /** Working directory and workspace root. */
176
+ cwd?: string;
177
+ /** Default per-send timeout in ms. Defaults to 600000 (10 min). */
178
+ timeout?: number;
179
+ /** Prepended to the first turn's text (Muse has no system-prompt parameter on session/start). */
180
+ baseContext?: string;
181
+ /** Muse's session id. When set, the first send() resumes it (`session/resume`). */
182
+ externalSessionId?: string;
183
+ /** Host permission mode, mapped by `musePermissions`. Defaults to `default` (gated). */
184
+ permissionMode?: HostPermissionMode;
185
+ /**
186
+ * Environment merged over process.env. Per-login isolation goes here: Muse reads its login from
187
+ * `$XDG_CONFIG_HOME/muse/auth.json` (or `MUSE_AUTH_PATH`) and keeps sessions under
188
+ * `$XDG_DATA_HOME/muse`. Metered keys and endpoints are always removed.
189
+ */
190
+ env?: Record<string, string | undefined>;
191
+ /** Persist the actual session id before the first turn is written; a failure blocks work. */
192
+ onSessionReady?: (sessionId: string) => void | Promise<void>;
193
+ /** Replaces the host spawn and handshake. Tests inject a fake; defaults to `muse serve`. */
194
+ connect?: MuseConnect;
195
+ }
196
+
197
+ interface MuseForkSource {
198
+ readonly sessionId: string;
199
+ }
200
+
201
+ interface QueuedTurn {
202
+ onAdmission?: SessionSendOptions['onAdmission'];
203
+ evidence: TurnState;
204
+ message: string;
205
+ images: readonly SessionImage[];
206
+ timeout: number;
207
+ resolve: (result: SessionResult) => void;
208
+ reject: (error: Error) => void;
209
+ }
210
+
211
+ interface TrackedItem {
212
+ kind: string;
213
+ turnId?: string;
214
+ completed: boolean;
215
+ started: boolean;
216
+ }
217
+
218
+ class NativeValidationError extends Error {}
219
+
220
+ /** A classified event's identity and evidence, before its kind and time. */
221
+ type EventBase = Omit<SessionEvent, 'kind' | 'timestamp'>;
222
+
223
+ const STARTUP_TIMEOUT_MS = 30_000;
224
+ const CLIENT_INFO = { name: 'agent_session', title: 'agent-session', version: '1' } as const;
225
+
226
+ /**
227
+ * Inherited variables that would bill, route or loosen Muse away from its saved subscription login
228
+ * and the gates, by the names the 1.4.14 binary reads: metered keys and endpoints (META_, LLM_,
229
+ * MODEL_, LLAMA_, OPENROUTER_, ANTHROPIC_, OPENAI_), Meta's auth and key-mint endpoints, and MUSE_
230
+ * switches for the model, experiments, contributor mode, the approval judge and a fail-open
231
+ * profile. Muse's own tool runs inherit this environment, so they do not see these either.
232
+ */
233
+ const COMPETING_ENV =
234
+ /^(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)$)/;
235
+
236
+ export class MuseSession implements HarnessSession {
237
+ get transport() {
238
+ return TRANSPORTS['muse-serve'];
239
+ }
240
+ readonly admissionProtocol = 'prewrite-v1' as const;
241
+
242
+ // -- Config --
243
+ private readonly _bin: string;
244
+ private readonly _model?: string;
245
+ private readonly _effort?: MuseReasoningEffort;
246
+ private readonly _cwd: string;
247
+ private readonly _defaultTimeout: number;
248
+ private readonly _baseContext?: string;
249
+ private readonly _env?: Record<string, string | undefined>;
250
+ private readonly _permissionMode: HostPermissionMode;
251
+ private readonly _permissions: MusePermissions;
252
+ private readonly _connect: MuseConnect;
253
+ private readonly _onSessionReady?: (sessionId: string) => void | Promise<void>;
254
+ private readonly _config: MuseSessionConfig;
255
+ private _forkFrom?: MuseForkSource;
256
+
257
+ // -- Host --
258
+ private _host: MuseHost | null = null;
259
+ private _client: MuseClient | null = null;
260
+ private _session: NativeSession | null = null;
261
+ private _durability?: SessionDurabilityProfile;
262
+ private _startedOnce = false;
263
+
264
+ // -- Session state --
265
+ private _externalSessionId?: string;
266
+ private _alive = false;
267
+ private _endEmitted = false;
268
+ private _eventLog: SessionEvent[] = [];
269
+ private _handlers: SessionEventHandler[] = [];
270
+ private _observerFailures = { synchronous: 0, asynchronous: 0 };
271
+ private _beforeSendHooks: BeforeSendHook[] = [];
272
+ private _turns = 0;
273
+ private _totalTokens = { input: 0, output: 0 };
274
+ private _startedAt = Date.now();
275
+ private _injectedBaseContext = false;
276
+ private _limits?: LimitSnapshot;
277
+
278
+ // -- Turns --
279
+ private _queue: QueuedTurn[] = [];
280
+ private _attempts: TurnState[] = [];
281
+ private _inflight: QueuedTurn | null = null;
282
+ private _turnTimer: ReturnType<typeof setTimeout> | null = null;
283
+ private _turnDone?: () => void;
284
+ private _seenTerminals = new Set<string>();
285
+ private _items = new Map<string, TrackedItem>();
286
+ /** Frames for turns not yet known while our turn/start answer is due: the answer names our turn. */
287
+ private _held: Array<{ method: string; params: unknown }> = [];
288
+ /** Turns seen while a turn/start answer was lost: possibly ours, so never claimed as Muse's. */
289
+ private _unknownTurns = new Set<string>();
290
+ /** Settles when the in-flight turn/start answer arrives (or fails). */
291
+ private _ackDue?: Promise<void>;
292
+ private _ackDone?: () => void;
293
+ /** Turns Muse started without a send from this session (cron, monitor, goal, another client). */
294
+ private _museTurns = new Map<string, { content: string; trigger?: string }>();
295
+ /** The session a resume or fork is opening, before it becomes the binding. */
296
+ private _openingId?: string;
297
+ private _turnTokens?: SessionTokens;
298
+
299
+ // -- Gates and questions --
300
+ private _gates = new ToolGates();
301
+ private _approvals = new Map<string, AbortController>();
302
+ private _userInputHandler?: MuseUserInputHandler;
303
+ private _userInputs = new Map<string, AbortController>();
304
+ /** Questions already taken: the host sends a request and its notification twin, and re-sends on resume. */
305
+ private _askedInputs = new Set<string>();
306
+
307
+ constructor(config: MuseSessionConfig = {}) {
308
+ const bin = config.museBin ?? 'muse';
309
+ if (!/^[a-zA-Z0-9_./\\-]+$/.test(bin))
310
+ throw new Error(`Invalid muse CLI binary path: "${bin}"`);
311
+ if (config.effort && !EFFORTS.includes(config.effort))
312
+ throw new Error(`Invalid muse effort "${config.effort}". Valid: ${EFFORTS.join(', ')}`);
313
+ this._config = config;
314
+ this._bin = bin;
315
+ this._model = config.model;
316
+ this._effort = config.effort;
317
+ this._cwd = config.cwd ?? process.cwd();
318
+ this._defaultTimeout = config.timeout ?? 600_000;
319
+ this._baseContext = config.baseContext;
320
+ this._externalSessionId = config.externalSessionId;
321
+ this._env = config.env;
322
+ this._permissionMode = config.permissionMode ?? 'default';
323
+ this._permissions = musePermissions(this._permissionMode);
324
+ this._connect = config.connect ?? spawnMuseHost;
325
+ this._onSessionReady = config.onSessionReady;
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Accessors
330
+ // ---------------------------------------------------------------------------
331
+
332
+ get accounting() {
333
+ return 'observed-only' as const;
334
+ }
335
+ get attempts() {
336
+ return this._attempts.map((a) => a.snapshot());
337
+ }
338
+ get diagnostics() {
339
+ return Object.freeze({ observerFailures: Object.freeze({ ...this._observerFailures }) });
340
+ }
341
+ get alive(): boolean {
342
+ return this._alive;
343
+ }
344
+ get externalSessionId(): string | undefined {
345
+ return this._externalSessionId;
346
+ }
347
+ get events(): readonly SessionEvent[] {
348
+ return Object.freeze([...this._eventLog]);
349
+ }
350
+ get turns(): number {
351
+ return this._turns;
352
+ }
353
+ get totalTokens(): Readonly<{ input: number; output: number }> {
354
+ return { ...this._totalTokens };
355
+ }
356
+ /** Latest account limits observed on this session (stream or poll). */
357
+ get limits(): LimitSnapshot | undefined {
358
+ return this._limits;
359
+ }
360
+ /** Whether sessions survive this host (from the handshake); undefined before start(). */
361
+ get durability(): SessionDurabilityProfile | undefined {
362
+ return this._durability;
363
+ }
364
+ inspectAttempt(admissionId: string) {
365
+ return this._attempts.find((attempt) => attempt.admissionId === admissionId)?.snapshot();
366
+ }
367
+
368
+ onEvent(handler: SessionEventHandler): () => void {
369
+ this._handlers.push(handler);
370
+ return () => {
371
+ const idx = this._handlers.indexOf(handler);
372
+ if (idx !== -1) this._handlers.splice(idx, 1);
373
+ };
374
+ }
375
+
376
+ onBeforeSend(hook: BeforeSendHook): () => void {
377
+ this._beforeSendHooks.push(hook);
378
+ return () => {
379
+ const idx = this._beforeSendHooks.indexOf(hook);
380
+ if (idx !== -1) this._beforeSendHooks.splice(idx, 1);
381
+ };
382
+ }
383
+
384
+ /** Needs a gated mode (`allowAll` sends no approvals). Forks copy the gates registered now. */
385
+ onToolRequest(gate: ToolGate): () => void {
386
+ if (this._permissions.approvalMode === 'allowAll')
387
+ throw new Error(
388
+ 'Tool gates need a Muse session launched with approval mode "promptUnmatched"; under bypassPermissions no approval reaches them',
389
+ );
390
+ return this._gates.add(gate);
391
+ }
392
+
393
+ /**
394
+ * Answer Muse's questions (`userInput/request`). One handler at a time, because two cannot both
395
+ * answer one question; registering replaces the previous one. Without a handler, or when it
396
+ * declines or throws, the question is cancelled with a reason so the turn goes on.
397
+ */
398
+ onUserInput(handler: MuseUserInputHandler): () => void {
399
+ this._userInputHandler = handler;
400
+ return () => {
401
+ if (this._userInputHandler === handler) this._userInputHandler = undefined;
402
+ };
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // interrupt / kill / artifact / fork
407
+ // ---------------------------------------------------------------------------
408
+
409
+ interrupt(): void {
410
+ if (!this._inflight) return;
411
+ this._rejectInflight(
412
+ new Error('Local waiter interrupted; native cancellation unacknowledged'),
413
+ 'interrupt-request',
414
+ );
415
+ }
416
+
417
+ kill(): void {
418
+ if (!this._host) return;
419
+ this._alive = false;
420
+ this._rejectInflight(new Error('Session killed; native outcome may remain unknown'), 'killed');
421
+ this._rejectQueue(new Error('Session killed'));
422
+ this._withdrawRequests();
423
+ this._turnDone?.();
424
+ this._turnDone = undefined;
425
+ const host = this._host;
426
+ const client = this._client;
427
+ this._host = null;
428
+ this._client = null;
429
+ this._session = null;
430
+ // Frames the host already sent still reach the record; their ownership is unknown.
431
+ const pending = this._inflight?.evidence;
432
+ if (pending?.rpcOutcome === 'pending') pending.rpcOutcome = 'unknown';
433
+ this._flushHeld();
434
+ // Through the client first: it marks the EOF as ours, not a host death that discards sessions.
435
+ void Promise.resolve(client?.close())
436
+ .catch(() => undefined)
437
+ .finally(() => host.close().catch(() => undefined));
438
+ this._emit({ kind: 'session_end', timestamp: Date.now() });
439
+ }
440
+
441
+ artifact(): SessionArtifact {
442
+ return {
443
+ externalSessionId: this._externalSessionId,
444
+ attempts: this.attempts,
445
+ accounting: 'observed-only',
446
+ diagnostics: this.diagnostics,
447
+ events: [...this._eventLog],
448
+ startedAt: this._startedAt,
449
+ endedAt: this._alive ? undefined : Date.now(),
450
+ turns: this._turns,
451
+ totalTokens: { ...this._totalTokens },
452
+ toolCalls: this._eventLog.filter((e) => e.kind === 'tool_use').length,
453
+ toolResults: this._eventLog.filter((e) => e.kind === 'tool_result').length,
454
+ errors: this._eventLog.filter((e) => e.kind === 'error').length,
455
+ };
456
+ }
457
+
458
+ /**
459
+ * A new, unstarted session that branches this one (`session/fork` on its own host when it
460
+ * starts). Needs a native session id. Gates registered now are copied.
461
+ */
462
+ fork(opts?: { cwd?: string; baseContext?: string }): HarnessSession {
463
+ const source = this._externalSessionId;
464
+ if (!source) throw new Error('Nothing to fork: the Muse session has no native id yet');
465
+ if (this._inflight) throw new Error('Cannot fork a Muse session while a turn is in flight');
466
+ // The branch already holds the parent's base context; only a new one is prepended. `cwd` moves
467
+ // the fork's host; the forked session keeps the source's workspace root.
468
+ const fork = new MuseSession({
469
+ ...this._config,
470
+ externalSessionId: undefined,
471
+ onSessionReady: undefined,
472
+ baseContext: opts?.baseContext,
473
+ ...(opts?.cwd ? { cwd: opts.cwd } : {}),
474
+ });
475
+ fork._forkFrom = { sessionId: source };
476
+ for (const gate of this._gates.list()) fork._gates.add(gate);
477
+ if (this._userInputHandler) fork._userInputHandler = this._userInputHandler;
478
+ return fork;
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // start() — spawn the host, check the version, wire the client
483
+ // ---------------------------------------------------------------------------
484
+
485
+ async start(): Promise<void> {
486
+ if (this._inflight)
487
+ throw Error('Native call ownership unresolved; automatic resume is blocked');
488
+ if (this._startedOnce && !this._alive)
489
+ throw Error('Muse host restart requires a new owned instance');
490
+ if (this._host) throw new Error('Session already started');
491
+ this._startedOnce = true;
492
+ try {
493
+ await this._launch();
494
+ } catch (error) {
495
+ this.kill();
496
+ throw error;
497
+ }
498
+ }
499
+
500
+ private _childEnv(): Record<string, string> {
501
+ const merged: Record<string, string | undefined> = {
502
+ ...process.env,
503
+ ...this._env,
504
+ MUSE_NO_AUTO_UPDATE: '1',
505
+ };
506
+ const env: Record<string, string> = {};
507
+ // Subscription transport: a metered key or another endpoint never replaces the saved login.
508
+ for (const [key, value] of Object.entries(merged))
509
+ if (value !== undefined && !COMPETING_ENV.test(key)) env[key] = value;
510
+ return env;
511
+ }
512
+
513
+ private async _launch(): Promise<void> {
514
+ const host = await this._connect({
515
+ museBin: this._bin,
516
+ args: [
517
+ 'serve',
518
+ '--trust-workspace',
519
+ ...(this._permissions.disableSandbox ? ['--disable-sandbox'] : []),
520
+ ],
521
+ cwd: this._cwd,
522
+ env: this._childEnv(),
523
+ initialize: {
524
+ clientInfo: CLIENT_INFO,
525
+ // why: experimentalApi is what makes session/read report permissionState, the only proof the profile applied.
526
+ capabilities: { experimentalApi: true, userInputDialogs: true },
527
+ },
528
+ });
529
+ this._host = host;
530
+ const served = host.initializeResult.serverInfo?.version;
531
+ if (served !== MUSE_SDK_VERSION)
532
+ throw new Error(
533
+ `Muse Code ${served ?? 'unknown'} does not match the pinned @muse-code/sdk ${MUSE_SDK_VERSION}; install Muse Code ${MUSE_SDK_VERSION} or move the SDK pin with the CLI`,
534
+ );
535
+ if (host.initializeResult.schema?.version !== 1)
536
+ throw new Error(
537
+ `Muse Code serves protocol envelope ${String(host.initializeResult.schema?.version)}; this session reads version 1`,
538
+ );
539
+ this._durability = readSessionDurability(host.initializeResult);
540
+ this._tap(host.connection);
541
+ this._client = new MuseClient(host.connection, { durability: this._durability });
542
+ this._alive = true;
543
+ this._endEmitted = false;
544
+ this._emit({
545
+ kind: 'session_start',
546
+ timestamp: Date.now(),
547
+ raw: {
548
+ type: 'host-ready',
549
+ version: served,
550
+ durability: this._durability.kind,
551
+ permissionMode: this._permissionMode,
552
+ },
553
+ });
554
+ void host.exited.then(() => {
555
+ if (this._host !== host || !this._alive) return;
556
+ this._hostDied(host);
557
+ });
558
+ }
559
+
560
+ /** The host ended under us. An ephemeral host takes its sessions with it. */
561
+ private _hostDied(host: MuseHost): void {
562
+ const tail = host.stderrTail?.().join('').trim();
563
+ const message = tail ? `Muse host exited: ${tail.slice(-500)}` : 'Muse host exited';
564
+ const binding = this._externalSessionId;
565
+ const error =
566
+ binding && this._durability?.kind !== 'durable'
567
+ ? new Error(message, {
568
+ cause: new NativeBindingMissingError(
569
+ 'muse',
570
+ binding,
571
+ `Ephemeral Muse host died; session ${binding} went with it`,
572
+ this._cwd,
573
+ ),
574
+ })
575
+ : new Error(message);
576
+ this._transportFailure(error);
577
+ }
578
+
579
+ /**
580
+ * MuseClient claims the connection's single notification and server-request handlers. Wrap both
581
+ * before it does, so every frame still reaches the client first and this session classifies it
582
+ * after the fold has applied it.
583
+ */
584
+ private _tap(connection: Connection): void {
585
+ const onNotification = connection.onNotification.bind(connection);
586
+ connection.onNotification = (handler) =>
587
+ onNotification((notification) => {
588
+ handler(notification);
589
+ this._observe(notification.method, notification.params);
590
+ });
591
+ const onServerRequest = connection.onServerRequest.bind(connection);
592
+ connection.onServerRequest = (handler) =>
593
+ onServerRequest(async (request) => {
594
+ const answer = await handler(request);
595
+ if (request.method === 'userInput/request')
596
+ this._observe('userInput/requested', request.params);
597
+ return answer;
598
+ });
599
+ }
600
+
601
+ // ---------------------------------------------------------------------------
602
+ // send() — queue a turn, resolve on its native terminal
603
+ // ---------------------------------------------------------------------------
604
+
605
+ async send(message: string, opts?: SessionSendOptions): Promise<SessionResult> {
606
+ if (
607
+ this._attempts.some(
608
+ (a) =>
609
+ a.dispatch === 'attempted' &&
610
+ a.nativeOutcome === 'unknown' &&
611
+ (!this._alive || a.localOutcome !== 'pending'),
612
+ )
613
+ ) {
614
+ const blocked = new TurnState();
615
+ this._attempts.push(blocked);
616
+ throw blocked.fail(new Error('Native outcome unresolved; send not dispatched'), 'blocked');
617
+ }
618
+ if (!this._host && (this._externalSessionId || this._forkFrom)) await this.start();
619
+ if (!this._host) throw new Error('Session not started — call start() first');
620
+ if (!this._alive) throw new Error('Session ended');
621
+
622
+ const timeout = opts?.timeout ?? this._defaultTimeout;
623
+ const images = sendableImages(this.transport, opts?.images);
624
+ let transformed = message;
625
+ for (const hook of this._beforeSendHooks) transformed = await hook(transformed);
626
+ if (this._baseContext && !this._injectedBaseContext) {
627
+ transformed = `${this._baseContext}\n\n${transformed}`;
628
+ this._injectedBaseContext = true;
629
+ }
630
+
631
+ return new Promise<SessionResult>((resolve, reject) => {
632
+ const evidence = new TurnState();
633
+ this._attempts.push(evidence);
634
+ const turn: QueuedTurn = {
635
+ evidence,
636
+ message: transformed,
637
+ images,
638
+ timeout,
639
+ resolve,
640
+ reject,
641
+ onAdmission: opts?.onAdmission,
642
+ };
643
+ if (!this._alive || (this._inflight && this._inflight.evidence.localOutcome !== 'pending')) {
644
+ reject(
645
+ evidence.fail(
646
+ new Error('Previous native work unresolved; send not dispatched'),
647
+ 'blocked',
648
+ ),
649
+ );
650
+ return;
651
+ }
652
+ if (!this._inflight) this._dispatchTurn(turn);
653
+ else this._queue.push(turn);
654
+ });
655
+ }
656
+
657
+ private _dispatchTurn(turn: QueuedTurn): void {
658
+ this._inflight = turn;
659
+ if (!turn.onAdmission) {
660
+ this._writeTurn(turn);
661
+ return;
662
+ }
663
+ Promise.resolve()
664
+ .then(() => turn.onAdmission!(turn.evidence.snapshot()))
665
+ .then(() => {
666
+ if (this._inflight === turn && this._alive && turn.evidence.localOutcome === 'pending')
667
+ this._writeTurn(turn);
668
+ else if (this._inflight === turn && turn.evidence.dispatch === 'not-dispatched') {
669
+ this._inflight = null;
670
+ this._processNextTurn();
671
+ }
672
+ })
673
+ .catch((error) => {
674
+ turn.reject(
675
+ turn.evidence.fail(
676
+ error instanceof Error ? error : new Error(String(error)),
677
+ 'registration',
678
+ ),
679
+ );
680
+ if (this._inflight === turn) {
681
+ this._inflight = null;
682
+ this._processNextTurn();
683
+ }
684
+ });
685
+ }
686
+
687
+ private _writeTurn(turn: QueuedTurn): void {
688
+ turn.evidence.dispatch = 'attempted';
689
+ this._sendTurn(turn)
690
+ .then(() => {
691
+ if (this._inflight !== turn) return;
692
+ const tokens = turn.evidence.tokens;
693
+ if (tokens) {
694
+ this._totalTokens.input += tokens.input;
695
+ this._totalTokens.output += tokens.output;
696
+ }
697
+ this._resolveTurn();
698
+ })
699
+ .catch((err: Error) => {
700
+ if (this._inflight !== turn) return;
701
+ if (turn.evidence.rpcOutcome === 'pending') turn.evidence.rpcOutcome = 'unknown';
702
+ this._rejectInflight(err, err instanceof NativeValidationError ? 'validation' : 'rpc');
703
+ this._releaseKnownTurn();
704
+ });
705
+ if (turn.timeout > 0)
706
+ this._turnTimer = setTimeout(() => {
707
+ this._turnTimer = null;
708
+ // An approval waiting on an answer that never came must not hold the host forever.
709
+ this._releaseAck();
710
+ this._rejectInflight(new Error(`Turn timed out after ${turn.timeout}ms`), 'timeout');
711
+ }, turn.timeout);
712
+ }
713
+
714
+ private _resolveTurn(): void {
715
+ if (!this._inflight) return;
716
+ this._clearTimer();
717
+ const a = this._inflight.evidence;
718
+ if (a.localOutcome === 'pending') {
719
+ a.localOutcome = 'resolved';
720
+ this._inflight.resolve(a.result(this._externalSessionId));
721
+ }
722
+ this._releaseKnownTurn();
723
+ }
724
+
725
+ /** The admission is released once its turn/start answer and its native terminal are both known. */
726
+ private _releaseKnownTurn(): void {
727
+ const a = this._inflight?.evidence;
728
+ if (!a) return;
729
+ const noSend = a.dispatch === 'not-dispatched' && a.localOutcome === 'rejected';
730
+ const rpcKnown = a.rpcOutcome === 'resolved' || a.rpcOutcome === 'failed';
731
+ // A refused turn/start admitted nothing, so no terminal will come.
732
+ const settled = rpcKnown && (a.rpcOutcome === 'failed' || a.nativeOutcome !== 'unknown');
733
+ if (!noSend && !settled) return;
734
+ if (!noSend && a.rpcOutcome === 'resolved') this._turns++;
735
+ this._inflight = null;
736
+ this._processNextTurn();
737
+ }
738
+
739
+ private _processNextTurn(): void {
740
+ if (!this._inflight && this._queue.length > 0 && this._alive) {
741
+ const next = this._queue.shift()!;
742
+ this._dispatchTurn(next);
743
+ }
744
+ }
745
+
746
+ private _clearTimer(): void {
747
+ if (this._turnTimer) {
748
+ clearTimeout(this._turnTimer);
749
+ this._turnTimer = null;
750
+ }
751
+ }
752
+
753
+ private _rejectInflight(err: Error, reason: SessionAttempt['localFailure'] = 'transport'): void {
754
+ if (!this._inflight) return;
755
+ this._clearTimer();
756
+ const a = this._inflight.evidence;
757
+ if ((reason === 'transport' || reason === 'killed') && a.transportOutcome === 'open')
758
+ a.transportOutcome = reason === 'killed' ? 'closed' : 'failed';
759
+ if (a.localOutcome === 'pending') this._inflight.reject(a.fail(err, reason));
760
+ this._rejectQueue(new Error('Previous native work unresolved; queued send not dispatched'));
761
+ }
762
+
763
+ private _transportFailure(err: Error): void {
764
+ this._alive = false;
765
+ this._rejectInflight(err);
766
+ this._rejectQueue(err);
767
+ this._withdrawRequests();
768
+ this._turnDone?.();
769
+ this._turnDone = undefined;
770
+ this._emit({ kind: 'session_end', timestamp: Date.now(), transportOutcome: 'failed' });
771
+ }
772
+
773
+ private _rejectQueue(err: Error): void {
774
+ for (const turn of this._queue) turn.reject(turn.evidence.fail(err, 'blocked'));
775
+ this._queue = [];
776
+ }
777
+
778
+ private _withdrawRequests(): void {
779
+ for (const controller of this._approvals.values()) controller.abort();
780
+ this._approvals.clear();
781
+ for (const controller of this._userInputs.values()) controller.abort();
782
+ this._userInputs.clear();
783
+ }
784
+
785
+ private _emit(event: SessionEvent): void {
786
+ event = retainEvidence(event);
787
+ if (event.kind === 'session_end') {
788
+ if (this._endEmitted) return;
789
+ this._endEmitted = true;
790
+ }
791
+ this._eventLog.push(event);
792
+ for (const handler of this._handlers) {
793
+ try {
794
+ const observation: unknown = handler(event);
795
+ if (observation && typeof (observation as PromiseLike<unknown>).then === 'function')
796
+ void Promise.resolve(observation).catch(() => {
797
+ this._observerFailures.asynchronous++;
798
+ });
799
+ } catch {
800
+ this._observerFailures.synchronous++;
801
+ }
802
+ }
803
+ }
804
+
805
+ // ---------------------------------------------------------------------------
806
+ // Session open (start, resume, fork) and the turn itself
807
+ // ---------------------------------------------------------------------------
808
+
809
+ private _refuse(reason: string, status = 'unknown'): never {
810
+ const a = this._inflight?.evidence;
811
+ this._emit({
812
+ ...(a ? { admissionId: a.admissionId, ...a.identity } : {}),
813
+ kind: 'native_status',
814
+ timestamp: Date.now(),
815
+ raw: { type: 'session-refused', reason, status },
816
+ });
817
+ throw new NativeValidationError(
818
+ `Native session validation refused: ${reason} (${status}); no turn was sent`,
819
+ );
820
+ }
821
+
822
+ /** A Muse answer that the session does not exist (deleted, other home, ephemeral host gone). */
823
+ private _missing(error: unknown, binding: string, a: TurnState): never {
824
+ if (
825
+ error instanceof MuseSessionDiscardedError ||
826
+ (error instanceof MspError && (error.kind === 'sessionNotFound' || error.kind === 'notFound'))
827
+ ) {
828
+ a.dispatch = 'not-dispatched';
829
+ throw new NativeBindingMissingError(
830
+ 'muse',
831
+ binding,
832
+ error instanceof Error ? error.message : String(error),
833
+ this._cwd,
834
+ );
835
+ }
836
+ throw error;
837
+ }
838
+
839
+ private async _resume(sessionId: string, a: TurnState): Promise<NativeSession> {
840
+ // Frames the host re-sends during the resume (an open question) belong to this session.
841
+ this._openingId = sessionId;
842
+ try {
843
+ return await this._client!.resumeSession({
844
+ sessionId,
845
+ excludeItems: true,
846
+ permissionProfile: this._permissions.permissionProfile,
847
+ ...(this._model ? { modelId: this._model } : {}),
848
+ // On the resume itself, so an approval re-sent right after the reply reaches the gates.
849
+ onApproval: (request) => this._approval(request),
850
+ onApprovalError: (failure) => this._approvalFailure(failure),
851
+ });
852
+ } catch (error) {
853
+ return this._missing(error, sessionId, a);
854
+ }
855
+ }
856
+
857
+ private async _open(a: TurnState): Promise<void> {
858
+ const connection = this._host!.connection;
859
+ const { approvalMode, permissionProfile } = this._permissions;
860
+ let session: NativeSession;
861
+ const binding = this._externalSessionId;
862
+ const method = binding ? 'session/resume' : this._forkFrom ? 'session/fork' : 'session/start';
863
+ if (binding) session = await this._resume(binding, a);
864
+ else if (this._forkFrom) {
865
+ const source = this._forkFrom.sessionId;
866
+ let forked: Record<string, unknown>;
867
+ try {
868
+ forked = await connection.command('session/fork', {
869
+ sessionId: source,
870
+ excludeItems: true,
871
+ });
872
+ } catch (error) {
873
+ return this._missing(error, source, a);
874
+ }
875
+ const id = object(forked.session)?.sessionId;
876
+ if (typeof id !== 'string' || !id) this._refuse('fork-missing-session');
877
+ session = await this._resume(id, a);
878
+ } else {
879
+ try {
880
+ session = await this._client!.startSession({
881
+ workspaceRoot: this._cwd,
882
+ providerId: 'meta',
883
+ approvalMode,
884
+ permissionProfile,
885
+ requirePermissionProfile: true,
886
+ ...(this._model ? { modelId: this._model } : {}),
887
+ });
888
+ } catch (error) {
889
+ if (error instanceof PermissionProfileNotApplied)
890
+ this._refuse('permission-profile-not-applied', String(error.served ?? 'unknown'));
891
+ throw error;
892
+ }
893
+ session.onApproval((request) => this._approval(request));
894
+ session.onApprovalError((failure) => this._approvalFailure(failure));
895
+ }
896
+ const sessionId = session.sessionId;
897
+ if (binding && sessionId !== binding) this._refuse('binding-mismatch');
898
+ // A resumed or forked session keeps its stored approval mode; set it again, then prove both.
899
+ if (method !== 'session/start')
900
+ await connection.command('session/setApprovalMode', { sessionId, mode: approvalMode });
901
+ const read = object((await connection.command('session/read', { sessionId })).session);
902
+ const state = object(read?.permissionState);
903
+ if (state?.profileId !== permissionProfile)
904
+ this._refuse('permission-profile-not-applied', String(state?.profileId ?? 'unknown'));
905
+ if (object(read?.approvalMode)?.mode !== approvalMode)
906
+ this._refuse('approval-mode-not-applied', String(object(read?.approvalMode)?.mode));
907
+ // A gated session is answered by a person through the gates, never by Muse's AI reviewer.
908
+ if (
909
+ approvalMode === 'promptUnmatched' &&
910
+ (state.reviewerAuthority !== 'human' || object(state.axes)?.reviewer !== 'human')
911
+ )
912
+ this._refuse('reviewer-not-human', String(state.reviewerAuthority ?? 'unknown'));
913
+ // A resumed or forked session keeps its stored provider: only Meta bills the subscription login.
914
+ if (read?.providerId !== 'meta') this._refuse('provider-not-meta', String(read?.providerId));
915
+ if (read?.activeTurnId != null) this._refuse('not-idle', 'active');
916
+ this._session = session;
917
+ this._externalSessionId = sessionId;
918
+ this._forkFrom = undefined;
919
+ a.identity = { ...a.identity, nativeSessionId: sessionId };
920
+ if (this._onSessionReady) {
921
+ try {
922
+ await this._onSessionReady(sessionId);
923
+ } catch (cause) {
924
+ throw new NativeValidationError('Native binding persistence failed before turn/start', {
925
+ cause,
926
+ });
927
+ }
928
+ }
929
+ this._emit({
930
+ kind: 'native_status',
931
+ timestamp: Date.now(),
932
+ nativeSessionId: sessionId,
933
+ correlation: 'unknown',
934
+ unattributedReason: 'session-configuration',
935
+ raw: {
936
+ type: 'session-configured',
937
+ method,
938
+ sessionId,
939
+ ...(typeof read?.modelId === 'string' ? { model: read.modelId } : {}),
940
+ ...(typeof read?.providerId === 'string' ? { provider: read.providerId } : {}),
941
+ approvalMode,
942
+ permissionProfile,
943
+ ...(typeof state?.reviewerAuthority === 'string'
944
+ ? { reviewerAuthority: state.reviewerAuthority }
945
+ : {}),
946
+ ...(typeof read?.turnCount === 'number' ? { turnCount: read.turnCount } : {}),
947
+ },
948
+ });
949
+ }
950
+
951
+ private async _sendTurn(turn: QueuedTurn): Promise<void> {
952
+ const a = turn.evidence;
953
+ try {
954
+ if (!this._session) await this._open(a);
955
+ // Timeout/kill during open must not cause a later model write.
956
+ if (this._inflight !== turn || a.localOutcome !== 'pending' || !this._alive)
957
+ throw Error('Local admission closed before turn/start; no turn was sent');
958
+ } catch (error) {
959
+ // No turn/start was written, so nothing native is outstanding and the queue may move on.
960
+ a.dispatch = 'not-dispatched';
961
+ throw error;
962
+ }
963
+ this._turnTokens = undefined;
964
+ const ended = new Promise<'ended'>((resolve) => {
965
+ this._turnDone = () => resolve('ended');
966
+ });
967
+ a.rpcOutcome = 'pending';
968
+ this._ackDue = new Promise<void>((resolve) => {
969
+ this._ackDone = resolve;
970
+ });
971
+ let started: Awaited<ReturnType<NativeSession['sendUserTurn']>>;
972
+ try {
973
+ started = await this._session!.sendUserTurn({
974
+ input: [
975
+ { type: 'text', text: turn.message },
976
+ ...turn.images.map((image) => ({
977
+ type: 'image' as const,
978
+ base64Data: image.data,
979
+ mediaType: image.mediaType,
980
+ })),
981
+ ],
982
+ ...(this._effort ? { reasoningEffort: this._effort } : {}),
983
+ });
984
+ } catch (error) {
985
+ a.rpcOutcome = error instanceof MspError ? 'failed' : 'unknown';
986
+ this._flushHeld();
987
+ throw error;
988
+ }
989
+ a.rpcOutcome = 'resolved';
990
+ a.identity = { ...a.identity, turnId: started.turnId, correlation: 'native-turn' };
991
+ this._flushHeld();
992
+ // The SDK's turn wait is the terminal of record; the classifier has already recorded it when the
993
+ // notification named the owned turn, so only a terminal it could not attribute is recorded here.
994
+ const outcome = await Promise.race([started.completed, ended]);
995
+ this._turnDone = undefined;
996
+ if (outcome === 'ended') throw new Error('Muse host ended before the turn reached a terminal');
997
+ if (outcome.kind === 'terminalUnknown') {
998
+ const binding = this._externalSessionId ?? '';
999
+ throw new Error('Ephemeral Muse host died; the turn has no terminal', {
1000
+ cause: new NativeBindingMissingError(
1001
+ 'muse',
1002
+ binding,
1003
+ 'Ephemeral Muse host died',
1004
+ this._cwd,
1005
+ ),
1006
+ });
1007
+ }
1008
+ if (a.nativeOutcome !== 'unknown') return;
1009
+ const method = outcome.kind === 'completed' ? 'turn/completed' : 'turn/unqueued';
1010
+ const params = object(outcome.params) ?? {};
1011
+ const identity: EventBase = { ...a.identity, admissionId: a.admissionId };
1012
+ for (const event of this._terminal(method, params, identity, started.turnId)) {
1013
+ if (event.unattributedReason) {
1014
+ this._emit(event);
1015
+ continue;
1016
+ }
1017
+ const e = a.record(event);
1018
+ if (e.nativeOutcome && e.terminal) {
1019
+ a.nativeOutcome = e.nativeOutcome;
1020
+ a.terminal = e.terminal;
1021
+ }
1022
+ this._emit(e);
1023
+ }
1024
+ }
1025
+
1026
+ // ---------------------------------------------------------------------------
1027
+ // Classification — every view notification after the client folded it
1028
+ // ---------------------------------------------------------------------------
1029
+
1030
+ private _observe(method: string, params: unknown): void {
1031
+ try {
1032
+ for (const event of this._classify(method, object(params) ?? {})) {
1033
+ const a = this._inflight?.evidence;
1034
+ if (!a || event.unattributedReason || !event.admissionId) {
1035
+ this._emit(event);
1036
+ continue;
1037
+ }
1038
+ const e = a.record(event);
1039
+ if (e.nativeOutcome && e.terminal) {
1040
+ a.nativeOutcome = e.nativeOutcome;
1041
+ a.terminal = e.terminal;
1042
+ }
1043
+ this._emit(e);
1044
+ }
1045
+ } catch {
1046
+ this._emit({
1047
+ kind: 'native_status',
1048
+ timestamp: Date.now(),
1049
+ correlation: 'unknown',
1050
+ unattributedReason: 'unrecognized-event',
1051
+ raw: { method: 'unclassifiable' },
1052
+ });
1053
+ }
1054
+ }
1055
+
1056
+ /** Approvals and questions waiting on the turn/start answer go on, attributed or not. */
1057
+ private _releaseAck(): void {
1058
+ this._ackDone?.();
1059
+ this._ackDone = undefined;
1060
+ this._ackDue = undefined;
1061
+ }
1062
+
1063
+ /** Classify the frames held for the turn/start answer, now that it named (or did not) our turn. */
1064
+ private _flushHeld(): void {
1065
+ this._releaseAck();
1066
+ const held = this._held;
1067
+ this._held = [];
1068
+ const a = this._inflight?.evidence;
1069
+ if (a?.rpcOutcome === 'unknown' && !a.identity.turnId)
1070
+ for (const frame of held) {
1071
+ const params = object(frame.params);
1072
+ const turnId = str(params?.turnId) ?? str(object(params?.item)?.turnId);
1073
+ if (turnId && !this._museTurns.has(turnId)) this._unknownTurns.add(turnId);
1074
+ }
1075
+ for (const frame of held) this._observe(frame.method, frame.params);
1076
+ }
1077
+
1078
+ private _limitEvent(snapshot: LimitSnapshot, raw?: Record<string, unknown>): SessionEvent {
1079
+ this._limits = mergeLimits(this._limits, snapshot);
1080
+ return {
1081
+ kind: 'rate_limit',
1082
+ timestamp: Date.now(),
1083
+ correlation: 'unknown',
1084
+ unattributedReason: 'account-status',
1085
+ limits: this._limits,
1086
+ ...(raw ? { raw } : {}),
1087
+ };
1088
+ }
1089
+
1090
+ private _classify(method: string, params: Record<string, unknown>): SessionEvent[] {
1091
+ if (method === 'usage/changed') {
1092
+ const snapshot = museLimitSnapshot(params, 'stream');
1093
+ return snapshot ? [this._limitEvent(snapshot)] : [];
1094
+ }
1095
+ const sessionId = str(params.sessionId);
1096
+ if (!sessionId || (sessionId !== this._externalSessionId && sessionId !== this._openingId))
1097
+ return [];
1098
+ const now = Date.now();
1099
+ const unowned = (
1100
+ reason: NonNullable<SessionEvent['unattributedReason']>,
1101
+ raw: Record<string, unknown> = {},
1102
+ ): SessionEvent[] => [
1103
+ {
1104
+ kind: 'native_status',
1105
+ timestamp: now,
1106
+ nativeSessionId: sessionId,
1107
+ correlation: 'unknown',
1108
+ unattributedReason: reason,
1109
+ raw: { method, ...raw },
1110
+ },
1111
+ ];
1112
+
1113
+ if (method === 'approval/resolved') this._settle(this._approvals, str(params.approvalId), true);
1114
+ if (method === 'userInput/settled') this._settle(this._userInputs, str(params.userInputId));
1115
+ if (method === 'userInput/requested') {
1116
+ this._question(params);
1117
+ return [];
1118
+ }
1119
+
1120
+ const item = object(params.item);
1121
+ const itemId = str(item?.itemId) ?? str(params.itemId);
1122
+ let turnId = str(params.turnId) ?? str(item?.turnId);
1123
+ if (!turnId && itemId) turnId = this._items.get(itemId)?.turnId;
1124
+ const a = this._inflight?.evidence;
1125
+ // While our turn/start answer is due, a frame for a turn not yet known could be ours or one
1126
+ // Muse started itself; hold it until the answer names our turn, rather than guess.
1127
+ const known =
1128
+ (!!turnId && (this._museTurns.has(turnId) || this._seenTerminals.has(turnId))) ||
1129
+ (!turnId && !!itemId && this._items.has(itemId));
1130
+ if (
1131
+ a?.dispatch === 'attempted' &&
1132
+ a.rpcOutcome === 'pending' &&
1133
+ !a.identity.turnId &&
1134
+ !known &&
1135
+ (!!turnId ||
1136
+ method.startsWith('item/') ||
1137
+ method.startsWith('turn/') ||
1138
+ method === 'session/tokenUsage')
1139
+ ) {
1140
+ this._held.push({ method, params });
1141
+ return [];
1142
+ }
1143
+ const owned = !!a && !!turnId && turnId === a.identity.turnId;
1144
+ // A finished turn never starts again: late frames (usage after the terminal) are diagnostics.
1145
+ if (turnId && this._seenTerminals.has(turnId))
1146
+ return unowned(method === 'turn/completed' ? 'duplicate-terminal' : 'after-terminal');
1147
+ // Held while a turn/start answer that never came was due: the turn may be ours; claim nothing.
1148
+ if (turnId && this._unknownTurns.has(turnId))
1149
+ return unowned('no-admission').map((event) => ({
1150
+ ...event,
1151
+ turnId,
1152
+ ...(itemId ? { itemId } : {}),
1153
+ ...(item ? { raw: { method, itemKind: kindLabel(item) } } : {}),
1154
+ }));
1155
+ if (!owned) {
1156
+ // Work Muse runs without a send from us still reaches the caller, marked as a task.
1157
+ if (turnId) return this._museTurn(method, params, sessionId, turnId, item, itemId);
1158
+ if (itemId && (item || this._items.has(itemId)))
1159
+ return this._background(method, params, sessionId, item, itemId);
1160
+ if (method === 'session/goalChanged') return this._goal(params, sessionId);
1161
+ return unowned('session-configuration');
1162
+ }
1163
+ const identity: EventBase = {
1164
+ ...a!.identity,
1165
+ admissionId: a!.admissionId,
1166
+ turnId,
1167
+ ...(itemId ? { itemId } : {}),
1168
+ };
1169
+ const status = (raw: Record<string, unknown> = {}): SessionEvent[] => [
1170
+ { ...identity, kind: 'native_status', timestamp: now, raw: { method, ...raw } },
1171
+ ];
1172
+
1173
+ switch (method) {
1174
+ case 'turn/completed':
1175
+ case 'turn/unqueued':
1176
+ return this._terminal(method, params, identity, turnId!);
1177
+ case 'turn/retryScheduled':
1178
+ case 'turn/modelRetryScheduled': {
1179
+ const http = typeof params.httpStatus === 'number' ? params.httpStatus : undefined;
1180
+ const kind = str(params.errorKind);
1181
+ const raw = {
1182
+ method,
1183
+ reason: str(params.reason),
1184
+ ...(http ? { httpStatus: http } : {}),
1185
+ ...(kind ? { errorKind: kind } : {}),
1186
+ retryDelayMs: params.retryDelayMs,
1187
+ attempt: params.attempt,
1188
+ maxAttempts: params.maxAttempts,
1189
+ };
1190
+ // The retry stays on the turn; it is also a rate limit only on HTTP 429 (a server error,
1191
+ // or a context or output limit, is not one).
1192
+ return [
1193
+ { ...identity, kind: 'native_status', timestamp: now, raw },
1194
+ ...(http === 429
1195
+ ? [
1196
+ this._limitEvent(
1197
+ { runtime: 'muse', source: 'stream', observedAt: now, windows: [] },
1198
+ raw,
1199
+ ),
1200
+ ]
1201
+ : []),
1202
+ ];
1203
+ }
1204
+ case 'session/tokenUsage': {
1205
+ const tokens = museTokens(params.usage);
1206
+ if (!tokens) return status({ malformed: true });
1207
+ this._turnTokens = addTokens(this._turnTokens, tokens);
1208
+ a!.tokens = this._turnTokens;
1209
+ return [{ ...identity, kind: 'usage', timestamp: now, tokens }];
1210
+ }
1211
+ case 'item/delta':
1212
+ return this._delta(params, itemId, identity, status);
1213
+ case 'item/started':
1214
+ case 'item/updated':
1215
+ case 'item/completed':
1216
+ return item && itemId ? this._item(method, item, itemId, identity, status) : status();
1217
+ default:
1218
+ return status();
1219
+ }
1220
+ }
1221
+
1222
+ /** A turn's native terminal as a result event (plus the quota it hit, as a rate limit). */
1223
+ private _terminal(
1224
+ method: string,
1225
+ params: Record<string, unknown>,
1226
+ identity: EventBase,
1227
+ turnId: string,
1228
+ ): SessionEvent[] {
1229
+ this._seenTerminals.add(turnId);
1230
+ const now = Date.now();
1231
+ const content = this._inflight?.evidence.content ?? '';
1232
+ if (method === 'turn/unqueued')
1233
+ // Reclaimed before it ran: no turn/completed will ever name it.
1234
+ return [
1235
+ {
1236
+ ...identity,
1237
+ kind: 'result',
1238
+ timestamp: now,
1239
+ text: content,
1240
+ nativeOutcome: 'failed',
1241
+ terminal: { type: method, turnId, subtype: 'unqueued' },
1242
+ },
1243
+ ];
1244
+ const error = object(params.error);
1245
+ const terminal = str(params.terminal) ?? 'unknown';
1246
+ const quota = object(error?.quota);
1247
+ const reason = str(error?.message) ?? str(params.reason);
1248
+ return [
1249
+ ...(quota && typeof quota.resetsAtMs === 'number'
1250
+ ? [
1251
+ this._limitEvent(
1252
+ {
1253
+ runtime: 'muse',
1254
+ source: 'stream',
1255
+ observedAt: now,
1256
+ windows: [],
1257
+ blocked: true,
1258
+ ...(str(error?.kind) ? { reachedType: str(error?.kind) } : {}),
1259
+ },
1260
+ { method, resetsAtMs: quota.resetsAtMs },
1261
+ ),
1262
+ ]
1263
+ : []),
1264
+ {
1265
+ ...identity,
1266
+ kind: 'result',
1267
+ timestamp: now,
1268
+ text: content,
1269
+ ...(this._turnTokens ? { tokens: this._turnTokens } : {}),
1270
+ nativeOutcome: terminal === 'completed' && !error ? 'completed' : 'failed',
1271
+ terminal: { type: method, turnId, subtype: terminal, ...(reason ? { reason } : {}) },
1272
+ },
1273
+ ];
1274
+ }
1275
+
1276
+ /**
1277
+ * A turn Muse started itself: a cron firing, a monitor or background-terminal wake, a goal, or a
1278
+ * message from another client of the same session. Its events fold as an admission's would, but
1279
+ * belong to none (`unattributedReason: 'task'`); the turn itself is a `task` lifecycle keyed by
1280
+ * its turn id, typed `muse-<trigger>` once its first item says what woke it.
1281
+ */
1282
+ private _museTurn(
1283
+ method: string,
1284
+ params: Record<string, unknown>,
1285
+ sessionId: string,
1286
+ turnId: string,
1287
+ item: Record<string, unknown> | undefined,
1288
+ itemId: string | undefined,
1289
+ ): SessionEvent[] {
1290
+ const now = Date.now();
1291
+ const turnBase: EventBase = {
1292
+ nativeSessionId: sessionId,
1293
+ turnId,
1294
+ correlation: 'native-turn',
1295
+ unattributedReason: 'task',
1296
+ };
1297
+ const base: EventBase = { ...turnBase, ...(itemId ? { itemId } : {}) };
1298
+ const status = (raw: Record<string, unknown> = {}): SessionEvent[] => [
1299
+ { ...base, kind: 'native_status', timestamp: now, raw: { method, ...raw } },
1300
+ ];
1301
+ const out: SessionEvent[] = [];
1302
+ let entry = this._museTurns.get(turnId);
1303
+ const lifecycle = (
1304
+ phase: 'started' | 'updated' | 'ended',
1305
+ extra: Partial<TaskLifecycle> = {},
1306
+ ): SessionEvent => ({
1307
+ ...turnBase,
1308
+ kind: 'task',
1309
+ timestamp: now,
1310
+ task: {
1311
+ phase,
1312
+ taskId: turnId,
1313
+ taskType: entry?.trigger ? `muse-${entry.trigger}` : 'muse-turn',
1314
+ ...extra,
1315
+ },
1316
+ });
1317
+ if (!entry) {
1318
+ entry = { content: '' };
1319
+ this._museTurns.set(turnId, entry);
1320
+ out.push(lifecycle('started'));
1321
+ }
1322
+ switch (method) {
1323
+ case 'turn/completed':
1324
+ case 'turn/unqueued': {
1325
+ this._seenTerminals.add(turnId);
1326
+ this._museTurns.delete(turnId);
1327
+ const reason = str(object(params.error)?.message) ?? str(params.reason);
1328
+ out.push(
1329
+ lifecycle('ended', {
1330
+ status: method === 'turn/unqueued' ? 'unqueued' : (str(params.terminal) ?? 'unknown'),
1331
+ ...(entry.content ? { summary: entry.content } : {}),
1332
+ ...(reason ? { reason } : {}),
1333
+ }),
1334
+ );
1335
+ return out;
1336
+ }
1337
+ case 'session/tokenUsage': {
1338
+ const tokens = museTokens(params.usage);
1339
+ out.push(
1340
+ ...(tokens ? [{ ...base, kind: 'usage' as const, timestamp: now, tokens }] : status()),
1341
+ );
1342
+ return out;
1343
+ }
1344
+ case 'item/delta':
1345
+ out.push(...this._delta(params, itemId, base, status));
1346
+ return out;
1347
+ case 'item/started':
1348
+ case 'item/updated':
1349
+ case 'item/completed': {
1350
+ if (!item || !itemId) return [...out, ...status()];
1351
+ const trigger = museTrigger(item);
1352
+ // A specific wake (cron, monitor, …) outranks the prompt message it delivers.
1353
+ if (trigger && (!entry.trigger || (entry.trigger === 'message' && trigger !== 'message'))) {
1354
+ entry.trigger = trigger;
1355
+ const description = triggerDescription(item);
1356
+ out.push(lifecycle('updated', description ? { description } : {}));
1357
+ }
1358
+ const events = this._item(method, item, itemId, base, status);
1359
+ for (const event of events)
1360
+ if (event.kind === 'text' && event.text && !event.parentToolUseId)
1361
+ entry.content = event.text;
1362
+ out.push(...events);
1363
+ return out;
1364
+ }
1365
+ default:
1366
+ return [...out, ...status()];
1367
+ }
1368
+ }
1369
+
1370
+ /** An item outside any turn (`turnId: null`): a monitor's feed, a shell run from another client. */
1371
+ private _background(
1372
+ method: string,
1373
+ params: Record<string, unknown>,
1374
+ sessionId: string,
1375
+ item: Record<string, unknown> | undefined,
1376
+ itemId: string,
1377
+ ): SessionEvent[] {
1378
+ const now = Date.now();
1379
+ const base: EventBase = {
1380
+ nativeSessionId: sessionId,
1381
+ itemId,
1382
+ correlation: 'unknown',
1383
+ unattributedReason: 'task',
1384
+ };
1385
+ const status = (raw: Record<string, unknown> = {}): SessionEvent[] => [
1386
+ { ...base, kind: 'native_status', timestamp: now, raw: { method, ...raw } },
1387
+ ];
1388
+ if (method === 'item/delta') return this._delta(params, itemId, base, status);
1389
+ if (!item) return status();
1390
+ if (item.kind !== 'monitorFeed') return this._item(method, item, itemId, base, status);
1391
+ const tracked = this._items.get(itemId) ?? {
1392
+ kind: 'monitorFeed',
1393
+ completed: false,
1394
+ started: false,
1395
+ };
1396
+ this._items.set(itemId, tracked);
1397
+ if (
1398
+ tracked.completed ||
1399
+ (method !== 'item/completed' && !TERMINAL_ITEM.has(String(item.status)))
1400
+ )
1401
+ return status({ itemKind: 'monitorFeed' });
1402
+ tracked.completed = true;
1403
+ const summary = str(item.text) ?? str(item.fallbackText);
1404
+ const description = str(item.monitorDescription);
1405
+ return [
1406
+ {
1407
+ ...base,
1408
+ kind: 'task',
1409
+ timestamp: now,
1410
+ task: {
1411
+ phase: 'progress',
1412
+ taskId: str(item.monitorId) ?? itemId,
1413
+ taskType: 'muse-monitor',
1414
+ ...(description ? { description } : {}),
1415
+ ...(summary ? { summary } : {}),
1416
+ },
1417
+ },
1418
+ ];
1419
+ }
1420
+
1421
+ /** The session's goal (create_goal, update_goal, report_progress), as a `muse-goal` task. */
1422
+ private _goal(params: Record<string, unknown>, sessionId: string): SessionEvent[] {
1423
+ const goal = object(params.goal);
1424
+ const status = goal ? (str(goal.status) ?? 'unknown') : 'cleared';
1425
+ const description = str(goal?.objective);
1426
+ const summary = str(goal?.currentWork);
1427
+ return [
1428
+ {
1429
+ nativeSessionId: sessionId,
1430
+ correlation: 'unknown',
1431
+ unattributedReason: 'task',
1432
+ kind: 'task',
1433
+ timestamp: Date.now(),
1434
+ task: {
1435
+ phase:
1436
+ !goal || /^(complete|completed|cleared|cancelled|abandoned)$/i.test(status)
1437
+ ? 'ended'
1438
+ : 'updated',
1439
+ taskId: 'goal',
1440
+ taskType: 'muse-goal',
1441
+ status,
1442
+ ...(description ? { description } : {}),
1443
+ ...(summary ? { summary } : {}),
1444
+ },
1445
+ raw: {
1446
+ method: 'session/goalChanged',
1447
+ ...(typeof goal?.percentComplete === 'number'
1448
+ ? { percentComplete: goal.percentComplete }
1449
+ : {}),
1450
+ ...(str(goal?.nextWork) ? { nextWork: str(goal?.nextWork) } : {}),
1451
+ },
1452
+ },
1453
+ ];
1454
+ }
1455
+
1456
+ private _delta(
1457
+ params: Record<string, unknown>,
1458
+ itemId: string | undefined,
1459
+ base: EventBase,
1460
+ status: (raw?: Record<string, unknown>) => SessionEvent[],
1461
+ ): SessionEvent[] {
1462
+ const delta = str(params.delta);
1463
+ const tracked = itemId ? this._items.get(itemId) : undefined;
1464
+ if (!delta || !tracked || tracked.completed) return status();
1465
+ if (tracked.kind === 'agentMessage' && (params.field ?? 'text') === 'text')
1466
+ return [{ ...base, kind: 'text_delta', timestamp: Date.now(), text: delta }];
1467
+ if (TOOL_KINDS.has(tracked.kind))
1468
+ return [
1469
+ {
1470
+ ...base,
1471
+ kind: 'native_status',
1472
+ timestamp: Date.now(),
1473
+ text: delta,
1474
+ raw: { method: 'item/delta', field: params.field ?? 'output' },
1475
+ },
1476
+ ];
1477
+ return status({ itemKind: tracked.kind });
1478
+ }
1479
+
1480
+ private _item(
1481
+ method: string,
1482
+ item: Record<string, unknown>,
1483
+ itemId: string,
1484
+ base: EventBase,
1485
+ status: (raw?: Record<string, unknown>) => SessionEvent[],
1486
+ ): SessionEvent[] {
1487
+ const kind = str(item.kind) ?? 'unknown';
1488
+ const prior = this._items.get(itemId);
1489
+ const tracked: TrackedItem = prior ?? {
1490
+ kind,
1491
+ turnId: str(item.turnId),
1492
+ completed: false,
1493
+ started: false,
1494
+ };
1495
+ this._items.set(itemId, tracked);
1496
+ if (tracked.completed) return status({ itemKind: kindLabel(item), duplicate: true });
1497
+ const done = method === 'item/completed';
1498
+ const ended = done || TERMINAL_ITEM.has(String(item.status));
1499
+ const now = Date.now();
1500
+ const out: SessionEvent[] = [];
1501
+
1502
+ if (kind === 'agentMessage') {
1503
+ if (!done) return status({ itemKind: kind });
1504
+ tracked.completed = true;
1505
+ return typeof item.text === 'string'
1506
+ ? [{ ...base, kind: 'text', timestamp: now, text: item.text }]
1507
+ : status({ itemKind: kind, malformed: true });
1508
+ }
1509
+ if (kind === 'reasoning') {
1510
+ if (!done) return status({ itemKind: kind });
1511
+ tracked.completed = true;
1512
+ const text =
1513
+ str(item.text) ??
1514
+ (Array.isArray(item.summary)
1515
+ ? item.summary.filter((s) => typeof s === 'string').join('\n')
1516
+ : undefined);
1517
+ return text
1518
+ ? [{ ...base, kind: 'thinking', timestamp: now, text }]
1519
+ : status({ itemKind: kind });
1520
+ }
1521
+ if (TOOL_KINDS.has(kind)) {
1522
+ const tool = toolIdentity(kind, item);
1523
+ if (!tracked.started) {
1524
+ tracked.started = true;
1525
+ out.push({ ...base, ...tool, kind: 'tool_use', timestamp: now, ...toolInput(kind, item) });
1526
+ }
1527
+ if (ended) {
1528
+ tracked.completed = true;
1529
+ const output = str(item.visibleOutput) ?? str(item.failureReason) ?? str(item.fallbackText);
1530
+ out.push({
1531
+ ...base,
1532
+ ...tool,
1533
+ kind: 'tool_result',
1534
+ timestamp: now,
1535
+ ...(output !== undefined ? { toolOutput: output } : { toolOutputOmitted: true }),
1536
+ toolError:
1537
+ item.status !== 'completed' ||
1538
+ (typeof item.exitCode === 'number' && item.exitCode !== 0),
1539
+ });
1540
+ }
1541
+ return out.length ? out : status({ itemKind: kind });
1542
+ }
1543
+ if (kind === 'subagent') {
1544
+ const taskId = str(item.childSessionId) ?? str(item.subagentId) ?? itemId;
1545
+ const lifecycle = {
1546
+ taskId,
1547
+ toolUseId: itemId,
1548
+ ...(str(item.role) ? { agentType: str(item.role) } : {}),
1549
+ ...(str(item.objective) ? { prompt: str(item.objective) } : {}),
1550
+ ...(typeof item.depth === 'number' ? { depth: item.depth } : { depth: 1 }),
1551
+ ...(typeof item.background === 'boolean' ? { backgrounded: item.background } : {}),
1552
+ };
1553
+ if (!tracked.started) {
1554
+ tracked.started = true;
1555
+ out.push({
1556
+ ...base,
1557
+ kind: 'subagent',
1558
+ timestamp: now,
1559
+ subagent: { phase: 'started', ...lifecycle },
1560
+ });
1561
+ }
1562
+ if (ended) {
1563
+ tracked.completed = true;
1564
+ const summary = str(object(item.result)?.summary) ?? str(item.finalSummary);
1565
+ out.push({
1566
+ ...base,
1567
+ kind: 'subagent',
1568
+ timestamp: now,
1569
+ subagent: {
1570
+ phase: 'ended',
1571
+ ...lifecycle,
1572
+ status: String(item.status),
1573
+ ...(summary ? { summary } : {}),
1574
+ },
1575
+ });
1576
+ } else if (prior)
1577
+ out.push({
1578
+ ...base,
1579
+ kind: 'subagent',
1580
+ timestamp: now,
1581
+ subagent: { phase: 'updated', ...lifecycle, status: String(item.status) },
1582
+ });
1583
+ return out;
1584
+ }
1585
+ if (kind === 'compaction') {
1586
+ if (!ended) return status({ itemKind: kind });
1587
+ tracked.completed = true;
1588
+ return [
1589
+ {
1590
+ ...base,
1591
+ kind: 'session_compact',
1592
+ timestamp: now,
1593
+ compactionSource: 'muse',
1594
+ raw: {
1595
+ method,
1596
+ outcome: str(item.outcome),
1597
+ trigger: str(item.trigger),
1598
+ tokensBefore: item.tokensBefore,
1599
+ tokensAfter: item.tokensAfter,
1600
+ },
1601
+ },
1602
+ ];
1603
+ }
1604
+ if (kind === 'reminderChild' || kind === 'workflow') {
1605
+ // Muse's own background agents (skill, goal and verify reminders; workflows): tasks, never
1606
+ // part of an admission's content.
1607
+ const { admissionId: _admission, ...rest } = base;
1608
+ const description =
1609
+ str(item.reminderAgentId) ?? str(item.label) ?? str(item.entryId) ?? undefined;
1610
+ const lifecycle = {
1611
+ taskId: str(item.childSessionId) ?? str(item.workflowRunId) ?? itemId,
1612
+ taskType: kind === 'reminderChild' ? 'muse-reminder' : 'muse-workflow',
1613
+ ...(description ? { description } : {}),
1614
+ };
1615
+ const taskBase = { ...rest, unattributedReason: 'task' as const };
1616
+ if (!tracked.started) {
1617
+ tracked.started = true;
1618
+ out.push({
1619
+ ...taskBase,
1620
+ kind: 'task',
1621
+ timestamp: now,
1622
+ task: { phase: 'started', ...lifecycle },
1623
+ });
1624
+ }
1625
+ if (ended) {
1626
+ tracked.completed = true;
1627
+ const summary = str(item.finalSummary) ?? str(object(item.result)?.summary);
1628
+ out.push({
1629
+ ...taskBase,
1630
+ kind: 'task',
1631
+ timestamp: now,
1632
+ task: {
1633
+ phase: 'ended',
1634
+ ...lifecycle,
1635
+ status: String(item.status),
1636
+ ...(summary ? { summary } : {}),
1637
+ },
1638
+ });
1639
+ }
1640
+ return out.length ? out : status({ itemKind: kind });
1641
+ }
1642
+ if (done) tracked.completed = true;
1643
+ return status({ itemKind: kindLabel(item), status: str(item.status) });
1644
+ }
1645
+
1646
+ private _settle(map: Map<string, AbortController>, id: string | undefined, prefix = false) {
1647
+ if (!id) return;
1648
+ for (const [key, controller] of map)
1649
+ if (key === id || (prefix && key.startsWith(`${id}:`))) {
1650
+ controller.abort();
1651
+ map.delete(key);
1652
+ }
1653
+ }
1654
+
1655
+ // ---------------------------------------------------------------------------
1656
+ // Approvals → gates (stage `permission`); questions → onUserInput
1657
+ // ---------------------------------------------------------------------------
1658
+
1659
+ private async _approval(request: ApprovalRequest): Promise<ApprovalDecisionInput> {
1660
+ const choices = request.availableChoices;
1661
+ const allow = choices.find((c) => c.decision === 'approved' && c.scope === 'once');
1662
+ const deny =
1663
+ choices.find((c) => c.decision === 'denied' && c.scope === 'once') ??
1664
+ choices.find((c) => c.decision === 'abort');
1665
+ const stage = request.currentRequirementId?.sourceIndex ?? 0;
1666
+ const key = `${request.approvalId}:${stage}`;
1667
+ const controller = new AbortController();
1668
+ this._approvals.get(key)?.abort();
1669
+ this._approvals.set(key, controller);
1670
+ // An approval can outrun our turn/start answer; attribute it once the answer names our turn.
1671
+ await this._ackDue;
1672
+ const a = this._inflight?.evidence;
1673
+ const identity =
1674
+ a && a.identity.turnId === request.turnId
1675
+ ? { ...a.identity, admissionId: a.admissionId, itemId: request.itemId }
1676
+ : {
1677
+ nativeSessionId: request.sessionId,
1678
+ turnId: request.turnId,
1679
+ correlation: 'unknown' as const,
1680
+ unattributedReason: 'foreign-turn' as const,
1681
+ };
1682
+ this._emit({
1683
+ ...identity,
1684
+ kind: 'native_status',
1685
+ timestamp: Date.now(),
1686
+ raw: { type: 'approval-request', toolName: request.toolName, stage },
1687
+ });
1688
+ const decision = await this._gates.decide({
1689
+ stage: 'permission',
1690
+ toolName: request.toolName,
1691
+ ...mcpToolParts(request.toolName),
1692
+ toolInput: Object.freeze(approvalInput(request)),
1693
+ callId: request.toolCallId,
1694
+ ...(a && a.identity.turnId === request.turnId ? { admissionId: a.admissionId } : {}),
1695
+ ...(request.reviewerReason ? { description: request.reviewerReason } : {}),
1696
+ ...(request.subagentOrigin ? { agentId: request.subagentOrigin.subagentId } : {}),
1697
+ signal: controller.signal,
1698
+ });
1699
+ if (this._approvals.get(key) === controller) this._approvals.delete(key);
1700
+ const accepted = decision?.behavior === 'allow' && !controller.signal.aborted && !!allow;
1701
+ const choice = accepted ? allow : deny;
1702
+ if (!choice) {
1703
+ this._emit({
1704
+ ...identity,
1705
+ kind: 'error',
1706
+ timestamp: Date.now(),
1707
+ text: `Muse offered no approve-once or reject choice for ${request.toolName}; the approval stays open`,
1708
+ raw: { type: 'approval-unanswerable', choices: choices.map((c) => c.decision) },
1709
+ });
1710
+ throw new Error('Muse offered no approve-once or reject choice');
1711
+ }
1712
+ const feedback =
1713
+ !accepted && decision?.behavior === 'deny' && choice.acceptsFeedback
1714
+ ? decision.message
1715
+ : undefined;
1716
+ this._emit({
1717
+ ...identity,
1718
+ kind: 'native_status',
1719
+ timestamp: Date.now(),
1720
+ raw: {
1721
+ type: 'approval-response',
1722
+ toolName: request.toolName,
1723
+ stage,
1724
+ decision: accepted ? 'approve-once' : choice.decision,
1725
+ },
1726
+ });
1727
+ return { choiceId: choice.choiceId, ...(feedback ? { feedback } : {}) };
1728
+ }
1729
+
1730
+ private _approvalFailure(failure: ApprovalFailure): void {
1731
+ this._emit({
1732
+ kind: 'native_status',
1733
+ timestamp: Date.now(),
1734
+ nativeSessionId: this._externalSessionId,
1735
+ correlation: 'unknown',
1736
+ unattributedReason: 'unrecognized-event',
1737
+ raw: { type: 'approval-failure', failure: failure.kind, approvalId: failure.approvalId },
1738
+ });
1739
+ }
1740
+
1741
+ private _question(params: Record<string, unknown>): void {
1742
+ const userInputId = str(params.userInputId);
1743
+ const sessionId = str(params.sessionId);
1744
+ if (!userInputId || !sessionId || this._askedInputs.has(userInputId)) return;
1745
+ this._askedInputs.add(userInputId);
1746
+ const controller = new AbortController();
1747
+ this._userInputs.set(userInputId, controller);
1748
+ const turnId = str(params.turnId);
1749
+ const questions = Array.isArray(params.questions)
1750
+ ? (params.questions as MuseUserInputQuestion[])
1751
+ : [];
1752
+ const connection = this._host?.connection;
1753
+ const handler = this._userInputHandler;
1754
+ let identity: EventBase = {
1755
+ nativeSessionId: sessionId,
1756
+ ...(turnId ? { turnId } : {}),
1757
+ correlation: 'unknown',
1758
+ unattributedReason: 'task',
1759
+ };
1760
+ const emit = (type: string, extra: Record<string, unknown> = {}) =>
1761
+ this._emit({
1762
+ ...identity,
1763
+ kind: 'native_status',
1764
+ timestamp: Date.now(),
1765
+ raw: { type, userInputId, questions: questions.length, ...extra },
1766
+ });
1767
+ void (async () => {
1768
+ // A question can outrun our turn/start answer; attribute it once the answer names our turn.
1769
+ // One from a turn Muse started itself is a task's, like the rest of that turn.
1770
+ await this._ackDue;
1771
+ const a = this._inflight?.evidence;
1772
+ const owned = !!a && !!turnId && a.identity.turnId === turnId;
1773
+ if (owned) identity = { ...a.identity, admissionId: a.admissionId };
1774
+ emit('question-request');
1775
+ let answers: readonly MuseUserInputAnswer[] | undefined;
1776
+ let reason = 'No client handler answers Muse questions in this session';
1777
+ if (handler)
1778
+ try {
1779
+ answers = await handler({
1780
+ userInputId,
1781
+ ...(turnId ? { turnId } : {}),
1782
+ ...(str(params.toolName) ? { toolName: str(params.toolName) } : {}),
1783
+ questions: retainEvidence(questions),
1784
+ ...(owned ? { admissionId: a.admissionId } : {}),
1785
+ signal: controller.signal,
1786
+ });
1787
+ if (!answers?.length) reason = 'The client declined to answer';
1788
+ } catch (error) {
1789
+ answers = undefined;
1790
+ reason =
1791
+ `The client could not answer: ${error instanceof Error ? error.message : String(error)}`.slice(
1792
+ 0,
1793
+ 300,
1794
+ );
1795
+ }
1796
+ if (controller.signal.aborted || !connection || !this._alive) return;
1797
+ try {
1798
+ if (answers?.length) {
1799
+ await connection.command('userInput/answer', { sessionId, userInputId, answers });
1800
+ emit('question-answered');
1801
+ } else {
1802
+ await connection.command('userInput/cancel', {
1803
+ sessionId,
1804
+ userInputId,
1805
+ reason,
1806
+ });
1807
+ emit('question-cancelled', { reason });
1808
+ }
1809
+ } catch (error) {
1810
+ emit('question-failed', { kind: error instanceof MspError ? error.kind : 'transport' });
1811
+ } finally {
1812
+ if (this._userInputs.get(userInputId) === controller) this._userInputs.delete(userInputId);
1813
+ }
1814
+ })();
1815
+ }
1816
+
1817
+ // ---------------------------------------------------------------------------
1818
+ // Account limits, native interrupt, steer
1819
+ // ---------------------------------------------------------------------------
1820
+
1821
+ /** usage/read — no model turn. Undefined until Meta has reported windows on this host. */
1822
+ async readLimits(opts?: { timeoutMs?: number }): Promise<LimitSnapshot | undefined> {
1823
+ if (!this._host || !this._alive) throw Error('Session not running');
1824
+ const result = await bounded(
1825
+ this._host.connection.command('usage/read', {}),
1826
+ opts?.timeoutMs ?? 10_000,
1827
+ 'usage/read timed out',
1828
+ );
1829
+ const snapshot = museLimitSnapshot(result, 'poll');
1830
+ if (snapshot) this._emit(this._limitEvent(snapshot));
1831
+ return snapshot;
1832
+ }
1833
+
1834
+ /** turn/interrupt for the owned turn: "acknowledged" once its own terminal arrives. */
1835
+ async interruptNative(opts?: { timeoutMs?: number }): Promise<NativeInterruptOutcome> {
1836
+ const a = this._inflight?.evidence;
1837
+ const sessionId = this._externalSessionId;
1838
+ if (
1839
+ a?.dispatch !== 'attempted' ||
1840
+ a.nativeOutcome !== 'unknown' ||
1841
+ !a.identity.turnId ||
1842
+ !sessionId ||
1843
+ !this._host
1844
+ )
1845
+ return 'no-turn';
1846
+ const deadline = Date.now() + (opts?.timeoutMs ?? 5_000);
1847
+ try {
1848
+ await bounded(
1849
+ this._host.connection.command('turn/interrupt', { sessionId, turnId: a.identity.turnId }),
1850
+ Math.max(1, deadline - Date.now()),
1851
+ 'turn/interrupt timed out',
1852
+ );
1853
+ } catch {
1854
+ return 'unacknowledged';
1855
+ }
1856
+ while (a.nativeOutcome === 'unknown' && this._alive && Date.now() < deadline)
1857
+ await Bun.sleep(10);
1858
+ return a.nativeOutcome === 'unknown' ? 'unacknowledged' : 'acknowledged';
1859
+ }
1860
+
1861
+ /** Mid-turn steer (turn/steer) into the owned in-flight turn; otherwise records push_ignored. */
1862
+ async push(payload: { kind: string; text: string }): Promise<void> {
1863
+ const a = this._inflight?.evidence;
1864
+ const sessionId = this._externalSessionId;
1865
+ const ignored = (reason: string) =>
1866
+ this._emit({
1867
+ kind: 'error',
1868
+ timestamp: Date.now(),
1869
+ text: `push_ignored: kind=${payload.kind} — ${reason}`,
1870
+ raw: { kind: payload.kind },
1871
+ });
1872
+ if (
1873
+ a?.dispatch !== 'attempted' ||
1874
+ a.nativeOutcome !== 'unknown' ||
1875
+ !a.identity.turnId ||
1876
+ !sessionId ||
1877
+ !this._host
1878
+ )
1879
+ return ignored('no owned in-flight turn');
1880
+ const identity = { ...a.identity, admissionId: a.admissionId };
1881
+ try {
1882
+ await bounded(
1883
+ this._host.connection.command('turn/steer', {
1884
+ sessionId,
1885
+ expectedTurnId: a.identity.turnId,
1886
+ input: [{ type: 'text', text: payload.text }],
1887
+ }),
1888
+ 10_000,
1889
+ 'turn/steer unanswered',
1890
+ );
1891
+ this._emit({
1892
+ ...identity,
1893
+ kind: 'native_status',
1894
+ timestamp: Date.now(),
1895
+ raw: { type: 'push-steered', kind: payload.kind },
1896
+ });
1897
+ } catch (error) {
1898
+ // A refusal is the runtime's answer; no answer leaves delivery unknown, never "ignored".
1899
+ if (error instanceof MspError) ignored(`turn/steer refused: ${error.message.slice(0, 200)}`);
1900
+ else
1901
+ this._emit({
1902
+ ...identity,
1903
+ kind: 'native_status',
1904
+ timestamp: Date.now(),
1905
+ raw: { type: 'push-unknown', kind: payload.kind },
1906
+ });
1907
+ }
1908
+ }
1909
+ }
1910
+
1911
+ // ---------------------------------------------------------------------------
1912
+ // Default host: `muse serve` through the SDK's owned-process handshake
1913
+ // ---------------------------------------------------------------------------
1914
+
1915
+ async function spawnMuseHost(launch: MuseLaunch): Promise<MuseHost> {
1916
+ const handshake = spawnMspConnection({
1917
+ command: launch.museBin,
1918
+ args: launch.args,
1919
+ cwd: launch.cwd,
1920
+ env: launch.env,
1921
+ shutdownTimeoutMs: 10_000,
1922
+ });
1923
+ try {
1924
+ const spawned = await bounded(
1925
+ handshake.initialize(launch.initialize),
1926
+ STARTUP_TIMEOUT_MS,
1927
+ `muse serve did not initialize within ${STARTUP_TIMEOUT_MS}ms`,
1928
+ );
1929
+ return {
1930
+ connection: spawned.connection,
1931
+ initializeResult: spawned.initializeResult,
1932
+ exited: spawned.exited,
1933
+ stderrTail: () => handshake.child.stderrTail,
1934
+ close: () => spawned.close(),
1935
+ };
1936
+ } catch (error) {
1937
+ await handshake.close().catch(() => undefined);
1938
+ throw error;
1939
+ }
1940
+ }
1941
+
1942
+ // ---------------------------------------------------------------------------
1943
+ // Helpers
1944
+ // ---------------------------------------------------------------------------
1945
+
1946
+ const TOOL_KINDS = new Set(['toolCall', 'userShell', 'webSearch']);
1947
+ /** The item that says what woke a turn Muse started itself, and the task type it gives that turn. */
1948
+ const MUSE_TRIGGERS: Readonly<Record<string, string>> = {
1949
+ scheduledFire: 'cron',
1950
+ monitorFeed: 'monitor',
1951
+ backgroundTerminalCompletion: 'background-terminal',
1952
+ overdueNotice: 'overdue',
1953
+ userMessage: 'message',
1954
+ };
1955
+ /** A monitor wake turn carries no feed item (feeds sit outside turns); its first answer names the source. */
1956
+ const wakeSources = (item: Record<string, unknown>): string[] =>
1957
+ Array.isArray(item.wakeSourceDescriptions)
1958
+ ? item.wakeSourceDescriptions.filter((d): d is string => typeof d === 'string' && !!d)
1959
+ : [];
1960
+ function museTrigger(item: Record<string, unknown>): string | undefined {
1961
+ return MUSE_TRIGGERS[String(item.kind)] ?? (wakeSources(item).length ? 'monitor' : undefined);
1962
+ }
1963
+ function triggerDescription(item: Record<string, unknown>): string | undefined {
1964
+ if (item.kind === 'agentMessage' && wakeSources(item).length) return wakeSources(item).join('; ');
1965
+ switch (item.kind) {
1966
+ case 'scheduledFire':
1967
+ return str(item.jobId) ? `cron job ${item.jobId}` : 'cron job';
1968
+ case 'monitorFeed':
1969
+ return str(item.monitorDescription);
1970
+ case 'userMessage':
1971
+ return 'a message sent by another client of this session';
1972
+ default:
1973
+ return str(item.fallbackText);
1974
+ }
1975
+ }
1976
+ const TERMINAL_ITEM = new Set(['completed', 'failed', 'cancelled', 'rejected', 'timedOut']);
1977
+ const KNOWN_KINDS = new Set([
1978
+ 'userMessage',
1979
+ 'agentMessage',
1980
+ 'reasoning',
1981
+ 'toolCall',
1982
+ 'userShell',
1983
+ 'subagent',
1984
+ 'workflow',
1985
+ 'reminderChild',
1986
+ 'compaction',
1987
+ 'hookRun',
1988
+ 'sideChat',
1989
+ 'webSearch',
1990
+ 'monitorFeed',
1991
+ 'damagedRange',
1992
+ 'scheduledFire',
1993
+ 'backgroundTerminalCompletion',
1994
+ 'overdueNotice',
1995
+ 'earlierHistory',
1996
+ ]);
1997
+
1998
+ const object = (value: unknown): Record<string, unknown> | undefined =>
1999
+ value !== null && typeof value === 'object' && !Array.isArray(value)
2000
+ ? (value as Record<string, unknown>)
2001
+ : undefined;
2002
+ const str = (value: unknown): string | undefined =>
2003
+ typeof value === 'string' && value ? value : undefined;
2004
+ /** A known item kind, or `unsupported`: unknown values are not safe labels. */
2005
+ const kindLabel = (item: Record<string, unknown>) =>
2006
+ KNOWN_KINDS.has(String(item.kind)) ? String(item.kind) : 'unsupported';
2007
+
2008
+ function bounded<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
2009
+ let timer: ReturnType<typeof setTimeout> | undefined;
2010
+ return Promise.race([
2011
+ promise,
2012
+ new Promise<never>((_, reject) => {
2013
+ timer = setTimeout(() => reject(new Error(message)), ms);
2014
+ }),
2015
+ ]).finally(() => clearTimeout(timer));
2016
+ }
2017
+
2018
+ function toolIdentity(
2019
+ kind: string,
2020
+ item: Record<string, unknown>,
2021
+ ): Pick<SessionEvent, 'toolName' | 'toolServer' | 'toolMethod' | 'callId'> {
2022
+ const toolName =
2023
+ kind === 'userShell' ? 'shell' : kind === 'webSearch' ? 'web_search' : (str(item.tool) ?? kind);
2024
+ return {
2025
+ toolName,
2026
+ ...mcpToolParts(toolName),
2027
+ ...(str(item.callId) ? { callId: str(item.callId) } : {}),
2028
+ };
2029
+ }
2030
+
2031
+ function toolInput(
2032
+ kind: string,
2033
+ item: Record<string, unknown>,
2034
+ ): Pick<SessionEvent, 'toolInput' | 'toolInputOmitted'> {
2035
+ if (kind === 'userShell' && str(item.commandText))
2036
+ return { toolInput: { command: item.commandText } };
2037
+ const args = parseArgs(item.args);
2038
+ if (args) return { toolInput: args };
2039
+ return {
2040
+ toolInputOmitted:
2041
+ typeof item.args === 'string' ? 'Native arguments are not a JSON object' : 'No arguments',
2042
+ };
2043
+ }
2044
+
2045
+ function parseArgs(value: unknown): Record<string, unknown> | undefined {
2046
+ if (typeof value !== 'string') return undefined;
2047
+ try {
2048
+ return object(JSON.parse(value));
2049
+ } catch {
2050
+ return undefined;
2051
+ }
2052
+ }
2053
+
2054
+ /** What a gate sees of an approval: the call's arguments, what Muse asks about, and which stage. */
2055
+ function approvalInput(request: ApprovalRequest): Record<string, unknown> {
2056
+ const subject = request.subject;
2057
+ const stage = subject.stages?.find(
2058
+ (s) => s.requirementId.sourceIndex === request.currentRequirementId.sourceIndex,
2059
+ );
2060
+ const pick = <K extends keyof typeof subject>(...keys: K[]) =>
2061
+ Object.fromEntries(keys.flatMap((k) => (subject[k] !== undefined ? [[k, subject[k]]] : [])));
2062
+ return {
2063
+ ...(parseArgs(request.rawArgs) ?? {}),
2064
+ approval: {
2065
+ ...pick('kind', 'command', 'path', 'access', 'host', 'port', 'protocol', 'target'),
2066
+ ...(stage
2067
+ ? { argv: [...stage.argv], position: stage.position, totalStages: stage.totalStages }
2068
+ : {}),
2069
+ protectedWrite: request.protectedWrite,
2070
+ },
2071
+ };
2072
+ }
2073
+
2074
+ /** Muse per-request usage: input includes cached reads and writes, reasoning is part of output. */
2075
+ function museTokens(value: unknown): SessionTokens | undefined {
2076
+ const usage = object(value);
2077
+ const count = (v: unknown) =>
2078
+ typeof v === 'number' && Number.isSafeInteger(v) && v >= 0 ? v : undefined;
2079
+ const input = count(usage?.inputTokens),
2080
+ output = count(usage?.outputTokens);
2081
+ if (!usage || input === undefined || output === undefined) return undefined;
2082
+ const cacheRead = count(usage.cacheReadTokens) ?? count(usage.cachedTokens) ?? 0;
2083
+ const cacheWrite = count(usage.cacheWriteTokens) ?? 0;
2084
+ const thinking = count(usage.reasoningTokens);
2085
+ return {
2086
+ input: Math.max(0, input - cacheRead - cacheWrite),
2087
+ output,
2088
+ cacheRead,
2089
+ cacheWrite,
2090
+ ...(thinking !== undefined ? { thinking } : {}),
2091
+ providerUsage: usage,
2092
+ };
2093
+ }
2094
+
2095
+ function addTokens(total: SessionTokens | undefined, next: SessionTokens): SessionTokens {
2096
+ if (!total) {
2097
+ const { providerUsage: _raw, ...first } = next;
2098
+ return first;
2099
+ }
2100
+ const sum = (x?: number, y?: number) =>
2101
+ x === undefined && y === undefined ? undefined : (x ?? 0) + (y ?? 0);
2102
+ const thinking = sum(total.thinking, next.thinking);
2103
+ return {
2104
+ input: total.input + next.input,
2105
+ output: total.output + next.output,
2106
+ cacheRead: sum(total.cacheRead, next.cacheRead),
2107
+ cacheWrite: sum(total.cacheWrite, next.cacheWrite),
2108
+ ...(thinking !== undefined ? { thinking } : {}),
2109
+ };
2110
+ }