@openclaw/gateway-client 2026.9.6 → 2026.9.8

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.
@@ -1,13 +1,12 @@
1
1
  import { DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS, clearGatewayConnectTimeout, resolveSafeTimeoutDelayMs, startGatewayConnectTimeout } from "./timeouts.mjs";
2
2
  import { ConnectErrorDetailCodes, readConnectErrorDetailCode, readConnectErrorRecoveryAdvice, readPairingConnectErrorDetails } from "@openclaw/gateway-protocol/connect-error-details";
3
3
  import { isGatewayEventFrame, isGatewayResponseFrame } from "@openclaw/gateway-protocol/frame-guards";
4
+ import { GATEWAY_SUSPEND_IDENTITY_RETRY_AFTER_MS, isGatewaySuspendUnavailableError } from "@openclaw/gateway-protocol/restart-unavailable";
4
5
  import { GATEWAY_ASSISTANT_ERROR_FALLBACK_TEXT } from "@openclaw/gateway-protocol/gateway-error-details";
5
6
  //#region packages/gateway-client/src/device-auth.ts
6
7
  function normalizeDeviceMetadataForAuth(value) {
7
8
  if (typeof value !== "string") return "";
8
- const trimmed = value.trim();
9
- if (!trimmed) return "";
10
- return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32));
9
+ return value.trim().replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32));
11
10
  }
12
11
  function buildDeviceAuthPayload(params) {
13
12
  const scopes = params.scopes.join(",");
@@ -44,6 +43,63 @@ function buildDeviceAuthPayloadV3(params) {
44
43
  ].join("|");
45
44
  }
46
45
  //#endregion
46
+ //#region packages/normalization-core/src/record-coerce.ts
47
+ /** Type guard for non-array object records at browser-safe boundaries. */
48
+ function isRecord(value) {
49
+ return value !== null && typeof value === "object" && !Array.isArray(value);
50
+ }
51
+ /** Coerces object-like values to records, falling back to an empty record. */
52
+ function asRecord(value) {
53
+ return asOptionalObjectRecord(value) ?? {};
54
+ }
55
+ /** Returns a non-array record or null. */
56
+ function asNullableRecord(value) {
57
+ return isRecord(value) ? value : null;
58
+ }
59
+ /** Returns any object-backed record, including arrays, or undefined. */
60
+ function asOptionalObjectRecord(value) {
61
+ return value && typeof value === "object" ? value : void 0;
62
+ }
63
+ //#endregion
64
+ //#region packages/gateway-client/src/chat-stream-message.ts
65
+ /** A snapshot includes its delta; append-only frames require an existing baseline. */
66
+ function mergeChatStreamMessage(previous, payload) {
67
+ if (payload.message !== void 0) return payload.message;
68
+ const delta = payload.deltaText;
69
+ if (typeof delta !== "string") return previous;
70
+ const replace = payload.replace === true;
71
+ if (!delta && !replace) return previous;
72
+ if (!isRecord(previous) && !replace) return;
73
+ const message = isRecord(previous) ? previous : { role: "assistant" };
74
+ if (typeof message.content === "string") return {
75
+ ...message,
76
+ content: replace ? delta : message.content + delta
77
+ };
78
+ const content = Array.isArray(message.content) ? [...message.content] : [];
79
+ const isText = (block) => isRecord(block) && block.type === "text";
80
+ if (replace) return {
81
+ ...message,
82
+ content: [{
83
+ type: "text",
84
+ text: delta
85
+ }, ...content.filter((block) => !isText(block))]
86
+ };
87
+ const index = content.findLastIndex(isText);
88
+ const block = content[index];
89
+ if (isText(block)) content[index] = {
90
+ ...block,
91
+ text: `${typeof block.text === "string" ? block.text : ""}${delta}`
92
+ };
93
+ else content.push({
94
+ type: "text",
95
+ text: delta
96
+ });
97
+ return {
98
+ ...message,
99
+ content
100
+ };
101
+ }
102
+ //#endregion
47
103
  //#region packages/gateway-client/src/connect-auth.ts
48
104
  function normalized(value) {
49
105
  return typeof value === "string" ? value.trim() || void 0 : void 0;
@@ -114,21 +170,19 @@ var GatewayBrowserDeviceAuthLifecycle = class {
114
170
  deviceId: identity.deviceId,
115
171
  role: params.role
116
172
  }) : null;
117
- const storedValue = stored?.token;
118
173
  const selectedAuth = selectGatewayConnectAuth({
119
174
  token: params.token,
120
175
  bootstrapToken: params.bootstrapToken,
121
176
  password: params.password,
122
- storedToken: storedValue,
177
+ storedToken: stored?.token,
123
178
  storedScopes: stored?.scopes,
124
179
  pendingDeviceTokenRetry: params.pendingDeviceTokenRetry,
125
180
  trustedDeviceTokenRetry: params.trustedDeviceTokenRetry,
126
181
  preferBootstrapToken: params.preferBootstrapToken
127
182
  });
128
- const { usingStoredDeviceToken } = selectedAuth;
129
183
  const scopes = resolveGatewayConnectScopes({
130
184
  requestedScopes: selectedAuth.authBootstrapToken ? params.bootstrapScopes ? [...params.bootstrapScopes] : void 0 : void 0,
131
- usingStoredDeviceToken,
185
+ usingStoredDeviceToken: selectedAuth.usingStoredDeviceToken,
132
186
  storedScopes: selectedAuth.storedScopes,
133
187
  defaultScopes: params.defaultScopes
134
188
  });
@@ -143,10 +197,6 @@ var GatewayBrowserDeviceAuthLifecycle = class {
143
197
  const signedAtMs = params.challengeTs === void 0 ? this.deps.nowMs?.() ?? Date.now() : params.challengeTs;
144
198
  if (typeof signedAtMs !== "number" || !Number.isSafeInteger(signedAtMs) || signedAtMs < 0) throw new Error("gateway connect challenge timestamp invalid");
145
199
  const nonce = params.nonce ?? "";
146
- const { authBootstrapToken: primary, signatureToken: signed } = selectedAuth;
147
- let token = null;
148
- if (primary) token = primary;
149
- else if (signed) token = signed;
150
200
  const payload = buildDeviceAuthPayloadV3({
151
201
  deviceId: identity.deviceId,
152
202
  clientId: params.client.id,
@@ -154,7 +204,7 @@ var GatewayBrowserDeviceAuthLifecycle = class {
154
204
  role: params.role,
155
205
  scopes,
156
206
  signedAtMs,
157
- token,
207
+ token: selectedAuth.authBootstrapToken || selectedAuth.signatureToken || null,
158
208
  nonce,
159
209
  platform: params.client.platform,
160
210
  deviceFamily: params.client.deviceFamily
@@ -245,9 +295,12 @@ function computeBackoff(policy, attempt) {
245
295
  const jitter = base * policy.jitter * Math.random();
246
296
  return Math.min(policy.maxMs, Math.round(base + jitter));
247
297
  }
298
+ function resolveSleepDelayMs(ms) {
299
+ return Number.isFinite(ms) && ms > 0 ? Math.min(Math.max(Math.floor(ms), 1), MAX_TIMER_TIMEOUT_MS) : 0;
300
+ }
248
301
  async function sleepWithAbort(ms, abortSignal, options = {}) {
249
- if (!Number.isFinite(ms) || ms <= 0) return;
250
- const delayMs = Math.min(Math.max(Math.floor(ms), 1), MAX_TIMER_TIMEOUT_MS);
302
+ const delayMs = resolveSleepDelayMs(ms);
303
+ if (delayMs === 0) return;
251
304
  await new Promise((resolve, reject) => {
252
305
  let settled = false;
253
306
  let timer = null;
@@ -330,21 +383,18 @@ const defaultSleep = async (ms) => {
330
383
  } while (remainingMs > 0);
331
384
  };
332
385
  function clampNumber(value, fallback, min, max) {
333
- const next = Number.isFinite(value) ? value : void 0;
334
- if (next === void 0) return fallback;
335
- return Math.min(Math.max(next, min ?? Number.NEGATIVE_INFINITY), max ?? Number.POSITIVE_INFINITY);
386
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
387
+ return Math.min(Math.max(value, min ?? Number.NEGATIVE_INFINITY), max ?? Number.POSITIVE_INFINITY);
336
388
  }
337
389
  function resolveAttemptCount(value, fallback) {
338
- return Math.max(1, Math.round(Number.isFinite(value) ? value : fallback));
390
+ return Math.max(1, Math.round(clampNumber(value, fallback)));
339
391
  }
340
392
  function resolveRetryDelayMs(value) {
341
- const finite = value === Number.POSITIVE_INFINITY ? MAX_TIMER_TIMEOUT_MS : Number.isFinite(value) ? value : 0;
342
- return Math.min(Math.max(Math.round(finite), 0), MAX_TIMER_TIMEOUT_MS);
393
+ return Math.min(Math.max(Math.round(value === Number.POSITIVE_INFINITY ? MAX_TIMER_TIMEOUT_MS : Number.isFinite(value) ? value : 0), 0), MAX_TIMER_TIMEOUT_MS);
343
394
  }
344
395
  function resolveJitterConfig(value, fallback) {
345
396
  if (value === "full") return "full";
346
- const fraction = Number.isFinite(value) ? value : void 0;
347
- return fraction === void 0 ? fallback : Math.min(Math.max(fraction, 0), 1);
397
+ return typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : fallback;
348
398
  }
349
399
  function resolveRetryConfig(defaults = DEFAULT_RETRY_CONFIG, overrides) {
350
400
  const attempts = resolveAttemptCount(overrides?.attempts, defaults.attempts);
@@ -499,6 +549,20 @@ var GatewayPendingRequests = class {
499
549
  this.pending = /* @__PURE__ */ new Map();
500
550
  this.requestSequence = 0;
501
551
  }
552
+ setSuspensionPhase(phase) {
553
+ if (phase === "accepting") this.resumeIdentityRequests();
554
+ else if (phase === "preparing" || phase === "draining" || phase === "prepared") this.pauseIdentityRequests();
555
+ }
556
+ pauseIdentityRequests(retryAfterMs = GATEWAY_SUSPEND_IDENTITY_RETRY_AFTER_MS) {
557
+ clearTimeout(this.suspensionTimer);
558
+ this.suspensionTimer = setTimeout(() => this.resumeIdentityRequests(), resolveSafeTimeoutDelayMs(Number.isFinite(retryAfterMs) && retryAfterMs > 0 ? retryAfterMs : GATEWAY_SUSPEND_IDENTITY_RETRY_AFTER_MS));
559
+ this.suspensionTimer.unref?.();
560
+ }
561
+ resumeIdentityRequests() {
562
+ clearTimeout(this.suspensionTimer);
563
+ this.suspensionTimer = void 0;
564
+ for (const pending of this.pending.values()) if (pending.waitingForResume) pending.resend?.();
565
+ }
502
566
  get hasPending() {
503
567
  return this.pending.size > 0;
504
568
  }
@@ -562,15 +626,27 @@ var GatewayPendingRequests = class {
562
626
  options?.signal?.addEventListener("abort", onAbort, { once: true });
563
627
  this.pending.set(id, pending);
564
628
  try {
565
- sender.send(JSON.stringify({
629
+ const frame = JSON.stringify({
566
630
  type: "req",
567
631
  id,
568
632
  method,
569
633
  params
570
- }));
571
- if (this.pending.get(id) !== pending) return;
572
- requestSent = true;
573
- this.invoke("sent", () => options?.onSent?.(id));
634
+ });
635
+ const send = () => {
636
+ if (this.pending.get(id) !== pending) return;
637
+ pending.waitingForResume = Boolean(pending.resend && this.suspensionTimer);
638
+ if (pending.waitingForResume) return;
639
+ try {
640
+ sender.send(frame);
641
+ if (this.pending.get(id) !== pending) return;
642
+ requestSent = true;
643
+ this.invoke("sent", () => options?.onSent?.(id));
644
+ } catch (error) {
645
+ if (retire("CLIENT_SEND_ERROR")) reject(error instanceof Error ? error : new Error(String(error)));
646
+ }
647
+ };
648
+ if (method === "agent.identity.get") pending.resend = send;
649
+ send();
574
650
  } catch (error) {
575
651
  if (retire("CLIENT_SEND_ERROR")) reject(error instanceof Error ? error : new Error(String(error)));
576
652
  }
@@ -587,6 +663,11 @@ var GatewayPendingRequests = class {
587
663
  }
588
664
  return;
589
665
  }
666
+ if (!frame.ok && pending.resend && !pending.acceptedNotified && frame.error?.code === "UNAVAILABLE" && frame.error.retryable === true && isGatewaySuspendUnavailableError(frame.error)) {
667
+ this.pauseIdentityRequests(frame.error.retryAfterMs);
668
+ pending.waitingForResume = true;
669
+ return;
670
+ }
590
671
  this.pending.delete(frame.id);
591
672
  pending.cleanup?.();
592
673
  if (frame.ok) {
@@ -600,6 +681,8 @@ var GatewayPendingRequests = class {
600
681
  pending.reject(error);
601
682
  }
602
683
  flush(error) {
684
+ clearTimeout(this.suspensionTimer);
685
+ this.suspensionTimer = void 0;
603
686
  const retired = this.pending;
604
687
  this.pending = /* @__PURE__ */ new Map();
605
688
  this.requestSequence = 0;
@@ -615,19 +698,15 @@ var GatewayPendingRequests = class {
615
698
  }
616
699
  finishTiming(id, pending, ok, errorCode) {
617
700
  const endedAtMs = this.opts.nowMs();
618
- try {
619
- this.opts.onTiming?.({
620
- id,
621
- method: pending.method,
622
- ok,
623
- durationMs: Math.max(0, endedAtMs - pending.startedAtMs),
624
- startedAtMs: pending.startedAtMs,
625
- endedAtMs,
626
- errorCode
627
- });
628
- } catch (error) {
629
- this.opts.onCallbackError?.("request timing", error);
630
- }
701
+ this.invoke("request timing", () => this.opts.onTiming?.({
702
+ id,
703
+ method: pending.method,
704
+ ok,
705
+ durationMs: Math.max(0, endedAtMs - pending.startedAtMs),
706
+ startedAtMs: pending.startedAtMs,
707
+ endedAtMs,
708
+ errorCode
709
+ }));
631
710
  }
632
711
  invoke(label, callback) {
633
712
  try {
@@ -872,6 +951,7 @@ var GatewayProtocolClient = class {
872
951
  this.request("connect", this.opts.buildConnectParams(plan)).then((hello) => {
873
952
  if (!this.isConnectCurrent(socket, generation)) return;
874
953
  this.helloReceived = true;
954
+ this.requests.setSuspensionPhase(hello.snapshot?.suspension?.phase);
875
955
  this.clearHandshakeTimer();
876
956
  this.connectFailure = void 0;
877
957
  this.reconnectSupervisor.reset();
@@ -910,7 +990,7 @@ var GatewayProtocolClient = class {
910
990
  });
911
991
  }
912
992
  handleMessage(socket, generation, raw) {
913
- if (!this.isActive(socket, generation)) return;
993
+ if (!this.isActive(socket, generation) || this.connectionAbort?.signal.aborted) return;
914
994
  let parsed;
915
995
  try {
916
996
  parsed = JSON.parse(raw);
@@ -943,26 +1023,34 @@ var GatewayProtocolClient = class {
943
1023
  if (seq !== null) {
944
1024
  if (this.lastSeq !== null && seq > this.lastSeq + 1) {
945
1025
  const expected = this.lastSeq + 1;
1026
+ const state = asRecord(parsed.payload).state;
1027
+ if (parsed.event === "chat" && (state === "final" || state === "error" || state === "aborted")) this.dispatchEvent(socket, generation, parsed);
1028
+ if (!this.isActive(socket, generation) || this.connectionAbort?.signal.aborted) return;
946
1029
  this.invoke("gap", () => this.opts.onGap?.({
947
1030
  expected,
948
1031
  received: seq
949
1032
  }));
950
- if (!this.isActive(socket, generation)) return;
1033
+ if (this.isActive(socket, generation) && !this.connectionAbort?.signal.aborted) this.closeSocket(4e3, "event sequence gap");
1034
+ return;
951
1035
  }
952
1036
  this.lastSeq = seq;
953
1037
  }
954
- const listeners = this.listeners.snapshot();
955
- this.invoke("event", () => this.opts.onEvent?.(parsed));
956
- for (const [listener, subscription] of listeners) {
957
- if (!this.isActive(socket, generation)) return;
958
- if (this.listeners.isCurrent(listener, subscription)) this.invoke("event listener", () => listener(parsed));
959
- }
1038
+ this.dispatchEvent(socket, generation, parsed);
960
1039
  return;
961
1040
  }
962
1041
  if (!isGatewayResponseFrame(parsed)) return;
963
1042
  this.opts.onActivity?.();
964
1043
  this.requests.handleResponse(parsed);
965
1044
  }
1045
+ dispatchEvent(socket, generation, event) {
1046
+ const listeners = this.listeners.snapshot();
1047
+ if (event.event === "gateway.suspension" && typeof event.payload === "object" && event.payload !== null && "phase" in event.payload) this.requests.setSuspensionPhase(event.payload.phase);
1048
+ this.invoke("event", () => this.opts.onEvent?.(event));
1049
+ for (const [listener, subscription] of listeners) {
1050
+ if (!this.isActive(socket, generation) || this.connectionAbort?.signal.aborted) return;
1051
+ if (this.listeners.isCurrent(listener, subscription)) this.invoke("event listener", () => listener(event));
1052
+ }
1053
+ }
966
1054
  handleClose(socket, generation, code, reason) {
967
1055
  if (this.socket !== socket) {
968
1056
  if (this.stoppedSocket?.socket === socket) {
@@ -1008,13 +1096,23 @@ var GatewayProtocolClient = class {
1008
1096
  const retry = this.reconnectSupervisor.next();
1009
1097
  if (!retry) return;
1010
1098
  this.reconnectSignal = retry.signal;
1011
- sleepWithAbort(overrideMs ?? Math.max(retry.delayMs, minimumMs), retry.signal).then(() => {
1099
+ let delayMs = overrideMs;
1100
+ if (delayMs === void 0) {
1101
+ const base = Math.max(retry.delayMs, minimumMs);
1102
+ const ceiling = Math.max(this.opts.reconnect.maxMs, Math.min(Number.MAX_VALUE, minimumMs * 1.2));
1103
+ const lower = Math.max(minimumMs, Math.min(base, ceiling / 1.2));
1104
+ const upper = Math.min(base * 1.2, ceiling);
1105
+ delayMs = Math.ceil(lower + Math.random() * (upper - lower));
1106
+ }
1107
+ delayMs = resolveSleepDelayMs(delayMs);
1108
+ sleepWithAbort(delayMs, retry.signal).then(() => {
1012
1109
  if (this.reconnectSignal !== retry.signal) return;
1013
1110
  this.reconnectSignal = null;
1014
1111
  this.invoke("reconnect", () => this.connect());
1015
1112
  }, () => {
1016
1113
  if (this.reconnectSignal === retry.signal) this.reconnectSignal = null;
1017
1114
  });
1115
+ this.invoke("reconnect scheduled", () => this.opts.onReconnectScheduled?.(delayMs, retry.signal));
1018
1116
  }
1019
1117
  closeContext() {
1020
1118
  return {
@@ -1083,16 +1181,6 @@ function shouldPauseGatewayReconnect(params) {
1083
1181
  return NON_RECOVERABLE_AUTH_ERRORS.has(code) || params.protocolMismatchIsTerminal === true && code === ConnectErrorDetailCodes.PROTOCOL_MISMATCH || params.clientVersionMismatchIsTerminal === true && code === ConnectErrorDetailCodes.CLIENT_VERSION_MISMATCH;
1084
1182
  }
1085
1183
  //#endregion
1086
- //#region packages/normalization-core/src/record-coerce.ts
1087
- /** Type guard for non-array object records at browser-safe boundaries. */
1088
- function isRecord(value) {
1089
- return value !== null && typeof value === "object" && !Array.isArray(value);
1090
- }
1091
- /** Returns a non-array record or null. */
1092
- function asNullableRecord(value) {
1093
- return isRecord(value) ? value : null;
1094
- }
1095
- //#endregion
1096
1184
  //#region packages/normalization-core/src/stable-stringify.ts
1097
1185
  const preserveString = (value) => value;
1098
1186
  /** Deterministically stringifies values, optionally normalizing strings before key ordering. */
@@ -1346,12 +1434,7 @@ function isSessionProjectionErrorMessage(message, errorMessage) {
1346
1434
  //#endregion
1347
1435
  //#region packages/gateway-client/src/session-projection-final-identity.ts
1348
1436
  /** Terminal identity rules used to reconcile live and durable assistant projections. */
1349
- /**
1350
- * The class of rows the #148297 relaxation newly admits as finals: persisted
1351
- * with the run's terminal tool stop reason while carrying no tool-call
1352
- * content. Both adoption directions and every position rule scope to this
1353
- * predicate, so pre-existing match semantics stay untouched.
1354
- */
1437
+ /** A run's selected text reply can inherit its terminal tool stop reason. */
1355
1438
  function isToolUsePersistedFinalRow(message) {
1356
1439
  return asNullableRecord(message)?.["stopReason"] === "toolUse" && !isSessionProjectionToolContinuation(message);
1357
1440
  }
@@ -1448,22 +1531,15 @@ function findUniqueSnapshotTerminalMatch(current, matches, run, snapshot) {
1448
1531
  });
1449
1532
  snapshotIndexes = indexes;
1450
1533
  };
1451
- const hasCompletedRunSnapshotContext = (entry) => {
1452
- const runId = current.identity?.runId;
1453
- if (!runId || entry.identity?.runId !== runId) return false;
1454
- ensureRunIndexes();
1455
- const entryIndex = snapshotIndexes?.get(entry);
1456
- return entryIndex !== void 0 && firstUserIndex >= 0 && firstUserIndex < entryIndex && lastAssistantIndex <= entryIndex;
1457
- };
1458
- const isLastSameRunAssistantRow = (entry) => {
1534
+ const hasTerminalSnapshotPosition = (entry, requireUser) => {
1459
1535
  const runId = current.identity?.runId;
1460
1536
  if (!runId || entry.identity?.runId !== runId) return false;
1461
1537
  ensureRunIndexes();
1462
1538
  const entryIndex = snapshotIndexes?.get(entry);
1463
- return entryIndex !== void 0 && lastAssistantIndex >= 0 && entryIndex >= lastAssistantIndex;
1539
+ return entryIndex !== void 0 && lastAssistantIndex <= entryIndex && (requireUser ? firstUserIndex >= 0 && firstUserIndex < entryIndex : lastAssistantIndex >= 0);
1464
1540
  };
1465
1541
  const durableTerminalMatches = matches.filter((entry) => {
1466
- return (asNullableRecord(asNullableRecord(entry.message)?.["__openclaw"])?.runTerminal === true || entry.identity?.runId === current.identity?.runId && (hasTerminalStopReason(entry.message) || asNullableRecord(entry.message)?.["stopReason"] === "toolUse" && !isSessionProjectionToolContinuation(entry.message) && isLastSameRunAssistantRow(entry)) || hasCompletedRunSnapshotContext(entry)) && readFinalContentIdentity(entry.message) === terminalContent;
1542
+ return (asNullableRecord(asNullableRecord(entry.message)?.["__openclaw"])?.runTerminal === true || entry.identity?.runId === current.identity?.runId && (hasTerminalStopReason(entry.message) || isToolUsePersistedFinalRow(entry.message) && hasTerminalSnapshotPosition(entry, false)) || hasTerminalSnapshotPosition(entry, true)) && readFinalContentIdentity(entry.message) === terminalContent;
1467
1543
  });
1468
1544
  const entry = durableTerminalMatches.length === 1 ? durableTerminalMatches[0] : void 0;
1469
1545
  if (!entry) return null;
@@ -1493,22 +1569,19 @@ function retainSessionProjectionRuns(runs) {
1493
1569
  }
1494
1570
  //#endregion
1495
1571
  //#region packages/gateway-client/src/session-projection-run-event.ts
1496
- function readNonemptyString(value) {
1497
- return typeof value === "string" ? value.trim() || null : null;
1498
- }
1499
1572
  /** Normalizes Gateway run envelopes once for every browser and terminal adapter. */
1500
1573
  function reduceSessionProjectionRunEvent(projection, event, scope = {}) {
1501
- const runId = readNonemptyString(event.runId);
1574
+ const runId = readSessionProjectionString(event.runId);
1502
1575
  if (!runId || typeof event.state !== "string" || ![
1503
1576
  "delta",
1504
1577
  "final",
1505
1578
  "error",
1506
1579
  "aborted"
1507
1580
  ].includes(event.state)) return null;
1508
- const message = event.message;
1509
- const messageStopReason = isRecord(message) ? readNonemptyString(message.stopReason) : null;
1510
- const stopReason = readNonemptyString(event.stopReason) ?? messageStopReason;
1511
- const errorKind = readNonemptyString(event.errorKind);
1581
+ const message = event.state === "delta" ? mergeChatStreamMessage(projection.runs[runId]?.message, event) : event.message;
1582
+ const messageStopReason = isRecord(message) ? readSessionProjectionString(message.stopReason) : null;
1583
+ const stopReason = readSessionProjectionString(event.stopReason) ?? messageStopReason;
1584
+ const errorKind = readSessionProjectionString(event.errorKind);
1512
1585
  const base = {
1513
1586
  runId,
1514
1587
  seq: typeof event.seq === "number" ? event.seq : void 0,
@@ -1861,16 +1934,67 @@ function reduceSessionProjection(state, event) {
1861
1934
  }
1862
1935
  }
1863
1936
  //#endregion
1864
- //#region packages/gateway-client/src/session-subscriptions.ts
1865
- function sessionSubscriptionParams(key, agentId) {
1937
+ //#region packages/normalization-core/src/result.ts
1938
+ /** Create a successful {@link Result}. */
1939
+ function ok(value) {
1866
1940
  return {
1867
- key: key.trim(),
1868
- ...agentId ? { agentId } : {}
1941
+ ok: true,
1942
+ value
1869
1943
  };
1870
1944
  }
1945
+ /** Create a failed {@link Result}. */
1946
+ function err(error) {
1947
+ return {
1948
+ ok: false,
1949
+ error
1950
+ };
1951
+ }
1952
+ //#endregion
1953
+ //#region packages/normalization-core/src/agent-id.ts
1954
+ const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
1955
+ const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
1956
+ const LEADING_DASH_RE = /^-+/;
1957
+ const TRAILING_DASH_RE = /-+$/;
1958
+ /** Normalizes an explicitly supplied agent id without falling back to the default agent. */
1959
+ function normalizeAgentIdStrict(value) {
1960
+ const trimmed = (value ?? "").trim();
1961
+ const normalized = trimmed.toLowerCase();
1962
+ if (VALID_ID_RE.test(trimmed)) return ok(normalized);
1963
+ const agentId = normalized.replace(INVALID_CHARS_RE, "-").replace(LEADING_DASH_RE, "").replace(TRAILING_DASH_RE, "").slice(0, 64);
1964
+ return agentId ? ok(agentId) : err("unrepresentable");
1965
+ }
1966
+ //#endregion
1967
+ //#region packages/normalization-core/src/uuid.ts
1968
+ let warnedWeakCrypto = false;
1969
+ function warnWeakCryptoOnce() {
1970
+ if (warnedWeakCrypto) return;
1971
+ warnedWeakCrypto = true;
1972
+ console.warn("[uuid] crypto API missing; refusing insecure UUID generation");
1973
+ }
1974
+ function generateUUID(cryptoLike = globalThis.crypto) {
1975
+ if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID();
1976
+ if (cryptoLike && typeof cryptoLike.getRandomValues === "function") {
1977
+ const bytes = /* @__PURE__ */ new Uint8Array(16);
1978
+ cryptoLike.getRandomValues(bytes);
1979
+ const view = new DataView(bytes.buffer);
1980
+ view.setUint8(6, view.getUint8(6) & 15 | 64);
1981
+ view.setUint8(8, view.getUint8(8) & 63 | 128);
1982
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1983
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
1984
+ }
1985
+ warnWeakCryptoOnce();
1986
+ throw new Error("Web Crypto is required for UUID generation");
1987
+ }
1988
+ //#endregion
1989
+ //#region packages/gateway-client/src/session-subscriptions.ts
1990
+ function normalizedAgentScope(agentId) {
1991
+ if (!agentId) return null;
1992
+ const normalized = normalizeAgentIdStrict(agentId);
1993
+ return normalized.ok ? normalized.value : agentId;
1994
+ }
1871
1995
  /**
1872
- * One Gateway connection owns one targeted observer per canonical session.
1873
- * Approval delivery is an upgrade of that observer, never a second observer.
1996
+ * Shared leases retain one wire observer ID. The Gateway combines independently
1997
+ * addressed observers; approval delivery upgrades the same owner.
1874
1998
  */
1875
1999
  var GatewaySessionMessageSubscriptionCoordinator = class {
1876
2000
  #client;
@@ -1892,17 +2016,24 @@ var GatewaySessionMessageSubscriptionCoordinator = class {
1892
2016
  const normalizedKey = key.trim();
1893
2017
  if (!normalizedKey) throw new Error("Session message subscription requires a session key");
1894
2018
  const agentId = options.agentId?.trim() || null;
2019
+ const scopeAgentId = normalizedAgentScope(agentId);
2020
+ const narration = options.mode === "narration";
2021
+ const includeApprovals = options.includeApprovals === true;
1895
2022
  let entry;
2023
+ let created = false;
1896
2024
  while (true) {
1897
2025
  if (this.#retired) throw new Error("Session message subscription belongs to a replaced Gateway connection");
1898
- const existing = [...this.#entries].find((candidate) => candidate.agentId === agentId && (this.#areKeysEquivalent(candidate.key, normalizedKey) || [...candidate.requestedKeys].some((requestedKey) => this.#areKeysEquivalent(requestedKey, normalizedKey))));
2026
+ const existing = [...this.#entries].find((candidate) => {
2027
+ return candidate.scopeAgentId === scopeAgentId && ([...candidate.requestedKeys].some((requestedKey) => this.#areKeysEquivalent(requestedKey, normalizedKey)) || candidate.key !== "global" && this.#areKeysEquivalent(candidate.key, normalizedKey));
2028
+ });
1899
2029
  if (!existing) {
1900
- const provisional = [...this.#entries].find((candidate) => candidate.agentId === agentId && !candidate.canonicalSettled && this.#couldShareCanonicalIdentity(candidate.key, normalizedKey));
2030
+ const provisional = [...this.#entries].find((candidate) => candidate.scopeAgentId === scopeAgentId && !candidate.canonicalSettled && this.#couldShareCanonicalIdentity(candidate.key, normalizedKey));
1901
2031
  if (provisional) {
1902
2032
  await (provisional.plainFallback ?? provisional.ready).catch(() => void 0);
1903
2033
  continue;
1904
2034
  }
1905
- entry = this.#createEntry(normalizedKey, agentId, options.includeApprovals === true);
2035
+ entry = this.#createEntry(normalizedKey, agentId);
2036
+ created = true;
1906
2037
  break;
1907
2038
  }
1908
2039
  if (!existing.release) {
@@ -1913,13 +2044,31 @@ var GatewaySessionMessageSubscriptionCoordinator = class {
1913
2044
  await existing.release.catch(() => void 0);
1914
2045
  }
1915
2046
  entry.pendingOwners += 1;
2047
+ if (!narration) entry.pendingFullOwners += 1;
1916
2048
  try {
1917
- const result = await this.#acquireCapability(entry, options.includeApprovals === true);
2049
+ if (created) {
2050
+ entry.ready = this.#requestSubscribe(entry, includeApprovals);
2051
+ if (includeApprovals) entry.ready = this.#trackApprovalRequest(entry, entry.ready);
2052
+ entry.ready.catch(() => void 0);
2053
+ }
2054
+ if (entry.refreshRequired) {
2055
+ entry.refreshRequired = false;
2056
+ entry.plainFallback = null;
2057
+ entry.approvalRequest = null;
2058
+ const retainedApprovals = [...entry.handles].some((handle) => handle.includeApprovals);
2059
+ entry.ready = this.#requestSubscribe(entry, retainedApprovals).catch((error) => {
2060
+ entry.refreshRequired = true;
2061
+ throw error;
2062
+ });
2063
+ }
2064
+ const result = await this.#acquireCapability(entry, includeApprovals);
2065
+ if (!narration && entry.mode === "narration") await this.#requestSubscribe(entry, false, "full");
1918
2066
  if (this.#retired) throw new Error("Session message subscription completed on a replaced Gateway connection");
1919
2067
  const subscription = {
1920
2068
  key: result.key,
1921
2069
  agentId,
1922
- ...options.includeApprovals === true ? {
2070
+ ...narration ? { mode: "narration" } : {},
2071
+ ...includeApprovals ? {
1923
2072
  includeApprovals: true,
1924
2073
  ...result.approvalReplay !== void 0 ? { approvalReplay: result.approvalReplay } : {}
1925
2074
  } : {}
@@ -1932,6 +2081,7 @@ var GatewaySessionMessageSubscriptionCoordinator = class {
1932
2081
  return subscription;
1933
2082
  } finally {
1934
2083
  entry.pendingOwners -= 1;
2084
+ if (!narration) entry.pendingFullOwners -= 1;
1935
2085
  if (entry.pendingOwners === 0 && entry.handles.size === 0 && !entry.release) this.#entries.delete(entry);
1936
2086
  }
1937
2087
  }
@@ -1939,26 +2089,34 @@ var GatewaySessionMessageSubscriptionCoordinator = class {
1939
2089
  const owner = sessionMessageSubscriptionOwners.get(subscription);
1940
2090
  if (!owner || owner.coordinator !== this) return Promise.resolve();
1941
2091
  const { entry } = owner;
1942
- if (this.#retired || entry.handles.size > 1) {
2092
+ if (entry.release) return entry.releasing === subscription ? entry.release : entry.release.catch(() => void 0).then(() => this.release(subscription));
2093
+ const releasesLastFullOwner = subscription.mode !== "narration" && [...entry.handles].every((handle) => handle === subscription || handle.mode === "narration");
2094
+ if (this.#retired || entry.handles.size > 1 && !releasesLastFullOwner) {
1943
2095
  this.#finishRelease(subscription, owner);
1944
2096
  return Promise.resolve();
1945
2097
  }
1946
- if (entry.release) return entry.release;
1947
2098
  if (entry.pendingOwners > 0) {
1948
- const pending = [entry.ready, ...entry.approvalRequest ? [entry.approvalRequest] : []];
1949
- const tracked = Promise.allSettled(pending).then(() => {
2099
+ const tracked = Promise.allSettled([
2100
+ entry.ready,
2101
+ entry.approvalRequest,
2102
+ entry.wireRequest
2103
+ ]).then(() => {
1950
2104
  if (entry.release === tracked) entry.release = null;
1951
2105
  return this.release(subscription);
1952
2106
  });
1953
2107
  entry.release = tracked;
2108
+ entry.releasing = subscription;
1954
2109
  return tracked;
1955
2110
  }
1956
- const tracked = this.#client.request("sessions.messages.unsubscribe", sessionSubscriptionParams(entry.key, entry.agentId), { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS }).then(() => {
1957
- this.#finishRelease(subscription, owner, true);
1958
- }).finally(() => {
2111
+ const removeEntry = entry.handles.size === 1;
2112
+ const tracked = (removeEntry ? this.#requestMessages(entry).catch((error) => {
2113
+ if (error instanceof GatewayProtocolRequestTimeoutError && error.requestSent) entry.refreshRequired = true;
2114
+ throw error;
2115
+ }) : this.#requestSubscribe(entry, false, "narration")).then(() => this.#finishRelease(subscription, owner, removeEntry)).finally(() => {
1959
2116
  if (entry.release === tracked) entry.release = null;
1960
2117
  });
1961
2118
  entry.release = tracked;
2119
+ entry.releasing = subscription;
1962
2120
  return tracked;
1963
2121
  }
1964
2122
  /** A reconnect retires leases without touching the next connection's observers. */
@@ -1970,22 +2128,26 @@ var GatewaySessionMessageSubscriptionCoordinator = class {
1970
2128
  }
1971
2129
  this.#entries.clear();
1972
2130
  }
1973
- #createEntry(key, agentId, includeApprovals) {
2131
+ #createEntry(key, agentId) {
1974
2132
  const entry = {
2133
+ subscriptionId: generateUUID(),
1975
2134
  key,
1976
2135
  requestedKeys: /* @__PURE__ */ new Set([key]),
1977
2136
  agentId,
2137
+ scopeAgentId: normalizedAgentScope(agentId),
2138
+ ownerAgentId: null,
1978
2139
  ready: Promise.resolve({ key }),
1979
2140
  approvalRequest: null,
1980
2141
  plainFallback: null,
2142
+ wireRequest: null,
2143
+ includeApprovals: false,
1981
2144
  canonicalSettled: false,
2145
+ refreshRequired: false,
1982
2146
  handles: /* @__PURE__ */ new Set(),
1983
2147
  pendingOwners: 0,
2148
+ pendingFullOwners: 0,
1984
2149
  release: null
1985
2150
  };
1986
- entry.ready = this.#requestSubscribe(entry, includeApprovals);
1987
- if (includeApprovals) entry.ready = this.#trackApprovalRequest(entry, entry.ready);
1988
- entry.ready.catch(() => void 0);
1989
2151
  this.#entries.add(entry);
1990
2152
  return entry;
1991
2153
  }
@@ -2016,19 +2178,43 @@ var GatewaySessionMessageSubscriptionCoordinator = class {
2016
2178
  entry.approvalRequest = tracked;
2017
2179
  return tracked;
2018
2180
  }
2019
- async #requestSubscribe(entry, includeApprovals) {
2020
- const params = sessionSubscriptionParams(entry.key, entry.agentId);
2021
- const result = await this.#client.request("sessions.messages.subscribe", includeApprovals ? {
2022
- ...params,
2023
- includeApprovals: true
2024
- } : params, { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS }).catch(async (error) => {
2181
+ #requestSubscribe(entry, includeApprovals, mode) {
2182
+ const send = () => this.#sendSubscribe(entry, includeApprovals, mode);
2183
+ const tracked = (entry.wireRequest ? entry.wireRequest.then(send, send) : send()).finally(() => {
2184
+ if (entry.wireRequest === tracked) entry.wireRequest = null;
2185
+ });
2186
+ entry.wireRequest = tracked;
2187
+ return tracked;
2188
+ }
2189
+ #requestMessages(entry, subscription) {
2190
+ const agentId = entry.key === "global" ? entry.ownerAgentId ?? entry.agentId : entry.agentId;
2191
+ return this.#client.request(subscription ? "sessions.messages.subscribe" : "sessions.messages.unsubscribe", {
2192
+ subscriptionId: entry.subscriptionId,
2193
+ key: entry.key,
2194
+ ...agentId ? { agentId } : {},
2195
+ ...subscription?.mode ? { mode: subscription.mode } : {},
2196
+ ...subscription?.includeApprovals ? { includeApprovals: true } : {}
2197
+ }, { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS });
2198
+ }
2199
+ async #sendSubscribe(entry, requestedApprovals, requestedMode) {
2200
+ if (this.#retired) throw new Error("Session message subscription belongs to a replaced Gateway connection");
2201
+ if (requestedMode === "full" && entry.mode !== "narration") return { key: entry.key };
2202
+ const includeApprovals = requestedApprovals || requestedMode !== void 0 && entry.includeApprovals;
2203
+ const mode = requestedMode === "narration" || requestedMode !== "full" && entry.pendingFullOwners === 0 && [...entry.handles].every((handle) => handle.mode === "narration") ? "narration" : void 0;
2204
+ const result = await this.#requestMessages(entry, {
2205
+ mode,
2206
+ includeApprovals
2207
+ }).catch(async (error) => {
2025
2208
  if (!(error instanceof GatewayProtocolRequestTimeoutError) || !error.requestSent || this.#retired) throw error;
2026
2209
  try {
2027
2210
  const retainedApprovals = [...entry.handles].some((handle) => handle.includeApprovals);
2028
- await this.#client.request(entry.handles.size > 0 ? "sessions.messages.subscribe" : "sessions.messages.unsubscribe", retainedApprovals ? {
2029
- ...params,
2030
- includeApprovals: true
2031
- } : params, { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS });
2211
+ const retainedMode = entry.handles.size > 0 && [...entry.handles].every((handle) => handle.mode === "narration") ? "narration" : void 0;
2212
+ await this.#requestMessages(entry, entry.handles.size > 0 ? {
2213
+ mode: retainedMode,
2214
+ includeApprovals: retainedApprovals
2215
+ } : void 0);
2216
+ entry.mode = retainedMode;
2217
+ entry.includeApprovals = retainedApprovals;
2032
2218
  } catch (recoveryError) {
2033
2219
  if (!this.#retired) throw new AggregateError([error, recoveryError], "session message subscription recovery failed", { cause: recoveryError });
2034
2220
  }
@@ -2038,6 +2224,10 @@ var GatewaySessionMessageSubscriptionCoordinator = class {
2038
2224
  const responseKey = response && "key" in response ? response.key : void 0;
2039
2225
  entry.key = typeof responseKey === "string" && responseKey.trim() ? responseKey.trim() : entry.key;
2040
2226
  entry.canonicalSettled = true;
2227
+ const responseAgentId = response && "agentId" in response ? response.agentId : void 0;
2228
+ entry.ownerAgentId = normalizedAgentScope((typeof responseAgentId === "string" ? responseAgentId : null) ?? entry.ownerAgentId ?? entry.scopeAgentId);
2229
+ entry.mode = mode;
2230
+ entry.includeApprovals = includeApprovals;
2041
2231
  return {
2042
2232
  key: entry.key,
2043
2233
  ...response && "approvalReplay" in response ? { approvalReplay: response.approvalReplay } : {}
@@ -2075,4 +2265,4 @@ function releaseGatewaySessionMessageSubscription(subscription) {
2075
2265
  return sessionMessageSubscriptionOwners.get(subscription)?.coordinator.release(subscription) ?? Promise.resolve();
2076
2266
  }
2077
2267
  //#endregion
2078
- export { resolveGatewayConnectScopes as A, GatewayProtocolRequestTimeoutError as C, gatewayOriginScope as D, gatewayCredentialScope as E, normalizeDeviceMetadataForAuth as F, shouldRetryGatewayWithDeviceToken as M, buildDeviceAuthPayload as N, GatewayBrowserDeviceAuthLifecycle as O, buildDeviceAuthPayloadV3 as P, GatewayProtocolRequestError as S, resolveModelCatalogConnect as T, readSessionMessageIdentity as _, createSessionProjection as a, shouldPauseGatewayReconnect as b, reduceSessionProjection as c, readSessionProjectionFinalMessageIdentity as d, isSessionProjectionErrorMessage as f, readAssistantStreamSegmentIdentity as g, normalizeSessionProjectionRunId as h, resetGatewaySessionMessageSubscriptionCoordinator as i, selectGatewayConnectAuth as j, buildGatewayConnectAuth as k, reduceSessionProjectionRunEvent as l, isLocallyOptimisticSessionMessage as m, getGatewaySessionMessageSubscriptionCoordinator as n, projectLiveSessionMessage as o, projectSessionTerminalReplyMessage as p, releaseGatewaySessionMessageSubscription as r, reconcileSessionProjectionSnapshot as s, GatewaySessionMessageSubscriptionCoordinator as t, hasSessionProjectionAcceptedFinal as u, readSessionMessageSequence as v, isGatewayProtocolResponseError as w, GatewayProtocolClient as x, isRecord as y };
2268
+ export { selectGatewayConnectAuth as A, isGatewayProtocolResponseError as C, GatewayBrowserDeviceAuthLifecycle as D, gatewayOriginScope as E, isRecord as F, buildDeviceAuthPayload as I, buildDeviceAuthPayloadV3 as L, mergeChatStreamMessage as M, asNullableRecord as N, buildGatewayConnectAuth as O, asRecord as P, normalizeDeviceMetadataForAuth as R, GatewayProtocolRequestTimeoutError as S, gatewayCredentialScope as T, readSessionMessageIdentity as _, createSessionProjection as a, GatewayProtocolClient as b, reduceSessionProjection as c, readSessionProjectionFinalMessageIdentity as d, isSessionProjectionErrorMessage as f, readAssistantStreamSegmentIdentity as g, normalizeSessionProjectionRunId as h, resetGatewaySessionMessageSubscriptionCoordinator as i, shouldRetryGatewayWithDeviceToken as j, resolveGatewayConnectScopes as k, reduceSessionProjectionRunEvent as l, isLocallyOptimisticSessionMessage as m, getGatewaySessionMessageSubscriptionCoordinator as n, projectLiveSessionMessage as o, projectSessionTerminalReplyMessage as p, releaseGatewaySessionMessageSubscription as r, reconcileSessionProjectionSnapshot as s, GatewaySessionMessageSubscriptionCoordinator as t, hasSessionProjectionAcceptedFinal as u, readSessionMessageSequence as v, resolveModelCatalogConnect as w, GatewayProtocolRequestError as x, shouldPauseGatewayReconnect as y };