@sagentlab/navarch-runtime 0.1.112 → 0.1.117

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.
Files changed (56) hide show
  1. package/README.md +56 -18
  2. package/bin/muse-mcp-bridge.cjs +3 -0
  3. package/dist/adapters/index.cjs +6 -1
  4. package/dist/adapters/muse.cjs +433 -0
  5. package/dist/agent-auth.cjs +2 -1
  6. package/dist/cli.cjs +4 -1
  7. package/dist/config.cjs +9 -1
  8. package/dist/conversation/capability.cjs +1 -1
  9. package/dist/conversation/loop.cjs +16 -0
  10. package/dist/conversation/mirror.cjs +225 -0
  11. package/dist/conversation/workspace.cjs +11 -4
  12. package/dist/exit-conditions.cjs +1 -1
  13. package/dist/muse-mcp-bridge.cjs +167 -0
  14. package/dist/session.cjs +30 -8
  15. package/dist/worktree-guard.cjs +137 -3
  16. package/node_modules/@muse-code/sdk/LICENSE +21 -0
  17. package/node_modules/@muse-code/sdk/README.md +88 -0
  18. package/node_modules/@muse-code/sdk/dist/src/connection/connection.d.ts +149 -0
  19. package/node_modules/@muse-code/sdk/dist/src/connection/connection.js +581 -0
  20. package/node_modules/@muse-code/sdk/dist/src/connection/spawn.d.ts +188 -0
  21. package/node_modules/@muse-code/sdk/dist/src/connection/spawn.js +496 -0
  22. package/node_modules/@muse-code/sdk/dist/src/errors.d.ts +71 -0
  23. package/node_modules/@muse-code/sdk/dist/src/errors.js +76 -0
  24. package/node_modules/@muse-code/sdk/dist/src/facade/approval.d.ts +96 -0
  25. package/node_modules/@muse-code/sdk/dist/src/facade/approval.js +293 -0
  26. package/node_modules/@muse-code/sdk/dist/src/facade/client.d.ts +171 -0
  27. package/node_modules/@muse-code/sdk/dist/src/facade/client.js +442 -0
  28. package/node_modules/@muse-code/sdk/dist/src/facade/discarded.d.ts +36 -0
  29. package/node_modules/@muse-code/sdk/dist/src/facade/discarded.js +36 -0
  30. package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.d.ts +80 -0
  31. package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.js +388 -0
  32. package/node_modules/@muse-code/sdk/dist/src/facade/host-death.d.ts +139 -0
  33. package/node_modules/@muse-code/sdk/dist/src/facade/host-death.js +96 -0
  34. package/node_modules/@muse-code/sdk/dist/src/facade/host-start.d.ts +62 -0
  35. package/node_modules/@muse-code/sdk/dist/src/facade/host-start.js +81 -0
  36. package/node_modules/@muse-code/sdk/dist/src/facade/session.d.ts +309 -0
  37. package/node_modules/@muse-code/sdk/dist/src/facade/session.js +654 -0
  38. package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.d.ts +156 -0
  39. package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.js +271 -0
  40. package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.d.ts +72 -0
  41. package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.js +183 -0
  42. package/node_modules/@muse-code/sdk/dist/src/fingerprint.d.ts +37 -0
  43. package/node_modules/@muse-code/sdk/dist/src/fingerprint.js +54 -0
  44. package/node_modules/@muse-code/sdk/dist/src/fold/item-store.d.ts +110 -0
  45. package/node_modules/@muse-code/sdk/dist/src/fold/item-store.js +169 -0
  46. package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.d.ts +536 -0
  47. package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.js +427 -0
  48. package/node_modules/@muse-code/sdk/dist/src/fold/state-store.d.ts +68 -0
  49. package/node_modules/@muse-code/sdk/dist/src/fold/state-store.js +89 -0
  50. package/node_modules/@muse-code/sdk/dist/src/index.d.ts +65 -0
  51. package/node_modules/@muse-code/sdk/dist/src/index.js +57 -0
  52. package/node_modules/@muse-code/sdk/dist/src/msp.d.ts +3380 -0
  53. package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.d.ts +361 -0
  54. package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.js +468 -0
  55. package/node_modules/@muse-code/sdk/package.json +47 -0
  56. package/package.json +8 -4
@@ -0,0 +1,654 @@
1
+ /**
2
+ * `Session` — the SS7.1 facade's composition of the transport-less core
3
+ * (spec 14990 FR-018 / T030, FM-002 / T030b).
4
+ *
5
+ * It owns a `SessionFold` and a `PendingCommandSet`, routes folded view events
6
+ * to per-turn handles, and discharges the ephemeral host-death obligation.
7
+ * Like everything else in this SDK it holds no durable state (spec Lifecycle &
8
+ * Composition): every fact it reports came from a server event or from the two
9
+ * stores it composes.
10
+ *
11
+ * The SS4.8 splice-fill (FR-020, T032) is here too, in `gap-fill.ts`: this
12
+ * layer owns it because the recipe needs client→server I/O (`view/page`) and
13
+ * because the buffer it splices is the same routing path the iterators read.
14
+ *
15
+ * Everything else FR-018/FR-019 name IS here as of #23980: `sendUserTurn` over
16
+ * the enrolled `turn/start` row, `onApproval`'s idempotent `approval/decide`
17
+ * submission, and the SS4.13 retirement drivers that need client->server I/O.
18
+ * The connection is an OPTIONAL seam: a fold-only `Session` — the transport-less
19
+ * composition every earlier arm builds — stays valid and refuses the submit
20
+ * verbs loudly rather than dropping a caller's input on the floor.
21
+ */
22
+ import { SessionFold } from "../fold/session-fold.js";
23
+ import { PendingCommandSet } from "../pending/pending-command-set.js";
24
+ import { ApprovalRouter } from "./approval.js";
25
+ import { TurnSubmitter } from "./turn-submit.js";
26
+ import { GapFiller } from "./gap-fill.js";
27
+ import { isAbnormalHostDeath, isItemInProgress, MuseHostDiedError, survivesHostDeath, } from "./host-death.js";
28
+ import { MuseForeignSessionError } from "../errors.js";
29
+ import { TurnHandle } from "./turn-handle.js";
30
+ /** Bound to the generated vocabulary so a misspelling fails `tsc`. */
31
+ const USER_MESSAGE = "userMessage";
32
+ /**
33
+ * The shared already-settled `io` for an event that triggered no I/O.
34
+ *
35
+ * A fresh `Promise.resolve([])` per `apply` would allocate on the hot
36
+ * notification path for the overwhelming majority of frames, which carry
37
+ * neither a pending command nor an approval.
38
+ */
39
+ const NO_IO = Promise.resolve([]);
40
+ /**
41
+ * One conversation with the agent.
42
+ *
43
+ * A session folds every server event into readable state — items, turns,
44
+ * session facts, pending approvals — and submits your side of the exchange.
45
+ * Get one from `MuseClient.startSession` or `resumeSession`, then
46
+ * `sendUserTurn` to talk, `turn` to follow a turn's items to its outcome,
47
+ * and `onApproval` to answer permission requests.
48
+ */
49
+ export class Session {
50
+ sessionId;
51
+ /** See {@link SessionOpening}. Absent when the caller built this directly. */
52
+ opening;
53
+ #pending;
54
+ #discarded;
55
+ /** `turn/start` submission and same-`commandId` replay (`turn-submit.ts`). */
56
+ #submit;
57
+ /** The FR-019 round trip (`approval.ts`). */
58
+ #approvals;
59
+ /** The FR-020 splice-fill (`gap-fill.ts`). */
60
+ #gaps;
61
+ #fold = new SessionFold();
62
+ #durability;
63
+ /** The first discharge, replayed verbatim on every later call. */
64
+ #discharge;
65
+ /** Set on a durable abnormal death, so late handles inherit the rejection. */
66
+ #deathError;
67
+ #turns = new Map();
68
+ /**
69
+ * `item/delta` frames whose item the fold does not hold yet. The store
70
+ * already buffers the delta TEXT against the item's arrival (SS4.7.3, spec
71
+ * Edge Cases); a delta's TURN is equally unknowable until then, so the
72
+ * frames wait here and are attributed when the item lands. Dropping them
73
+ * instead would put a silent hole in the very iterators FR-020 forbids one
74
+ * in. Bounded by the same fact that bounds the store's own buffer: the
75
+ * durable `item/completed` always lands the item.
76
+ */
77
+ #unattributedDeltas = new Map();
78
+ constructor(options) {
79
+ this.sessionId = options.sessionId;
80
+ this.#durability = options.durability;
81
+ this.#discarded = options.discarded;
82
+ this.opening = options.opening;
83
+ this.#pending = new PendingCommandSet(options.discarded === undefined
84
+ ? undefined
85
+ : { discardedCommandIds: options.discarded.commandIds });
86
+ this.#submit = new TurnSubmitter(options.sessionId, options.connection, this.#pending);
87
+ this.#approvals = new ApprovalRouter(options.sessionId, options.connection);
88
+ this.#gaps = new GapFiller({
89
+ apply: (frame, into) => this.#foldAndRoute(frame, into),
90
+ connection: options.connection,
91
+ discarded: () => this.#discharge?.kind === "discharged",
92
+ fold: this.#fold,
93
+ sessionId: options.sessionId,
94
+ });
95
+ }
96
+ get durability() {
97
+ return this.#durability;
98
+ }
99
+ /** Read-only; events enter through `apply` so turn routing cannot be skipped. */
100
+ get fold() {
101
+ return this.#fold;
102
+ }
103
+ /** The SS4.13 set, minus the three mutators `Session` drives itself. */
104
+ get pending() {
105
+ return this.#pending;
106
+ }
107
+ /**
108
+ * How many turns this session has minted a handle for.
109
+ *
110
+ * Distinct from `fold.turns()`, which counts turns the WIRE named: a handle
111
+ * is also minted by `turn()` and by item routing, so this is the number that
112
+ * reveals a phantom turn minted from a bad key (a `userShell` item's null
113
+ * `turnId`, say). Read-only and observation-only.
114
+ *
115
+ * @internal Test observability, stripped from the published declarations
116
+ * (`stripInternal`). `Session` is barrel-exported, so without this the getter
117
+ * would be frozen API the moment #211 adopts the package — the same call
118
+ * `liveStreamCount` got by staying off the public `Turn` (Constitution XI).
119
+ */
120
+ get knownTurnCount() {
121
+ return this.#turns.size;
122
+ }
123
+ apply(event) {
124
+ // Only enforce when the frame actually NAMES a session. `ViewEvent`'s
125
+ // members all declare `sessionId`, but a newer host's unknown notification
126
+ // reaches this method at runtime and SS1.5.4 says it is tolerated
127
+ // losslessly — an unconditional read threw `MuseForeignSessionError`
128
+ // ("belongs to session undefined") on it, or a raw `TypeError` when the
129
+ // frame carried no `params` at all.
130
+ const named = event.params?.sessionId;
131
+ if (typeof named === "string" && named !== this.sessionId) {
132
+ throw new MuseForeignSessionError(`event ${event.method} belongs to session ${named}, not ${this.sessionId}`);
133
+ }
134
+ // AFTER the foreign check, deliberately. A frame naming a DIFFERENT session
135
+ // is an embedder routing bug, never this session's trailing drain frame, so
136
+ // the pump-safety rationale below does not cover it — refusing it here
137
+ // would relabel a routing bug as our own drain refusal and hide it exactly
138
+ // when a multiplexed embedder needs to see it.
139
+ //
140
+ // REFUSED, not thrown. A discarded session must still not fold — ItemStore's
141
+ // own guard covers only ITEM events, so a post-discharge `turn/completed`
142
+ // once settled a fresh turn "completed" on a session the client was told to
143
+ // discard. But throwing killed the consumer's pump on a single trailing
144
+ // drain frame, the exact failure this method already refuses for missing
145
+ // params and unknown methods. SS2.13.3b requires that nothing fold; it does
146
+ // not require an exception.
147
+ if (this.#discharge?.kind === "discharged") {
148
+ return { fold: { kind: "refusedSessionDiscarded" }, io: NO_IO, retirements: [] };
149
+ }
150
+ // An SS4.8 fill is in flight, so the live tail waits for the paged prefix
151
+ // (tdd SS4.8's "buffer live events … splice the buffer after the paged
152
+ // prefix"). Folding it now would run the fold backwards when the older
153
+ // paged events land, and would hand the iterators the tail before the
154
+ // hole they follow. The MARKER itself is exempt: a second overflow has to
155
+ // reach the fold to extend the outstanding hole, or the walk stops short
156
+ // of it and reports current with a hole still open.
157
+ if (this.#gaps.filling && event.method !== "view/gap") {
158
+ this.#gaps.hold(event);
159
+ return {
160
+ fold: { kind: "bufferedDuringGap", method: event.method },
161
+ io: NO_IO,
162
+ retirements: [],
163
+ };
164
+ }
165
+ // The overlap's late half: a frame a completed fill already applied from a
166
+ // page, whose live twin the wire delivered afterwards. Reached only when NO
167
+ // fill is running — during one the buffer takes the frame first, and the
168
+ // drain refuses it there against both its own paged cursors and the
169
+ // persistent set.
170
+ const twin = this.#gaps.claimServedTwin(event);
171
+ if (twin !== undefined) {
172
+ return {
173
+ fold: { kind: "ignoredGapOverlap", method: event.method, viewCursor: twin },
174
+ io: NO_IO,
175
+ retirements: [],
176
+ };
177
+ }
178
+ const sink = { retirements: [], tasks: [] };
179
+ const outcome = this.#foldAndRoute(event, sink);
180
+ // FR-020: recover before this fold may call itself current again. Started
181
+ // HERE rather than inside the routing switch because it is the one arm
182
+ // that authors client→server I/O off a notification, and `io` is the
183
+ // channel that carries it.
184
+ if (outcome.kind === "deliveryGap")
185
+ sink.tasks.push(this.#gaps.start());
186
+ return {
187
+ fold: outcome,
188
+ io: Session.#settleIo(sink.tasks),
189
+ retirements: sink.retirements,
190
+ };
191
+ }
192
+ /** Observe SS4.8 fills that did not complete. See `MuseGapFillError`. */
193
+ onGapError(handler) {
194
+ this.#gaps.onError(handler);
195
+ }
196
+ /**
197
+ * Fold one frame and fan it out to the turn handles that want it.
198
+ *
199
+ * The shared path: `apply` uses it for a live frame, and `GapFiller` uses it
200
+ * to re-feed the paged prefix and the buffered tail. One path is what makes
201
+ * a spliced frame indistinguishable from a live one to everything
202
+ * downstream — the fold, the iterators, and the SS4.13 retirements.
203
+ */
204
+ #foldAndRoute(event, sink) {
205
+ const retirements = [];
206
+ const tasks = sink.tasks;
207
+ const outcome = this.#fold.apply(event);
208
+ // The fold drops a frame with no `params` as `ignoredMissingParams`
209
+ // (SS1.5.4: drop, never throw). The switch below reads `event.params` on
210
+ // the turn and delta arms, so without this early return a single
211
+ // `{method:"turn/completed"}` frame throws a raw TypeError out of the ONLY
212
+ // event entry point and kills the consumer's notification pump.
213
+ if (outcome.kind === "ignoredMissingParams")
214
+ return outcome;
215
+ const known = event;
216
+ switch (known.method) {
217
+ case "item/started":
218
+ case "item/updated":
219
+ case "item/completed":
220
+ // A stale re-emission changed nothing (INV-003), so it yields nothing.
221
+ if (outcome.kind === "item" && outcome.outcome.kind !== "ignoredStaleRevision") {
222
+ this.#itemFolded(known.params.item, retirements);
223
+ }
224
+ break;
225
+ case "item/delta":
226
+ this.#deltaFolded(known.params);
227
+ break;
228
+ case "turn/started":
229
+ this.#handle(known.params.turnId).markStarted();
230
+ this.#queueMoved(known.params.turnId, tasks);
231
+ break;
232
+ case "turn/completed":
233
+ this.#handle(known.params.turnId).settleCompleted(known.params);
234
+ this.#queueMoved(known.params.turnId, tasks);
235
+ break;
236
+ case "turn/unqueued": {
237
+ this.#handle(known.params.turnId).settleUnqueued(known.params);
238
+ // SS4.13 "Reclaimed": the reclaim this client just observed retires
239
+ // the entry NOW, with its input restored to the composer. Leaving it
240
+ // in the set is the durable-looking echo SS4.13 forbids — and it would
241
+ // later be retired `terminalUnknown` by a discharge, which would be a
242
+ // fabricated annotation about a command whose fate is known.
243
+ const reclaimed = this.#pending.observedReclaim(known.params.commandId);
244
+ if (reclaimed !== undefined)
245
+ retirements.push(reclaimed);
246
+ break;
247
+ }
248
+ case "approval/requested":
249
+ // FR-019's outbound half. Gated on the fold's own verdict: a request
250
+ // the fold IGNORED (a redelivery for an approval already durably
251
+ // resolved) must author no decision — no second resolution is coming,
252
+ // so a decide against it can only bounce.
253
+ if (outcome.kind === "approvalPending")
254
+ this.#approvalRequested(known.params, tasks);
255
+ break;
256
+ case "approval/updated":
257
+ // FR-019's outbound half too (#36949): a stage advance is delivered by
258
+ // this frame alone on the notification plane, so it must drive the
259
+ // handler — the why and the request merge live on
260
+ // `ApprovalRouter.updated`. The same fold verdict gates it: an update
261
+ // the fold IGNORED (unknown or already-resolved approval) authors
262
+ // nothing, and the verdict itself carries the retained request the
263
+ // merge needs.
264
+ if (outcome.kind === "approvalPending") {
265
+ this.#approvalUpdated(outcome.requested, known.params, tasks);
266
+ }
267
+ break;
268
+ // Folded, deliberately NOT routed to a turn handle. Listed rather than
269
+ // defaulted so this switch is exhaustive over `ViewEvent`: the fold's
270
+ // `AssertNever` forces each new #206 view notification into that union,
271
+ // and a `default` arm here would swallow the addition silently.
272
+ //
273
+ // `view/gap` is here because it routes to no turn: it names a hole in
274
+ // DELIVERY, so what it moves is the fold's currency, and the recovery
275
+ // it triggers is started by `apply` off the `deliveryGap` verdict rather
276
+ // than from this switch (FR-020 / T032).
277
+ case "view/gap":
278
+ case "turn/retracted": // the turn still reaches its own terminal
279
+ case "turn/retryScheduled": // non-terminal by contract (SS4.5.1)
280
+ case "turn/foregroundCompleted": // non-terminal by contract (SS4.5.12)
281
+ case "approval/resolved":
282
+ case "userInput/requested":
283
+ case "userInput/settled":
284
+ case "session/modelChanged":
285
+ case "session/reasoningEffortChanged":
286
+ case "session/goalChanged":
287
+ case "session/todoListChanged":
288
+ case "session/branchChanged":
289
+ case "session/tokenUsage":
290
+ case "session/contextUsage":
291
+ case "session/approvalModeChanged":
292
+ case "session/modelRouteUnserved":
293
+ case "session/nameChanged":
294
+ break;
295
+ default: {
296
+ // Compile-time exhaustiveness. Runtime tolerance of a newer host's
297
+ // unknown method is unaffected — `SessionFold.apply` already returns
298
+ // `ignoredUnrecognizedMethod` for one (SS1.5.4).
299
+ const unrouted = known;
300
+ void unrouted;
301
+ break;
302
+ }
303
+ }
304
+ // The retired entries are gone from the pending set, so their replay
305
+ // memory goes with them — `TurnSubmitter.#memory`'s contract is "pruned on
306
+ // retirement, so it tracks the pending set". This covers the two
307
+ // retirements `apply` produces synchronously (the materialized userMessage
308
+ // and the `turn/unqueued` reclaim); the consumer-driven retirement verbs
309
+ // (`stopRetrying`, `replayAnswered`) prune through their own `Session`
310
+ // wrappers.
311
+ if (retirements.length > 0) {
312
+ this.#submit.forgetRetired(retirements);
313
+ sink.retirements.push(...retirements);
314
+ }
315
+ return outcome;
316
+ }
317
+ /** Flatten the event's I/O tasks, or hand back the shared empty. */
318
+ static #settleIo(tasks) {
319
+ if (tasks.length === 0)
320
+ return NO_IO;
321
+ return Promise.all(tasks).then((batches) => batches.flat());
322
+ }
323
+ /**
324
+ * The handle for a turn, created on first mention from either side. A turn
325
+ * the fold has already settled returns its settled handle, so a wait
326
+ * registered late resolves instead of hanging on an event that has passed.
327
+ */
328
+ turn(turnId) {
329
+ const held = this.#turns.get(turnId);
330
+ if (held !== undefined)
331
+ return held;
332
+ const fresh = this.#mint(turnId);
333
+ // A CONSUMER asking about a turn this session has no news of, after the
334
+ // host died, must not hang: no answer is coming on this connection. That
335
+ // is the SS3.1.4 trap SS2.13.3b's "stop waiting for a terminal" forbids.
336
+ // Server-minted handles deliberately skip this — see `#handle`.
337
+ if (this.#discharge?.kind === "discharged")
338
+ fresh.settleTerminalUnknown();
339
+ else if (this.#deathError !== undefined)
340
+ fresh.fail(this.#deathError);
341
+ return fresh;
342
+ }
343
+ /**
344
+ * A handle minted because a SERVER FRAME named this turn. Never pre-failed,
345
+ * and that is what makes the post-death behaviour ORDER-INDEPENDENT.
346
+ *
347
+ * A durable death does not stop the fold (FM-001), so frames can still
348
+ * arrive for a turn with no handle yet. Pre-failing here made the outcome
349
+ * depend on which frame arrived first: terminal-first settled, started-first
350
+ * pre-failed the handle and first-settlement-wins then dropped the real
351
+ * terminal one frame later — the same server fact, two different answers.
352
+ * That asymmetry is measured against synthetic frames; which order a real
353
+ * host emits after a crash is NOT something this lane has observed, and the
354
+ * rule does not depend on it. A frame arriving IS the connection still speaking,
355
+ * so a handle it mints is not a waiter with no answer coming; `turn()` keeps
356
+ * the latch for the case that genuinely is.
357
+ */
358
+ #handle(turnId) {
359
+ return this.#turns.get(turnId) ?? this.#mint(turnId);
360
+ }
361
+ #mint(turnId) {
362
+ const fresh = new TurnHandle(turnId, () => this.#itemsOfTurn(turnId));
363
+ this.#turns.set(turnId, fresh);
364
+ return fresh;
365
+ }
366
+ /**
367
+ * A host process exited. Classify it, and discharge SS2.13.3b if the profile
368
+ * demands it (tasks.md T030b, FM-002).
369
+ *
370
+ * REPORT EVERY NOTIFICATION OF THE DEATH — the exit promise AND transport
371
+ * EOF. A repeat report of an already-latched death is not a no-op: it marks
372
+ * END OF DRAIN and settles waits the drain minted after the first report.
373
+ * A consumer that dedupes exit notifications and reports the death once
374
+ * leaves those waits pending forever (FM-001).
375
+ *
376
+ * Three outcomes, and the middle one is the one an implementation drops:
377
+ * - an ORDERLY exit (SS2.11 row 0) is not a death: nothing is discharged
378
+ * and the session stays usable;
379
+ * - a DURABLE session's abnormal death reconciles on resume (FM-001), so
380
+ * the stores are left exactly as observed and only the waiters are told;
381
+ * - an EPHEMERAL — or unrecognized (SS2.13.1) — session's abnormal death
382
+ * discharges the obligation in full.
383
+ */
384
+ hostExited(exit) {
385
+ // A second notification of the SAME death (a client can learn of it from
386
+ // both the exit promise and a transport EOF) must report the same thing.
387
+ // The primitives are only side-effect-idempotent: `discardEphemeral`
388
+ // drains the set, so its RETURN is a one-shot delta and a client reading
389
+ // the later notification would silently lose the retired inputs.
390
+ if (this.#discharge !== undefined) {
391
+ // END-OF-DRAIN SWEEP. A durable death does not stop the fold (FM-001),
392
+ // so between the first notification and this one the drain can mint
393
+ // handles from `turn/started` frames whose terminals never arrive.
394
+ // Those are left pending by `#handle` on purpose — a delivered terminal
395
+ // must be able to win — but a SECOND notification means the drain is
396
+ // over and nothing more is coming, so anything still unsettled would
397
+ // hang forever: the SS3.1.4 trap INV-014 forbids.
398
+ //
399
+ // Safe precisely because `fail()`/`settleTerminalUnknown()` no-op on an
400
+ // already-settled handle: a terminal the drain DID deliver keeps its
401
+ // win, so this cannot undo the order-independence it sits beside.
402
+ this.#sweepUnsettledAfterDrain();
403
+ return this.#discharge;
404
+ }
405
+ const profile = this.#durability;
406
+ if (!isAbnormalHostDeath(exit))
407
+ return { exit, kind: "notADeath", profile };
408
+ if (survivesHostDeath(profile)) {
409
+ // FM-001: the stores are left exactly as observed — the terminals arrive
410
+ // on resume — so only the waiters are told.
411
+ const died = new MuseHostDiedError(exit);
412
+ this.#deathError = died;
413
+ for (const handle of this.#turns.values())
414
+ handle.fail(died);
415
+ this.#discharge = { exit, kind: "durableDeath", profile };
416
+ return this.#discharge;
417
+ }
418
+ const retiredCommands = this.#pending.discardEphemeral();
419
+ const terminalUnknownItems = this.#fold.markEphemeralHostDeath(isItemInProgress);
420
+ for (const handle of this.#turns.values())
421
+ handle.settleTerminalUnknown();
422
+ // SS2.13.3b "do not attempt to reattach". `discardEphemeral` already wrote
423
+ // the commandIds into the shared set (it holds it by reference); the
424
+ // sessionId is this layer's to record, because the pending set does not
425
+ // know one. `MuseClient.resumeSession` is the reader.
426
+ this.#discarded?.sessionIds.add(this.sessionId);
427
+ this.#submit.forgetRetired(retiredCommands);
428
+ this.#discharge = {
429
+ exit,
430
+ kind: "discharged",
431
+ profile,
432
+ retiredCommands,
433
+ terminalUnknownItems,
434
+ };
435
+ return this.#discharge;
436
+ }
437
+ /**
438
+ * Settle everything the drain left open. DURABLE ONLY, and `#deathError` is
439
+ * set iff a durable death latched, so the condition is the discriminator.
440
+ *
441
+ * There is deliberately no ephemeral arm: after a discharge the first
442
+ * notification settled every held handle, `apply()` refuses before a frame
443
+ * can mint one, and `turn()` settles a fresh mint on the spot — so no
444
+ * unsettled handle can exist for it to reach. An arm for that state would be
445
+ * untestable defense for something no producer reaches (Constitution XI) and
446
+ * a mutant that survives by construction.
447
+ */
448
+ #sweepUnsettledAfterDrain() {
449
+ if (this.#deathError === undefined)
450
+ return;
451
+ for (const handle of this.#turns.values())
452
+ handle.fail(this.#deathError);
453
+ }
454
+ // ---- FR-018 submit verb (T030 obligation a) -----------------------------
455
+ /**
456
+ * Submit a user turn (tdd SS3.2) and hand back THIS session's handle for the
457
+ * turn the ack named.
458
+ *
459
+ * The returned handle is the one `apply` already routes events to — not a
460
+ * second one minted beside it, which would compare equal on every field and
461
+ * then never receive an item, a delta, or a terminal.
462
+ *
463
+ * The wire shaping, the optimistic SS4.13 entry, and the settle-on-rejection
464
+ * rule all live in `TurnSubmitter`; what is decided HERE is the one thing that
465
+ * needs the fold: the entry's insertion point, which is the last item this
466
+ * session holds at submission time.
467
+ */
468
+ async sendUserTurn(options) {
469
+ const ack = await this.#submit.submit(options, this.#lastFoldedItemId());
470
+ return this.#handle(ack.turnId);
471
+ }
472
+ // ---- FR-019 approval round trip (T031) ----------------------------------
473
+ /** Answer approvals with `handler` (FR-019). See `ApprovalRouter`. */
474
+ onApproval(handler) {
475
+ this.#approvals.onApproval(handler);
476
+ }
477
+ /** Observe round trips that did not complete. See `ApprovalFailure`. */
478
+ onApprovalError(handler) {
479
+ this.#approvals.onApprovalError(handler);
480
+ }
481
+ // ---- consumer-driven SS4.13 retirement verbs ----------------------------
482
+ /**
483
+ * Stop the SS3.1.1 retry loop for one entry and take its input back
484
+ * (SS4.13 "Abandoned").
485
+ *
486
+ * A `Session` method rather than a `pending` view member: the retirement
487
+ * must also prune the submitter's replay memory, or the abandoned command's
488
+ * `turn/start` params live for the session's lifetime — unbounded over the
489
+ * sanctioned submit-then-abandon flow.
490
+ */
491
+ stopRetrying(commandId) {
492
+ const retired = this.#pending.stopRetrying(commandId);
493
+ if (retired !== undefined)
494
+ this.#submit.forgetRetired([retired]);
495
+ return retired;
496
+ }
497
+ /**
498
+ * Feed a consumer-driven replay's answer through the live SS4.13 settlement
499
+ * rules. Wrapped for the same replay-memory bookkeeping as `stopRetrying`;
500
+ * `"held"` means the answer settled nothing and the entry stays.
501
+ */
502
+ replayAnswered(commandId, answer) {
503
+ const settled = this.#pending.replayAnswered(commandId, answer);
504
+ if (settled !== "held")
505
+ this.#submit.forgetRetired([settled]);
506
+ return settled;
507
+ }
508
+ // ---- SS4.13 drivers that need client->server I/O (T030 obligation d) ----
509
+ /**
510
+ * Resolve a snapshot join by performing the I/O its plan demands (SS4.13,
511
+ * SS4.9): same-`commandId` resubmits FIRST, then the replays, then the
512
+ * reclaimed-signature verdict.
513
+ *
514
+ * The order is normative, not incidental: SS4.13 requires an unacked entry be
515
+ * resubmitted "before any retire-to-composer", because a join miss does not
516
+ * prove the intake was never written.
517
+ *
518
+ * `PendingCommandSet.joinSnapshot` stays available on `pending` for a
519
+ * fold-only consumer that performs its own I/O; this verb is for the wired
520
+ * case and therefore requires a connection even when the plan turns out empty.
521
+ */
522
+ async resolveSnapshotJoin(facts) {
523
+ this.#submit.requireConnection("resolveSnapshotJoin");
524
+ const plan = this.#pending.joinSnapshot(facts);
525
+ const out = [...plan.retirements];
526
+ for (const commandId of plan.mustResubmit)
527
+ await this.#submit.driveReplay(commandId, out);
528
+ const queuedCommandIds = facts.queuedTurns.map((queued) => queued.commandId);
529
+ for (const commandId of plan.mustReplay) {
530
+ const answer = await this.#submit.replay(commandId);
531
+ if (answer === undefined)
532
+ continue;
533
+ // The snapshot is what makes the reclaimed signature decidable, so this
534
+ // path uses `resolveReplayAtJoin` rather than the live `replayAnswered`.
535
+ const settled = this.#pending.resolveReplayAtJoin(commandId, answer, queuedCommandIds);
536
+ if (settled !== "held")
537
+ out.push(settled);
538
+ }
539
+ this.#submit.forgetRetired(out);
540
+ return out;
541
+ }
542
+ /**
543
+ * Resolve a reconnect with NO snapshot (`history.mode: "none"`, the default
544
+ * cursor-resume path). Reconnect itself is the trigger: replay each acked
545
+ * entry once, resubmit each unacked entry's SAME `commandId` once.
546
+ */
547
+ async resolveReconnect() {
548
+ this.#submit.requireConnection("resolveReconnect");
549
+ const plan = this.#pending.reconnectedWithoutSnapshot();
550
+ const out = [];
551
+ for (const commandId of plan.mustResubmit)
552
+ await this.#submit.driveReplay(commandId, out);
553
+ for (const commandId of plan.mustReplay)
554
+ await this.#submit.driveReplay(commandId, out);
555
+ this.#submit.forgetRetired(out);
556
+ return out;
557
+ }
558
+ // ---- event-driven I/O ---------------------------------------------------
559
+ #lastFoldedItemId() {
560
+ const items = this.#fold.items.list();
561
+ return items.length === 0 ? null : (items[items.length - 1]?.itemId ?? null);
562
+ }
563
+ /**
564
+ * SS4.13 "Queue movement": a `turn/started` or `turn/completed` for a turn
565
+ * that is not an entry's own decides that entry's fate at a launch boundary,
566
+ * and SS4.3 carries no view event for a command-intake settlement — so the
567
+ * only way to learn it is to replay.
568
+ */
569
+ #queueMoved(turnId, tasks) {
570
+ if (!this.#submit.wired)
571
+ return;
572
+ const commandIds = this.#pending.observedQueueMovement(turnId);
573
+ if (commandIds.length === 0)
574
+ return;
575
+ tasks.push(this.#driveQueueReplays(commandIds));
576
+ }
577
+ async #driveQueueReplays(commandIds) {
578
+ const out = [];
579
+ for (const commandId of commandIds) {
580
+ try {
581
+ await this.#submit.driveReplay(commandId, out);
582
+ }
583
+ catch {
584
+ // Swallowed on purpose, and narrowly: `replay` already converts a
585
+ // server-authored MSP error into an answer, so reaching here means the
586
+ // TRANSPORT failed. That proves nothing about the intake, so the entry
587
+ // holds — and the authoritative report is the host-death notification
588
+ // already on its way. Rethrowing would reject an `io` most consumers
589
+ // never await, i.e. crash the embedder for a fact it is about to be
590
+ // told properly.
591
+ }
592
+ }
593
+ this.#submit.forgetRetired(out);
594
+ return out;
595
+ }
596
+ #approvalRequested(params, tasks) {
597
+ const decided = this.#approvals.requested(params);
598
+ // An approval decision retires no pending command — it is not an SS4.13
599
+ // entry — but it still has to be awaitable through the same `io`, or a
600
+ // consumer has no barrier for the round trip it just triggered.
601
+ if (decided !== undefined)
602
+ tasks.push(decided.then(() => []));
603
+ }
604
+ /** The #36949 stage-advance re-route; the merge lives on `ApprovalRouter.updated`. */
605
+ #approvalUpdated(requested, params, tasks) {
606
+ const decided = this.#approvals.updated(requested, params);
607
+ // Awaitable through `io`, same as `#approvalRequested` above.
608
+ if (decided !== undefined)
609
+ tasks.push(decided.then(() => []));
610
+ }
611
+ #itemsOfTurn(turnId) {
612
+ return this.#fold.items.list().filter((item) => item.turnId === turnId);
613
+ }
614
+ #itemFolded(item, retirements) {
615
+ // SS4.13 "Materialized": the `commandId`-bearing `userMessage` landed, so
616
+ // the optimistic entry is replaced by the real item at the item's own
617
+ // position. `itemId` is the ITEM's id — item ids and command ids are
618
+ // separate namespaces on the wire.
619
+ if (item.kind === USER_MESSAGE && item.commandId !== undefined) {
620
+ const materialized = this.#pending.observedUserMessage(item.commandId, item.itemId);
621
+ if (materialized !== undefined)
622
+ retirements.push(materialized);
623
+ }
624
+ // `!= null`, not `!== undefined`: `userShell` items carry `turnId: null`
625
+ // on the wire (spec Edge Cases; tdd SS4.5.6 example). A `!== undefined`
626
+ // guard let null through and minted a phantom turn keyed `null`,
627
+ // attributing the userShell item and its deltas to it (#23557).
628
+ const turnId = item.turnId;
629
+ if (turnId != null)
630
+ this.#handle(turnId).pushItem(item);
631
+ const buffered = this.#unattributedDeltas.get(item.itemId);
632
+ if (buffered === undefined)
633
+ return;
634
+ this.#unattributedDeltas.delete(item.itemId);
635
+ if (turnId == null)
636
+ return; // `userShell` is not turn-scoped
637
+ const handle = this.#handle(turnId);
638
+ for (const params of buffered)
639
+ handle.pushDelta(params);
640
+ }
641
+ #deltaFolded(params) {
642
+ const held = this.#fold.items.get(params.itemId);
643
+ if (held === undefined) {
644
+ const buffered = this.#unattributedDeltas.get(params.itemId);
645
+ if (buffered === undefined)
646
+ this.#unattributedDeltas.set(params.itemId, [params]);
647
+ else
648
+ buffered.push(params);
649
+ return;
650
+ }
651
+ if (held.turnId != null)
652
+ this.#handle(held.turnId).pushDelta(params);
653
+ }
654
+ }