@openclaw/gateway-client 2026.8.1-beta.1 → 2026.8.1-beta.3

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,4 +1,4 @@
1
- import { clearGatewayConnectTimeout, startGatewayConnectTimeout } from "./timeouts.mjs";
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
4
  //#region packages/gateway-client/src/device-auth.ts
@@ -59,7 +59,7 @@ function selectGatewayConnectAuth(params) {
59
59
  };
60
60
  if (params.preferBootstrapToken && bootstrapToken) return {
61
61
  authBootstrapToken: bootstrapToken,
62
- authPassword,
62
+ signatureToken: bootstrapToken,
63
63
  ...stored
64
64
  };
65
65
  const useRetryToken = params.pendingDeviceTokenRetry === true && !explicitDeviceToken && Boolean(authToken && storedToken && params.trustedDeviceTokenRetry);
@@ -177,12 +177,18 @@ var GatewayBrowserDeviceAuthLifecycle = class {
177
177
  async acceptHello(hello, plan) {
178
178
  const token = hello.auth?.deviceToken?.trim();
179
179
  if (!token || !plan.identity) return;
180
+ const role = hello.auth?.role ?? plan.role;
181
+ const stored = await this.deps.tokenStore.load({
182
+ clientId: plan.clientId,
183
+ deviceId: plan.identity.deviceId,
184
+ role
185
+ });
180
186
  await this.deps.tokenStore.store({
181
187
  clientId: plan.clientId,
182
188
  deviceId: plan.identity.deviceId,
183
- role: hello.auth?.role ?? plan.role,
189
+ role,
184
190
  token,
185
- scopes: hello.auth?.scopes ?? []
191
+ scopes: stored?.token === token ? stored.scopes : hello.auth?.scopes ?? []
186
192
  });
187
193
  }
188
194
  async clearStoredToken(plan) {
@@ -195,6 +201,29 @@ var GatewayBrowserDeviceAuthLifecycle = class {
195
201
  }
196
202
  };
197
203
  //#endregion
204
+ //#region packages/gateway-client/src/gateway-origin-scope.ts
205
+ function normalizeGatewayScope(gatewayUrl, includeSearch) {
206
+ const trimmed = gatewayUrl.trim();
207
+ if (!trimmed) return "default";
208
+ try {
209
+ const browserLocation = globalThis.location;
210
+ const base = browserLocation ? `${browserLocation.protocol}//${browserLocation.host}${browserLocation.pathname || "/"}` : void 0;
211
+ const parsed = base ? new URL(trimmed, base) : new URL(trimmed);
212
+ const pathname = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, "") || parsed.pathname;
213
+ return `${parsed.protocol}//${parsed.host}${pathname}${includeSearch ? parsed.search : ""}`;
214
+ } catch {
215
+ return trimmed;
216
+ }
217
+ }
218
+ /** Normalizes the gateway URL scope used for origin-bound device tokens. */
219
+ function gatewayOriginScope(gatewayUrl) {
220
+ return normalizeGatewayScope(gatewayUrl, false);
221
+ }
222
+ /** Normalizes the gateway URL scope used for browser credential records. */
223
+ function gatewayCredentialScope(gatewayUrl) {
224
+ return normalizeGatewayScope(gatewayUrl, true);
225
+ }
226
+ //#endregion
198
227
  //#region packages/retry/src/index.ts
199
228
  const MAX_TIMER_TIMEOUT_MS = 2147e6;
200
229
  function computeBackoff(policy, attempt) {
@@ -215,7 +244,9 @@ async function sleepWithAbort(ms, abortSignal, options = {}) {
215
244
  if (timer) clearTimeout(timer);
216
245
  timer = null;
217
246
  cleanup();
218
- reject(new Error("aborted", { cause: abortSignal?.reason ?? /* @__PURE__ */ new Error("aborted") }));
247
+ const error = new Error("aborted", { cause: abortSignal?.reason ?? /* @__PURE__ */ new Error("aborted") });
248
+ error.name = "AbortError";
249
+ reject(error);
219
250
  };
220
251
  abortSignal?.addEventListener("abort", onAbort, { once: true });
221
252
  if (abortSignal?.aborted) {
@@ -277,24 +308,21 @@ const DEFAULT_RETRY_CONFIG = {
277
308
  const defaultSleep = (ms) => new Promise((resolve) => {
278
309
  setTimeout(resolve, ms);
279
310
  });
280
- function asFiniteNumber(value) {
281
- return typeof value === "number" && Number.isFinite(value) ? value : void 0;
282
- }
283
311
  function clampNumber(value, fallback, min, max) {
284
- const next = asFiniteNumber(value);
312
+ const next = Number.isFinite(value) ? value : void 0;
285
313
  if (next === void 0) return fallback;
286
314
  return Math.min(Math.max(next, min ?? Number.NEGATIVE_INFINITY), max ?? Number.POSITIVE_INFINITY);
287
315
  }
288
316
  function resolveAttemptCount(value, fallback) {
289
- return Math.max(1, Math.round(asFiniteNumber(value) ?? fallback));
317
+ return Math.max(1, Math.round(Number.isFinite(value) ? value : fallback));
290
318
  }
291
319
  function resolveRetryDelayMs(value) {
292
- const finite = value === Number.POSITIVE_INFINITY ? MAX_TIMER_TIMEOUT_MS : asFiniteNumber(value) ?? 0;
320
+ const finite = value === Number.POSITIVE_INFINITY ? MAX_TIMER_TIMEOUT_MS : Number.isFinite(value) ? value : 0;
293
321
  return Math.min(Math.max(Math.round(finite), 0), MAX_TIMER_TIMEOUT_MS);
294
322
  }
295
323
  function resolveJitterConfig(value, fallback) {
296
324
  if (value === "full") return "full";
297
- const fraction = asFiniteNumber(value);
325
+ const fraction = Number.isFinite(value) ? value : void 0;
298
326
  return fraction === void 0 ? fallback : Math.min(Math.max(fraction, 0), 1);
299
327
  }
300
328
  function resolveRetryConfig(defaults = DEFAULT_RETRY_CONFIG, overrides) {
@@ -416,6 +444,162 @@ var GatewayProtocolRequestError = class extends Error {
416
444
  this.retryAfterMs = error.retryAfterMs;
417
445
  }
418
446
  };
447
+ /** A local transport deadline, distinct from a Gateway's authoritative rejection. */
448
+ var GatewayProtocolRequestTimeoutError = class extends Error {
449
+ constructor(params, message = `gateway request timed out after ${params.timeoutMs}ms: ${params.method}`) {
450
+ super(message);
451
+ this.code = "CLIENT_TIMEOUT";
452
+ this.name = "GatewayProtocolRequestTimeoutError";
453
+ this.method = params.method;
454
+ this.timeoutMs = params.timeoutMs;
455
+ this.requestSent = params.requestSent;
456
+ }
457
+ };
458
+ //#endregion
459
+ //#region packages/gateway-client/src/pending-request.ts
460
+ /** Owns request deadlines, correlation, settlement, and generation-scoped IDs. */
461
+ var GatewayPendingRequests = class {
462
+ constructor(opts) {
463
+ this.opts = opts;
464
+ this.pending = /* @__PURE__ */ new Map();
465
+ this.requestSequence = 0;
466
+ }
467
+ get hasPending() {
468
+ return this.pending.size > 0;
469
+ }
470
+ get hasUnboundedPending() {
471
+ return [...this.pending.values()].some((pending) => pending.unbounded);
472
+ }
473
+ request(sender, method, params, options) {
474
+ let id;
475
+ try {
476
+ id = this.allocateRequestId();
477
+ } catch (error) {
478
+ return Promise.reject(error instanceof Error ? error : new Error(String(error)));
479
+ }
480
+ const requestedTimeoutMs = options?.timeoutMs === null ? void 0 : options?.timeoutMs ?? this.opts.requestTimeoutMs;
481
+ const timeoutMs = typeof requestedTimeoutMs === "number" && Number.isFinite(requestedTimeoutMs) ? resolveSafeTimeoutDelayMs(requestedTimeoutMs, { minMs: 0 }) : void 0;
482
+ return new Promise((resolve, reject) => {
483
+ let timeout;
484
+ let requestSent = false;
485
+ const pending = {
486
+ resolve: (value) => resolve(value),
487
+ reject,
488
+ expectFinal: options?.expectFinal === true,
489
+ acceptedNotified: false,
490
+ onAccepted: options?.onAccepted,
491
+ unbounded: timeoutMs === void 0,
492
+ method,
493
+ startedAtMs: this.opts.nowMs()
494
+ };
495
+ const cleanup = () => {
496
+ if (timeout !== void 0) clearTimeout(timeout);
497
+ options?.signal?.removeEventListener("abort", onAbort);
498
+ };
499
+ const retire = (errorCode) => {
500
+ if (this.pending.get(id) !== pending) return false;
501
+ this.pending.delete(id);
502
+ cleanup();
503
+ this.finishTiming(id, pending, false, errorCode);
504
+ return true;
505
+ };
506
+ const onAbort = () => {
507
+ if (!retire("CLIENT_ABORTED")) return;
508
+ reject(this.opts.createRequestAbortError?.(method) ?? /* @__PURE__ */ new Error(`gateway request aborted for ${method}`));
509
+ };
510
+ if (options?.signal?.aborted) {
511
+ reject(this.opts.createRequestAbortError?.(method) ?? /* @__PURE__ */ new Error(`gateway request aborted for ${method}`));
512
+ return;
513
+ }
514
+ pending.cleanup = cleanup;
515
+ if (timeoutMs !== void 0) {
516
+ timeout = setTimeout(() => {
517
+ if (!retire("CLIENT_TIMEOUT")) return;
518
+ reject(this.opts.createRequestTimeoutError?.(method, timeoutMs, requestSent) ?? new GatewayProtocolRequestTimeoutError({
519
+ method,
520
+ timeoutMs,
521
+ requestSent
522
+ }));
523
+ }, timeoutMs);
524
+ timeout.unref?.();
525
+ }
526
+ options?.signal?.addEventListener("abort", onAbort, { once: true });
527
+ this.pending.set(id, pending);
528
+ try {
529
+ sender.send(JSON.stringify({
530
+ type: "req",
531
+ id,
532
+ method,
533
+ params
534
+ }));
535
+ if (this.pending.get(id) !== pending) return;
536
+ requestSent = true;
537
+ this.invoke("sent", () => options?.onSent?.());
538
+ } catch (error) {
539
+ if (retire("CLIENT_SEND_ERROR")) reject(error instanceof Error ? error : new Error(String(error)));
540
+ }
541
+ });
542
+ }
543
+ handleResponse(frame) {
544
+ const pending = this.pending.get(frame.id);
545
+ if (!pending) return;
546
+ const status = frame.payload?.status;
547
+ if (pending.expectFinal && status === "accepted") {
548
+ if (!pending.acceptedNotified) {
549
+ pending.acceptedNotified = true;
550
+ this.invoke("accepted", () => pending.onAccepted?.(frame.payload));
551
+ }
552
+ return;
553
+ }
554
+ this.pending.delete(frame.id);
555
+ pending.cleanup?.();
556
+ if (frame.ok) {
557
+ this.finishTiming(frame.id, pending, true);
558
+ pending.resolve(frame.payload);
559
+ return;
560
+ }
561
+ this.finishTiming(frame.id, pending, false, frame.error?.code);
562
+ pending.reject(this.opts.createRequestError?.(frame.error ?? {}) ?? new GatewayProtocolRequestError(frame.error ?? {}));
563
+ }
564
+ flush(error) {
565
+ for (const [id, pending] of this.pending) {
566
+ this.finishTiming(id, pending, false, "CLIENT_CLOSED");
567
+ pending.cleanup?.();
568
+ pending.reject(error);
569
+ }
570
+ this.pending.clear();
571
+ this.requestSequence = 0;
572
+ }
573
+ allocateRequestId() {
574
+ this.requestSequence += 1;
575
+ return `${this.requestSequence}:${this.opts.createRequestId()}`;
576
+ }
577
+ finishTiming(id, pending, ok, errorCode) {
578
+ const endedAtMs = this.opts.nowMs();
579
+ try {
580
+ const onTiming = this.opts.onTiming;
581
+ if (onTiming === void 0 || onTiming === null) return;
582
+ Reflect.apply(onTiming, this.opts, [{
583
+ id,
584
+ method: pending.method,
585
+ ok,
586
+ durationMs: Math.max(0, endedAtMs - pending.startedAtMs),
587
+ startedAtMs: pending.startedAtMs,
588
+ endedAtMs,
589
+ errorCode
590
+ }]);
591
+ } catch (error) {
592
+ this.opts.onCallbackError?.("request timing", error);
593
+ }
594
+ }
595
+ invoke(label, callback) {
596
+ try {
597
+ callback();
598
+ } catch (error) {
599
+ this.opts.onCallbackError?.(label, error);
600
+ }
601
+ }
602
+ };
419
603
  //#endregion
420
604
  //#region packages/gateway-client/src/protocol-client.ts
421
605
  /**
@@ -426,7 +610,6 @@ var GatewayProtocolClient = class {
426
610
  constructor(opts) {
427
611
  this.opts = opts;
428
612
  this.socket = null;
429
- this.pending = /* @__PURE__ */ new Map();
430
613
  this.listeners = new GatewayEventListeners();
431
614
  this.stopped = true;
432
615
  this.generation = 0;
@@ -445,18 +628,28 @@ var GatewayProtocolClient = class {
445
628
  factor: opts.reconnect.multiplier,
446
629
  jitter: 0
447
630
  });
631
+ this.requests = new GatewayPendingRequests({
632
+ createRequestId: opts.createRequestId,
633
+ createRequestError: opts.createRequestError,
634
+ createRequestTimeoutError: opts.createRequestTimeoutError,
635
+ createRequestAbortError: opts.createRequestAbortError,
636
+ requestTimeoutMs: opts.requestTimeoutMs,
637
+ nowMs: () => this.nowMs(),
638
+ onTiming: opts.onRequestTiming,
639
+ onCallbackError: opts.onCallbackError
640
+ });
448
641
  }
449
642
  get connected() {
450
643
  return this.socket?.isOpen() ?? false;
451
644
  }
452
645
  get hasPendingRequests() {
453
- return this.pending.size > 0;
646
+ return this.requests.hasPending;
454
647
  }
455
648
  get connecting() {
456
649
  return this.connectSent && !this.helloReceived;
457
650
  }
458
651
  get hasUnboundedPendingRequests() {
459
- return [...this.pending.values()].some((pending) => pending.unbounded);
652
+ return this.requests.hasUnboundedPending;
460
653
  }
461
654
  start() {
462
655
  if (this.socket || this.reconnectSignal) return;
@@ -477,71 +670,14 @@ var GatewayProtocolClient = class {
477
670
  this.socket = null;
478
671
  this.connectFailure = void 0;
479
672
  this.connectTiming = null;
480
- this.flushRequests(/* @__PURE__ */ new Error("gateway client stopped"));
673
+ this.requests.flush(/* @__PURE__ */ new Error("gateway client stopped"));
481
674
  socket?.close();
482
675
  }
483
676
  request(method, params, options) {
484
677
  const socket = this.socket;
485
678
  if (!socket?.isOpen()) return Promise.reject(/* @__PURE__ */ new Error("gateway not connected"));
486
679
  if (typeof method !== "string" || method.length === 0) return Promise.reject(/* @__PURE__ */ new Error("invalid request frame: method must be a non-empty string"));
487
- const id = this.opts.createRequestId();
488
- const timeoutMs = options?.timeoutMs === null ? void 0 : options?.timeoutMs ?? this.opts.requestTimeoutMs;
489
- return new Promise((resolve, reject) => {
490
- let timeout;
491
- let requestSent = false;
492
- const pending = {
493
- resolve: (value) => resolve(value),
494
- reject,
495
- expectFinal: options?.expectFinal === true,
496
- acceptedNotified: false,
497
- onAccepted: options?.onAccepted,
498
- unbounded: timeoutMs === void 0,
499
- method,
500
- startedAtMs: this.nowMs()
501
- };
502
- const onAbort = () => {
503
- this.pending.delete(id);
504
- pending.cleanup?.();
505
- this.finishRequestTiming(id, pending, false, "CLIENT_ABORTED");
506
- reject(this.opts.createRequestAbortError?.(method) ?? /* @__PURE__ */ new Error(`gateway request aborted for ${method}`));
507
- };
508
- const cleanup = () => {
509
- if (timeout) clearTimeout(timeout);
510
- options?.signal?.removeEventListener("abort", onAbort);
511
- };
512
- if (options?.signal?.aborted) {
513
- reject(this.opts.createRequestAbortError?.(method) ?? /* @__PURE__ */ new Error(`gateway request aborted for ${method}`));
514
- return;
515
- }
516
- pending.cleanup = cleanup;
517
- if (timeoutMs !== void 0 && timeoutMs >= 0) {
518
- timeout = setTimeout(() => {
519
- if (this.pending.get(id) !== pending) return;
520
- this.pending.delete(id);
521
- options?.signal?.removeEventListener("abort", onAbort);
522
- this.finishRequestTiming(id, pending, false, "CLIENT_TIMEOUT");
523
- reject(this.opts.createRequestTimeoutError?.(method, timeoutMs, requestSent) ?? /* @__PURE__ */ new Error(`gateway request timed out after ${timeoutMs}ms: ${method}`));
524
- }, timeoutMs);
525
- timeout.unref?.();
526
- }
527
- options?.signal?.addEventListener("abort", onAbort, { once: true });
528
- this.pending.set(id, pending);
529
- try {
530
- socket.send(JSON.stringify({
531
- type: "req",
532
- id,
533
- method,
534
- params
535
- }));
536
- requestSent = true;
537
- this.invoke("sent", () => options?.onSent?.());
538
- } catch (error) {
539
- this.pending.delete(id);
540
- cleanup();
541
- this.finishRequestTiming(id, pending, false, "CLIENT_SEND_ERROR");
542
- reject(error instanceof Error ? error : new Error(String(error)));
543
- }
544
- });
680
+ return this.requests.request(socket, method, params, options);
545
681
  }
546
682
  addEventListener(listener) {
547
683
  return this.listeners.add(listener);
@@ -758,28 +894,7 @@ var GatewayProtocolClient = class {
758
894
  }
759
895
  if (!isGatewayResponseFrame(parsed)) return;
760
896
  this.opts.onActivity?.();
761
- this.handleResponse(parsed);
762
- }
763
- handleResponse(frame) {
764
- const pending = this.pending.get(frame.id);
765
- if (!pending) return;
766
- const status = frame.payload?.status;
767
- if (pending.expectFinal && status === "accepted") {
768
- if (!pending.acceptedNotified) {
769
- pending.acceptedNotified = true;
770
- this.invoke("accepted", () => pending.onAccepted?.(frame.payload));
771
- }
772
- return;
773
- }
774
- this.pending.delete(frame.id);
775
- pending.cleanup?.();
776
- if (frame.ok) {
777
- this.finishRequestTiming(frame.id, pending, true);
778
- pending.resolve(frame.payload);
779
- return;
780
- }
781
- this.finishRequestTiming(frame.id, pending, false, frame.error?.code);
782
- pending.reject(this.opts.createRequestError?.(frame.error ?? {}) ?? new GatewayProtocolRequestError(frame.error ?? {}));
897
+ this.requests.handleResponse(parsed);
783
898
  }
784
899
  handleClose(socket, generation, code, reason) {
785
900
  if (this.socket !== socket) {
@@ -807,34 +922,15 @@ var GatewayProtocolClient = class {
807
922
  };
808
923
  this.connectFailure = void 0;
809
924
  const decision = this.opts.resolveClose(context);
810
- this.flushRequests(decision.pendingError ?? context.connectFailure?.error ?? /* @__PURE__ */ new Error(`gateway closed (${code}): ${reason}`));
925
+ this.requests.flush(decision.pendingError ?? context.connectFailure?.error ?? /* @__PURE__ */ new Error(`gateway closed (${code}): ${reason}`));
811
926
  this.invoke("close", () => this.opts.onClose?.(context, decision));
812
927
  if (decision.retry && !this.stopped) this.scheduleReconnect(decision.reconnectDelayMs ?? context.connectFailure?.reconnectDelayMs);
813
928
  }
814
929
  handleSocketError(socket, generation, error) {
815
930
  if (!this.isActive(socket, generation) || this.connectSent) return;
931
+ this.connectFailure = { error };
816
932
  this.opts.onConnectError?.(error);
817
933
  }
818
- flushRequests(error) {
819
- for (const [id, pending] of this.pending) {
820
- this.finishRequestTiming(id, pending, false, "CLIENT_CLOSED");
821
- pending.cleanup?.();
822
- pending.reject(error);
823
- }
824
- this.pending.clear();
825
- }
826
- finishRequestTiming(id, pending, ok, errorCode) {
827
- const endedAtMs = this.nowMs();
828
- this.invoke("request timing", () => this.opts.onRequestTiming?.({
829
- id,
830
- method: pending.method,
831
- ok,
832
- durationMs: Math.max(0, endedAtMs - pending.startedAtMs),
833
- startedAtMs: pending.startedAtMs,
834
- endedAtMs,
835
- errorCode
836
- }));
837
- }
838
934
  scheduleReconnect(overrideMs) {
839
935
  if (overrideMs !== void 0) this.reconnectSupervisor.nextDelayOverrideMs = overrideMs;
840
936
  const retry = this.reconnectSupervisor.next();
@@ -884,6 +980,8 @@ const NON_RECOVERABLE_AUTH_ERRORS = /* @__PURE__ */ new Set([
884
980
  ConnectErrorDetailCodes.AUTH_RATE_LIMITED,
885
981
  ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH,
886
982
  ConnectErrorDetailCodes.AUTH_SCOPE_MISMATCH,
983
+ ConnectErrorDetailCodes.AUTH_IDENTITY_HEADER_REQUIRED,
984
+ ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH,
887
985
  ConnectErrorDetailCodes.PAIRING_REQUIRED,
888
986
  ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED,
889
987
  ConnectErrorDetailCodes.DEVICE_IDENTITY_REQUIRED
@@ -894,23 +992,22 @@ function shouldPauseGatewayReconnect(params) {
894
992
  const pairing = readPairingConnectErrorDetails(params.details);
895
993
  if (code === ConnectErrorDetailCodes.PAIRING_REQUIRED && (pairing?.pauseReconnect === false || pairing?.recommendedNextStep === "wait_then_retry")) return false;
896
994
  if (code === ConnectErrorDetailCodes.AUTH_TOKEN_MISMATCH) return params.tokenMismatchIsTerminal === true && !params.deviceTokenRetryPending;
995
+ if (code === ConnectErrorDetailCodes.AUTH_IDENTITY_HEADER_REQUIRED) return !params.deviceTokenRetryPending;
897
996
  return NON_RECOVERABLE_AUTH_ERRORS.has(code) || params.protocolMismatchIsTerminal === true && code === ConnectErrorDetailCodes.PROTOCOL_MISMATCH || params.clientVersionMismatchIsTerminal === true && code === ConnectErrorDetailCodes.CLIENT_VERSION_MISMATCH;
898
997
  }
899
998
  //#endregion
900
- //#region packages/gateway-client/src/session-projection.ts
901
- const MAX_TRACKED_SESSION_RUNS = 200;
902
- const RETAINED_SESSION_RUNS = 150;
903
- const SESSION_PROJECTION_SCOPE_KEYS = [
904
- "sessionKey",
905
- "sessionId",
906
- "agentId",
907
- "lifecycleRevision",
908
- "activeLeafEntryId"
909
- ];
910
- function readRecord(value) {
911
- return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
999
+ //#region packages/normalization-core/src/record-coerce.ts
1000
+ /** Type guard for non-array object records at browser-safe boundaries. */
1001
+ function isRecord(value) {
1002
+ return value !== null && typeof value === "object" && !Array.isArray(value);
912
1003
  }
913
- function readNonemptyString(value) {
1004
+ /** Returns a non-array record or null. */
1005
+ function asNullableRecord(value) {
1006
+ return isRecord(value) ? value : null;
1007
+ }
1008
+ //#endregion
1009
+ //#region packages/gateway-client/src/session-projection-message-identity.ts
1010
+ function readSessionProjectionString(value) {
914
1011
  return typeof value === "string" ? value.trim() || null : null;
915
1012
  }
916
1013
  function readPositiveSafeInteger(value) {
@@ -918,29 +1015,36 @@ function readPositiveSafeInteger(value) {
918
1015
  }
919
1016
  /** History and status markers carry transcript order even when they have no chat role. */
920
1017
  function readSessionMessageSequence(message, envelope) {
921
- return readPositiveSafeInteger(readRecord(readRecord(message)?.["__openclaw"])?.seq) ?? readPositiveSafeInteger(envelope?.messageSeq);
1018
+ return readPositiveSafeInteger(asNullableRecord(asNullableRecord(message)?.["__openclaw"])?.seq) ?? readPositiveSafeInteger(envelope?.messageSeq);
922
1019
  }
923
1020
  /** Run ownership normalizes a user-turn suffix without changing its persisted send key. */
924
1021
  function normalizeSessionProjectionRunId(value) {
925
- const runId = readNonemptyString(value);
1022
+ const runId = readSessionProjectionString(value);
926
1023
  return runId?.endsWith(":user") ? runId.slice(0, -5) || null : runId;
927
1024
  }
928
- /** Persisted transcript facts win over envelope projections and provider-local import IDs. */
1025
+ /** Persisted row facts win; assistant run ownership comes from its authoritative producer. */
929
1026
  function readSessionMessageIdentity(message, envelope) {
930
- const record = readRecord(message);
931
- const role = readNonemptyString(record?.role)?.toLowerCase();
1027
+ const record = asNullableRecord(message);
1028
+ const role = readSessionProjectionString(record?.role)?.toLowerCase();
932
1029
  if (!record || !role) return null;
933
- const metadata = readRecord(record["__openclaw"]);
934
- const importedFrom = readNonemptyString(metadata?.importedFrom);
935
- const cliSessionId = readNonemptyString(metadata?.cliSessionId);
936
- const externalId = readNonemptyString(metadata?.externalId);
937
- const idempotencyKey = readNonemptyString(metadata?.idempotencyKey) ?? readNonemptyString(record.idempotencyKey) ?? readNonemptyString(envelope?.idempotencyKey) ?? readNonemptyString(envelope?.clientRunId);
1030
+ const metadata = asNullableRecord(record["__openclaw"]);
1031
+ const importedFrom = readSessionProjectionString(metadata?.importedFrom);
1032
+ const cliSessionId = readSessionProjectionString(metadata?.cliSessionId);
1033
+ const externalId = readSessionProjectionString(metadata?.externalId);
1034
+ const idempotencyKey = readSessionProjectionString(metadata?.idempotencyKey) ?? readSessionProjectionString(record.idempotencyKey) ?? readSessionProjectionString(envelope?.idempotencyKey) ?? readSessionProjectionString(envelope?.clientRunId);
1035
+ const persistedRunId = normalizeSessionProjectionRunId(idempotencyKey);
1036
+ const envelopeRunId = normalizeSessionProjectionRunId(envelope?.runId);
1037
+ const metadataRunId = normalizeSessionProjectionRunId(metadata?.runId);
1038
+ const mirroredMessage = readSessionProjectionString(metadata?.mirrorOrigin) !== null;
1039
+ const isCliAssistant = role === "assistant" && readSessionProjectionString(record.api)?.toLowerCase() === "cli";
1040
+ const canonicalPersistedRunId = isCliAssistant && persistedRunId?.startsWith("cli-assistant:") ? readSessionProjectionString(persistedRunId.slice(14)) : persistedRunId;
1041
+ const optimisticRunId = metadata && Object.keys(metadata).every((key) => key === "idempotencyKey") ? canonicalPersistedRunId : null;
938
1042
  return {
939
1043
  role,
940
- id: readNonemptyString(metadata?.id) ?? readNonemptyString(envelope?.messageId),
1044
+ id: readSessionProjectionString(metadata?.id) ?? readSessionProjectionString(envelope?.messageId),
941
1045
  sequence: readSessionMessageSequence(message, envelope),
942
1046
  idempotencyKey,
943
- runId: normalizeSessionProjectionRunId(idempotencyKey) ?? normalizeSessionProjectionRunId(envelope?.runId),
1047
+ runId: role === "assistant" ? metadataRunId ?? envelopeRunId ?? (isCliAssistant || !mirroredMessage ? canonicalPersistedRunId : null) ?? optimisticRunId : metadataRunId ?? canonicalPersistedRunId ?? envelopeRunId,
944
1048
  isImported: Boolean(importedFrom || cliSessionId || externalId),
945
1049
  externalSource: importedFrom && cliSessionId && externalId ? JSON.stringify([
946
1050
  importedFrom,
@@ -949,11 +1053,62 @@ function readSessionMessageIdentity(message, envelope) {
949
1053
  ]) : null
950
1054
  };
951
1055
  }
1056
+ //#endregion
1057
+ //#region packages/gateway-client/src/session-projection-run-event.ts
1058
+ function readNonemptyString(value) {
1059
+ return typeof value === "string" ? value.trim() || null : null;
1060
+ }
1061
+ function reduceSessionProjectionRunEventImpl(projection, event, scope = {}) {
1062
+ const runId = readNonemptyString(event.runId);
1063
+ if (!runId || typeof event.state !== "string" || ![
1064
+ "delta",
1065
+ "final",
1066
+ "error",
1067
+ "aborted"
1068
+ ].includes(event.state)) return null;
1069
+ const message = event.message;
1070
+ const messageStopReason = isRecord(message) ? readNonemptyString(message.stopReason) : null;
1071
+ const stopReason = readNonemptyString(event.stopReason) ?? messageStopReason;
1072
+ const errorKind = readNonemptyString(event.errorKind);
1073
+ const base = {
1074
+ runId,
1075
+ ...message === void 0 ? {} : { message },
1076
+ scope
1077
+ };
1078
+ const next = reduceSessionProjection(projection, event.state === "delta" ? {
1079
+ type: "runDelta",
1080
+ ...base
1081
+ } : {
1082
+ type: "runTerminal",
1083
+ ...base,
1084
+ status: event.state === "aborted" ? "aborted" : event.state === "error" ? errorKind === "timeout" ? "timeout" : "error" : event.yielded === true && stopReason === "end_turn" ? "yielded" : stopReason === "error" ? "error" : "completed",
1085
+ ...stopReason === null ? {} : { stopReason },
1086
+ ...errorKind === null ? {} : { errorKind },
1087
+ ...typeof event.errorMessage === "string" ? { errorMessage: event.errorMessage } : {}
1088
+ });
1089
+ return {
1090
+ projection: next,
1091
+ previousRun: projection.runs[runId],
1092
+ currentRun: next.runs[runId]
1093
+ };
1094
+ }
1095
+ //#endregion
1096
+ //#region packages/gateway-client/src/session-projection.ts
1097
+ /** Browser-safe identity and replay rules shared by Gateway conversation clients. */
1098
+ const MAX_TRACKED_SESSION_RUNS = 200;
1099
+ const RETAINED_SESSION_RUNS = 150;
1100
+ const SESSION_PROJECTION_SCOPE_KEYS = [
1101
+ "sessionKey",
1102
+ "sessionId",
1103
+ "agentId",
1104
+ "lifecycleRevision",
1105
+ "activeLeafEntryId"
1106
+ ];
952
1107
  /** Local turns have no durable transcript metadata beyond their own optional send key. */
953
1108
  function isLocallyOptimisticSessionMessage(message) {
954
1109
  const identity = readSessionMessageIdentity(message);
955
1110
  if (!identity || identity.role !== "user" && identity.role !== "assistant") return false;
956
- const metadata = readRecord(readRecord(message)?.["__openclaw"]);
1111
+ const metadata = asNullableRecord(asNullableRecord(message)?.["__openclaw"]);
957
1112
  return !metadata || Object.keys(metadata).every((key) => key === "idempotencyKey");
958
1113
  }
959
1114
  function createEntry(message, options) {
@@ -1011,13 +1166,18 @@ function sameTranscriptIdentity(left, right) {
1011
1166
  }
1012
1167
  function entryMatches(left, right, allowSnapshotPromotion = false) {
1013
1168
  if (sameTranscriptIdentity(left.identity, right.identity)) return true;
1169
+ const durableEntry = left.identity?.id ? left : right.identity?.id ? right : null;
1170
+ const provisionalEntry = durableEntry === left ? right : durableEntry === right ? left : null;
1171
+ const durableMetadata = asNullableRecord(asNullableRecord(durableEntry?.message)?.["__openclaw"]);
1172
+ if (durableEntry?.live && provisionalEntry?.live && durableEntry.identity?.role === "assistant" && provisionalEntry.identity?.role === "assistant" && !durableEntry.identity.isImported && !provisionalEntry.identity.isImported && !provisionalEntry.identity.id && durableEntry.identity.runId && durableEntry.identity.runId === provisionalEntry.identity.runId && (readSessionProjectionString(durableMetadata?.mirrorOrigin) === null || durableMetadata?.runTerminal === true)) return true;
1014
1173
  const persisted = left.identity;
1015
1174
  const observed = right.identity;
1016
- if (allowSnapshotPromotion && right.live && persisted && observed && persisted.role === observed.role && !persisted.isImported && !observed.isImported && persisted.id && !observed.id && (persisted.sequence !== null && persisted.sequence === observed.sequence || persisted.role === "assistant" && observed.sequence === null && persisted.runId !== null && persisted.runId === observed.runId)) return true;
1175
+ const persistedMetadata = asNullableRecord(asNullableRecord(left.message)?.["__openclaw"]);
1176
+ if (allowSnapshotPromotion && right.live && persisted && observed && persisted.role === observed.role && !persisted.isImported && !observed.isImported && persisted.id && !observed.id && (persisted.sequence !== null && persisted.sequence === observed.sequence || persisted.role === "assistant" && observed.sequence === null && persisted.runId !== null && persisted.runId === observed.runId && (readSessionProjectionString(persistedMetadata?.mirrorOrigin) === null || persistedMetadata?.runTerminal === true))) return true;
1017
1177
  if (left.pending && right.pending) return Boolean(left.identity?.role === right.identity?.role && left.pendingRunId && left.pendingRunId === right.pendingRunId);
1018
1178
  const pending = left.pending ? left : right.pending ? right : null;
1019
1179
  const authoritative = pending === left ? right : pending === right ? left : null;
1020
- return Boolean(pending && authoritative && pending.identity?.role === authoritative.identity?.role && !pending.identity?.isImported && !authoritative.identity?.isImported && pending.pendingRunId && pending.pendingRunId === authoritative.identity?.runId);
1180
+ return Boolean(pending && authoritative && pending.identity && authoritative.identity && pending.identity.role === authoritative.identity.role && !pending.identity.isImported && !authoritative.identity.isImported && pending.pendingRunId && pending.pendingRunId === authoritative.identity.runId && (pending.identity.sequence === null || authoritative.identity.sequence === null || pending.identity.sequence === authoritative.identity.sequence));
1021
1181
  }
1022
1182
  function withEntries(state, entries) {
1023
1183
  return {
@@ -1054,6 +1214,7 @@ function projectLiveSessionMessage(state, message, envelope, scope = {}) {
1054
1214
  if (existingIndex < 0) return withEntries(state, insertEntry(state.entries, incoming, state.runs));
1055
1215
  const existing = state.entries[existingIndex];
1056
1216
  if (existing && existing.message === message && existing.live && !existing.pending) return state;
1217
+ if (existing && !existing.pending && existing.identity?.id && !incoming.identity.id) return state;
1057
1218
  if (existing?.pending && incoming.identity.sequence !== null) {
1058
1219
  const sequence = incoming.identity.sequence;
1059
1220
  return withEntries(state, state.entries.some(({ identity }, index) => identity?.sequence != null && (index < existingIndex ? identity.sequence > sequence : identity.sequence < sequence)) ? insertEntry(state.entries.filter((_, index) => index !== existingIndex), incoming, state.runs) : state.entries.toSpliced(existingIndex, 1, incoming));
@@ -1084,13 +1245,13 @@ function reconcileSessionProjectionSnapshot(state, messages, scope = {}, options
1084
1245
  }
1085
1246
  function hasDisplayableSessionMessage(message) {
1086
1247
  if (typeof message === "string") return message.trim().length > 0;
1087
- const record = readRecord(message);
1248
+ const record = asNullableRecord(message);
1088
1249
  if (!record) return false;
1089
1250
  const displayableBlocks = Array.isArray(record.content) && record.content.some((block) => {
1090
- const entry = readRecord(block);
1091
- return entry ? entry.type !== "text" || readNonemptyString(entry.text) !== null : typeof block === "string" && block.trim().length > 0;
1251
+ const entry = asNullableRecord(block);
1252
+ return entry ? entry.type !== "text" || readSessionProjectionString(entry.text) !== null : typeof block === "string" && block.trim().length > 0;
1092
1253
  });
1093
- const media = readRecord(record["__openclaw"])?.media;
1254
+ const media = asNullableRecord(record["__openclaw"])?.media;
1094
1255
  return Boolean(typeof record.content === "string" && record.content.trim() || displayableBlocks || Array.isArray(media) && media.length > 0);
1095
1256
  }
1096
1257
  function readSessionProjectionFinalMessageIdentity(message) {
@@ -1099,8 +1260,8 @@ function readSessionProjectionFinalMessageIdentity(message) {
1099
1260
  if (identity?.externalSource) return `import:${identity.role}:${identity.externalSource}`;
1100
1261
  if (identity?.id && !identity.isImported) return `id:${identity.role}:${identity.id}`;
1101
1262
  if (identity?.sequence !== null && identity?.sequence !== void 0) return `seq:${identity.role}:${identity.sequence}`;
1102
- const record = readRecord(message);
1103
- const metadata = readRecord(record?.["__openclaw"]);
1263
+ const record = asNullableRecord(message);
1264
+ const metadata = asNullableRecord(record?.["__openclaw"]);
1104
1265
  try {
1105
1266
  return `content:${JSON.stringify([
1106
1267
  identity?.role ?? "assistant",
@@ -1131,7 +1292,7 @@ function retainSessionProjectionRuns(runs) {
1131
1292
  return Object.fromEntries([...active, ...retainedTerminal]);
1132
1293
  }
1133
1294
  function updateRun(state, incoming) {
1134
- const incomingErrorMessage = readNonemptyString(incoming.errorMessage);
1295
+ const incomingErrorMessage = readSessionProjectionString(incoming.errorMessage);
1135
1296
  const normalizedIncoming = { ...incoming };
1136
1297
  if (incomingErrorMessage) normalizedIncoming.errorMessage = incomingErrorMessage;
1137
1298
  else delete normalizedIncoming.errorMessage;
@@ -1142,7 +1303,7 @@ function updateRun(state, incoming) {
1142
1303
  const canRecoverFinal = !hasDisplayableSessionMessage(current.message) || (current.acceptedFinalMessageIdentities?.length ?? 0) > 0;
1143
1304
  const acceptFinal = incomingIsFinal && (current.status === incoming.status || canRecoverFinal) && incomingFinalIdentity !== null && !hasSessionProjectionAcceptedFinal(current, incoming.message);
1144
1305
  const recoverMessage = acceptFinal && !hasDisplayableSessionMessage(current.message);
1145
- const recoverError = readNonemptyString(current.errorMessage) === null && incomingErrorMessage !== null;
1306
+ const recoverError = readSessionProjectionString(current.errorMessage) === null && incomingErrorMessage !== null;
1146
1307
  if (!acceptFinal && !recoverError) return state;
1147
1308
  const firstFinalIdentity = readSessionProjectionFinalMessageIdentity(current.message);
1148
1309
  const previousFinalIdentities = current.acceptedFinalMessageIdentities ?? (firstFinalIdentity ? [firstFinalIdentity] : []);
@@ -1199,7 +1360,13 @@ function reduceSessionProjection(state, event) {
1199
1360
  const pendingRunId = normalizeSessionProjectionRunId(event.idempotencyKey ?? event.runId);
1200
1361
  const incoming = createEntry(event.message, { pendingRunId });
1201
1362
  if (!pendingRunId || !incoming.identity) return state;
1202
- return state.entries.findIndex((entry) => entryMatches(entry, incoming)) < 0 ? withEntries(state, insertEntry(state.entries, incoming, state.runs)) : state;
1363
+ const seed = state.entries.find((entry) => entry.message === event.message);
1364
+ if (seed && !seed.pending && incoming.identity.id === null && !incoming.identity.isImported && incoming.identity.runId === pendingRunId) return withEntries(state, state.entries.map((entry) => entry === seed ? {
1365
+ ...seed,
1366
+ pending: true,
1367
+ pendingRunId
1368
+ } : entry));
1369
+ return seed || state.entries.some((entry) => entryMatches(entry, incoming)) ? state : withEntries(state, insertEntry(state.entries, incoming, state.runs));
1203
1370
  }
1204
1371
  case "sendAcknowledged": {
1205
1372
  const runId = normalizeSessionProjectionRunId(event.idempotencyKey ?? event.runId);
@@ -1245,38 +1412,7 @@ function reduceSessionProjection(state, event) {
1245
1412
  }
1246
1413
  /** Normalizes Gateway run envelopes once for every browser and terminal adapter. */
1247
1414
  function reduceSessionProjectionRunEvent(projection, event, scope = {}) {
1248
- const runId = readNonemptyString(event.runId);
1249
- const eventState = event.state;
1250
- if (!runId || typeof eventState !== "string" || ![
1251
- "delta",
1252
- "final",
1253
- "error",
1254
- "aborted"
1255
- ].includes(eventState)) return null;
1256
- const message = event.message;
1257
- const stopReason = readNonemptyString(event.stopReason) ?? readNonemptyString(readRecord(message)?.stopReason);
1258
- const errorKind = readNonemptyString(event.errorKind);
1259
- const base = {
1260
- runId,
1261
- ...message === void 0 ? {} : { message },
1262
- scope
1263
- };
1264
- const next = reduceSessionProjection(projection, eventState === "delta" ? {
1265
- type: "runDelta",
1266
- ...base
1267
- } : {
1268
- type: "runTerminal",
1269
- ...base,
1270
- status: eventState === "aborted" ? "aborted" : eventState === "error" ? errorKind === "timeout" ? "timeout" : "error" : event.yielded === true && stopReason === "end_turn" ? "yielded" : stopReason === "error" ? "error" : "completed",
1271
- ...stopReason === null ? {} : { stopReason },
1272
- ...errorKind === null ? {} : { errorKind },
1273
- ...typeof event.errorMessage === "string" ? { errorMessage: event.errorMessage } : {}
1274
- });
1275
- return {
1276
- projection: next,
1277
- previousRun: projection.runs[runId],
1278
- currentRun: next.runs[runId]
1279
- };
1415
+ return reduceSessionProjectionRunEventImpl(projection, event, scope);
1280
1416
  }
1281
1417
  //#endregion
1282
1418
  //#region packages/gateway-client/src/session-subscriptions.ts
@@ -1371,7 +1507,7 @@ var GatewaySessionMessageSubscriptionCoordinator = class {
1371
1507
  entry.release = tracked;
1372
1508
  return tracked;
1373
1509
  }
1374
- const tracked = this.#client.request("sessions.messages.unsubscribe", sessionSubscriptionParams(entry.key, entry.agentId)).then(() => {
1510
+ const tracked = this.#client.request("sessions.messages.unsubscribe", sessionSubscriptionParams(entry.key, entry.agentId), { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS }).then(() => {
1375
1511
  this.#finishRelease(subscription, owner, true);
1376
1512
  }).finally(() => {
1377
1513
  if (entry.release === tracked) entry.release = null;
@@ -1446,9 +1582,18 @@ var GatewaySessionMessageSubscriptionCoordinator = class {
1446
1582
  return upgrade;
1447
1583
  }
1448
1584
  async #requestSubscribe(entry, includeApprovals) {
1449
- const result = await this.#client.request("sessions.messages.subscribe", {
1450
- ...sessionSubscriptionParams(entry.key, entry.agentId),
1451
- ...includeApprovals ? { includeApprovals: true } : {}
1585
+ const params = sessionSubscriptionParams(entry.key, entry.agentId);
1586
+ const result = await this.#client.request("sessions.messages.subscribe", includeApprovals ? {
1587
+ ...params,
1588
+ includeApprovals: true
1589
+ } : params, { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS }).catch(async (error) => {
1590
+ if (!(error instanceof GatewayProtocolRequestTimeoutError) || !error.requestSent || this.#retired) throw error;
1591
+ try {
1592
+ await this.#client.request(entry.handles.size > 0 ? "sessions.messages.subscribe" : "sessions.messages.unsubscribe", params, { timeoutMs: DEFAULT_GATEWAY_REQUEST_TIMEOUT_MS });
1593
+ } catch (recoveryError) {
1594
+ if (!this.#retired) throw new AggregateError([error, recoveryError], "session message subscription recovery failed", { cause: recoveryError });
1595
+ }
1596
+ throw error;
1452
1597
  });
1453
1598
  const response = result && typeof result === "object" ? result : null;
1454
1599
  const responseKey = response && "key" in response ? response.key : void 0;
@@ -1484,4 +1629,4 @@ function releaseGatewaySessionMessageSubscription(subscription) {
1484
1629
  return sessionMessageSubscriptionOwners.get(subscription)?.coordinator.release(subscription) ?? Promise.resolve();
1485
1630
  }
1486
1631
  //#endregion
1487
- export { buildDeviceAuthPayload as C, shouldRetryGatewayWithDeviceToken as S, normalizeDeviceMetadataForAuth as T, GatewayProtocolRequestError as _, createSessionProjection as a, resolveGatewayConnectScopes as b, normalizeSessionProjectionRunId as c, readSessionMessageSequence as d, reconcileSessionProjectionSnapshot as f, GatewayProtocolClient as g, shouldPauseGatewayReconnect as h, resetGatewaySessionMessageSubscriptionCoordinator as i, projectLiveSessionMessage as l, reduceSessionProjectionRunEvent as m, getGatewaySessionMessageSubscriptionCoordinator as n, hasSessionProjectionAcceptedFinal as o, reduceSessionProjection as p, releaseGatewaySessionMessageSubscription as r, isLocallyOptimisticSessionMessage as s, GatewaySessionMessageSubscriptionCoordinator as t, readSessionMessageIdentity as u, GatewayBrowserDeviceAuthLifecycle as v, buildDeviceAuthPayloadV3 as w, selectGatewayConnectAuth as x, buildGatewayConnectAuth as y };
1632
+ export { resolveGatewayConnectScopes as C, buildDeviceAuthPayloadV3 as D, buildDeviceAuthPayload as E, normalizeDeviceMetadataForAuth as O, buildGatewayConnectAuth as S, shouldRetryGatewayWithDeviceToken as T, GatewayProtocolRequestError as _, createSessionProjection as a, gatewayOriginScope as b, projectLiveSessionMessage as c, reduceSessionProjectionRunEvent as d, normalizeSessionProjectionRunId as f, GatewayProtocolClient as g, shouldPauseGatewayReconnect as h, resetGatewaySessionMessageSubscriptionCoordinator as i, reconcileSessionProjectionSnapshot as l, readSessionMessageSequence as m, getGatewaySessionMessageSubscriptionCoordinator as n, hasSessionProjectionAcceptedFinal as o, readSessionMessageIdentity as p, releaseGatewaySessionMessageSubscription as r, isLocallyOptimisticSessionMessage as s, GatewaySessionMessageSubscriptionCoordinator as t, reduceSessionProjection as u, GatewayProtocolRequestTimeoutError as v, selectGatewayConnectAuth as w, GatewayBrowserDeviceAuthLifecycle as x, gatewayCredentialScope as y };