ocuclaw 1.3.2 → 1.3.4

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 (84) hide show
  1. package/README.md +29 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +0 -3
  3. package/dist/config/runtime-config.js +22 -33
  4. package/dist/domain/activity-status-adapter.js +0 -7
  5. package/dist/domain/activity-status-arbiter.js +3 -27
  6. package/dist/domain/activity-status-labels.js +8 -38
  7. package/dist/domain/code-span-regions.js +4 -24
  8. package/dist/domain/constant-time-equal.js +9 -0
  9. package/dist/domain/constant-time-equal.test.js +28 -0
  10. package/dist/domain/conversation-state.js +27 -138
  11. package/dist/domain/debug-bundle-cache.js +52 -0
  12. package/dist/domain/debug-bundle-format.js +60 -0
  13. package/dist/domain/debug-bundle-preview.js +123 -0
  14. package/dist/domain/debug-bundle-redaction.js +182 -0
  15. package/dist/domain/debug-bundle-save.js +11 -0
  16. package/dist/domain/debug-bundle-zip.js +15 -0
  17. package/dist/domain/debug-bundle.js +97 -0
  18. package/dist/domain/debug-store.js +6 -17
  19. package/dist/domain/debug-upload-preset.js +27 -0
  20. package/dist/domain/glasses-display-system-prompt.js +0 -5
  21. package/dist/domain/glasses-display-system-prompt.test.js +1 -1
  22. package/dist/domain/glasses-ui-content-summary.js +0 -6
  23. package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
  24. package/dist/domain/message-emoji-allowlist.js +0 -7
  25. package/dist/domain/message-emoji-filter.js +3 -9
  26. package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
  27. package/dist/domain/prompt-channel-fragments.js +1 -10
  28. package/dist/domain/tagged-span-parser.js +3 -26
  29. package/dist/domain/tagged-span-strip.js +0 -7
  30. package/dist/even-ai/even-ai-endpoint.js +77 -24
  31. package/dist/even-ai/even-ai-run-waiter.js +0 -1
  32. package/dist/even-ai/even-ai-settings-store.js +11 -0
  33. package/dist/gateway/gateway-bridge.js +8 -9
  34. package/dist/gateway/gateway-timing-ledger.js +8 -6
  35. package/dist/gateway/openclaw-client.js +97 -297
  36. package/dist/gateway/sanitize-connect-reason.js +10 -0
  37. package/dist/gateway/sanitize-connect-reason.test.js +34 -0
  38. package/dist/index.js +3 -3
  39. package/dist/runtime/channel-two-hook.js +1 -6
  40. package/dist/runtime/container-env.js +1 -5
  41. package/dist/runtime/debug-bundle-handler.js +159 -0
  42. package/dist/runtime/display-toggle-states.js +6 -17
  43. package/dist/runtime/downstream-handler.js +682 -508
  44. package/dist/runtime/glasses-backpressure-latch.js +93 -0
  45. package/dist/runtime/ocuclaw-settings-store.js +10 -1
  46. package/dist/runtime/openclaw-host-version.js +5 -0
  47. package/dist/runtime/plugin-version-service.js +13 -6
  48. package/dist/runtime/provider-usage-select.js +0 -6
  49. package/dist/runtime/register-session-title-distiller.js +14 -16
  50. package/dist/runtime/relay-core.js +657 -271
  51. package/dist/runtime/relay-service.js +40 -36
  52. package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
  53. package/dist/runtime/relay-worker-entry.js +1 -2
  54. package/dist/runtime/relay-worker-health.js +2 -10
  55. package/dist/runtime/relay-worker-protocol.js +6 -1
  56. package/dist/runtime/relay-worker-supervisor.js +109 -39
  57. package/dist/runtime/relay-worker-transport.js +157 -15
  58. package/dist/runtime/session-context-service.js +5 -45
  59. package/dist/runtime/session-service.js +157 -175
  60. package/dist/runtime/session-title-distiller-budget.js +1 -5
  61. package/dist/runtime/session-title-distiller-helpers.js +14 -24
  62. package/dist/runtime/session-title-distiller.js +109 -122
  63. package/dist/runtime/session-title-record.js +0 -6
  64. package/dist/runtime/stable-prompt-snapshot.js +3 -14
  65. package/dist/runtime/upstream-runtime.js +600 -103
  66. package/dist/tools/device-info-tool.js +4 -21
  67. package/dist/tools/glasses-ui-cron.js +58 -63
  68. package/dist/tools/glasses-ui-descriptors.js +4 -33
  69. package/dist/tools/glasses-ui-limits.js +0 -13
  70. package/dist/tools/glasses-ui-paint-floor.js +22 -34
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +295 -100
  73. package/dist/tools/glasses-ui-template.js +7 -22
  74. package/dist/tools/glasses-ui-tool-description.test.js +2 -2
  75. package/dist/tools/glasses-ui-tool.js +475 -331
  76. package/dist/tools/glasses-ui-voicemail.js +242 -0
  77. package/dist/tools/glasses-ui-wake.js +195 -0
  78. package/dist/tools/session-title-tool.js +2 -7
  79. package/dist/tools/session-title-tool.test.js +1 -1
  80. package/dist/version.js +3 -2
  81. package/openclaw.plugin.json +60 -13
  82. package/package.json +3 -2
  83. package/skills/glasses-ui/SKILL.md +19 -3
  84. package/dist/runtime/protocol-adapter.js +0 -387
@@ -4,8 +4,7 @@ import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import WebSocket from "ws";
6
6
  import { createGatewayTimingLedger } from "./gateway-timing-ledger.js";
7
-
8
- // --- Constants ---
7
+ import { sanitizeConnectReason } from "./sanitize-connect-reason.js";
9
8
 
10
9
  const DEVICE_KEY_FILE = "ocuclaw-device-key.json";
11
10
  const DEVICE_TOKEN_FILE = "ocuclaw-device-token.json";
@@ -24,11 +23,14 @@ const MIN_PROTOCOL_VERSION = 3;
24
23
  const MAX_PROTOCOL_VERSION = 4;
25
24
  const HISTORY_ACTIVITY_POLL_INTERVAL_MS = 500;
26
25
  const HISTORY_ACTIVITY_POLL_LIMIT = 40;
27
- // Per-request ACK timeout: fires only while waiting for the *initial* ack, not
28
- // during a mid-turn run. Comfortably above normal ack latency but below the
29
- // ~60s coarse tick-watch run backstop. Disarmed when an accepted ack arrives.
26
+
30
27
  const RPC_ACK_TIMEOUT_MS = 15000;
31
28
 
29
+ const ESTABLISH_TIMEOUT_MS = 10000;
30
+
31
+ const TICK_WATCH_MAX_INTERVAL_MS = 60000;
32
+ const TICK_STALE_MULTIPLIER = 1.5;
33
+
32
34
  const THINKING_SUMMARY_KEYS = [
33
35
  "summary",
34
36
  "thinkingSummary",
@@ -110,12 +112,10 @@ function writeJsonFile(filePath, data) {
110
112
  try {
111
113
  fs.chmodSync(filePath, 0o600);
112
114
  } catch {
113
- // best-effort
115
+
114
116
  }
115
117
  }
116
118
 
117
- // --- Base64url helpers ---
118
-
119
119
  function base64UrlEncode(buf) {
120
120
  return buf
121
121
  .toString("base64")
@@ -124,12 +124,6 @@ function base64UrlEncode(buf) {
124
124
  .replace(/=+$/g, "");
125
125
  }
126
126
 
127
- // --- Device identity ---
128
-
129
- /**
130
- * Extract raw 32-byte Ed25519 public key from SPKI DER.
131
- * Strips the standard 12-byte SPKI prefix for Ed25519 keys.
132
- */
133
127
  function derivePublicKeyRaw(publicKeyPem) {
134
128
  const key = crypto.createPublicKey(publicKeyPem);
135
129
  const spki = key.export({ type: "spki", format: "der" });
@@ -142,17 +136,11 @@ function derivePublicKeyRaw(publicKeyPem) {
142
136
  return spki;
143
137
  }
144
138
 
145
- /**
146
- * SHA-256 hex hash of the raw 32-byte public key.
147
- */
148
139
  function fingerprintPublicKey(publicKeyPem) {
149
140
  const raw = derivePublicKeyRaw(publicKeyPem);
150
141
  return crypto.createHash("sha256").update(raw).digest("hex");
151
142
  }
152
143
 
153
- /**
154
- * Generate a new Ed25519 keypair.
155
- */
156
144
  function generateIdentity() {
157
145
  const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
158
146
  const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
@@ -161,12 +149,9 @@ function generateIdentity() {
161
149
  return { deviceId, publicKeyPem, privateKeyPem };
162
150
  }
163
151
 
164
- /**
165
- * Load device identity from disk, or generate and persist a new one.
166
- */
167
152
  function loadOrCreateDeviceIdentity(persistencePaths, logger) {
168
153
  const deviceKeyPath = persistencePaths && persistencePaths.deviceKeyPath;
169
- // Try loading existing key
154
+
170
155
  try {
171
156
  if (deviceKeyPath && fs.existsSync(deviceKeyPath)) {
172
157
  const raw = fs.readFileSync(deviceKeyPath, "utf8");
@@ -178,10 +163,10 @@ function loadOrCreateDeviceIdentity(persistencePaths, logger) {
178
163
  typeof parsed.publicKeyPem === "string" &&
179
164
  typeof parsed.privateKeyPem === "string"
180
165
  ) {
181
- // Verify deviceId matches public key
166
+
182
167
  const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
183
168
  if (derivedId && derivedId !== parsed.deviceId) {
184
- // Fix stored deviceId
169
+
185
170
  const updated = { ...parsed, deviceId: derivedId };
186
171
  writeJsonFile(deviceKeyPath, updated);
187
172
  logger.info(
@@ -204,10 +189,9 @@ function loadOrCreateDeviceIdentity(persistencePaths, logger) {
204
189
  }
205
190
  }
206
191
  } catch {
207
- // Fall through to regenerate
192
+
208
193
  }
209
194
 
210
- // Generate new identity
211
195
  const identity = generateIdentity();
212
196
  if (!deviceKeyPath) {
213
197
  logger.info(
@@ -229,12 +213,6 @@ function loadOrCreateDeviceIdentity(persistencePaths, logger) {
229
213
  return identity;
230
214
  }
231
215
 
232
- // --- Device token cache ---
233
-
234
- /**
235
- * Load cached device token from disk.
236
- * Returns token string or null.
237
- */
238
216
  function loadDeviceToken(deviceId, persistencePaths) {
239
217
  const deviceTokenPath = persistencePaths && persistencePaths.deviceTokenPath;
240
218
  try {
@@ -255,9 +233,6 @@ function loadDeviceToken(deviceId, persistencePaths) {
255
233
  }
256
234
  }
257
235
 
258
- /**
259
- * Store device token to disk.
260
- */
261
236
  function storeDeviceToken(deviceId, token, role, scopes, persistencePaths) {
262
237
  const deviceTokenPath = persistencePaths && persistencePaths.deviceTokenPath;
263
238
  if (!deviceTokenPath) return;
@@ -272,9 +247,6 @@ function storeDeviceToken(deviceId, token, role, scopes, persistencePaths) {
272
247
  writeJsonFile(deviceTokenPath, data);
273
248
  }
274
249
 
275
- /**
276
- * Clear cached device token.
277
- */
278
250
  function clearDeviceToken(persistencePaths) {
279
251
  const deviceTokenPath = persistencePaths && persistencePaths.deviceTokenPath;
280
252
  try {
@@ -282,16 +254,10 @@ function clearDeviceToken(persistencePaths) {
282
254
  fs.unlinkSync(deviceTokenPath);
283
255
  }
284
256
  } catch {
285
- // best-effort
257
+
286
258
  }
287
259
  }
288
260
 
289
- // --- Auth payload ---
290
-
291
- /**
292
- * Build the pipe-delimited device auth payload string.
293
- * Format: v2|{deviceId}|{clientId}|{clientMode}|{role}|{scopes}|{signedAtMs}|{token}|{nonce}
294
- */
295
261
  function buildDeviceAuthPayload(params) {
296
262
  const version = params.nonce ? "v2" : "v1";
297
263
  const scopes = params.scopes.join(",");
@@ -312,18 +278,12 @@ function buildDeviceAuthPayload(params) {
312
278
  return parts.join("|");
313
279
  }
314
280
 
315
- /**
316
- * Sign a payload string with Ed25519 private key, return base64url signature.
317
- */
318
281
  function signPayload(privateKeyPem, payload) {
319
282
  const key = crypto.createPrivateKey(privateKeyPem);
320
283
  const sig = crypto.sign(null, Buffer.from(payload, "utf8"), key);
321
284
  return base64UrlEncode(sig);
322
285
  }
323
286
 
324
- /**
325
- * Get the raw public key as base64url from PEM.
326
- */
327
287
  function publicKeyRawBase64Url(publicKeyPem) {
328
288
  return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
329
289
  }
@@ -389,7 +349,7 @@ function pickFirstStringEntry(obj, keys) {
389
349
 
390
350
  function normalizeThinkingText(raw) {
391
351
  if (typeof raw !== "string") return null;
392
- // Match ActivityStatus iOS app behavior: strip bold markers and trim boundaries only.
352
+
393
353
  const cleaned = raw
394
354
  .replace(/\*\*/g, "")
395
355
  .trim();
@@ -618,10 +578,7 @@ function buildTerminalErrorActivity(data, fallbackRunId, fallbackSessionKey, fal
618
578
  if (label) activity.label = label;
619
579
  if (detail) activity.detail = detail;
620
580
  else if (label) activity.detail = label;
621
- // failoverReason is set by the runtime when this terminal error is about to
622
- // trigger a profile rotation or fallback retry. Surface it as a hint so the
623
- // WebUI can suppress sticky failure feedback for runs that will retry on
624
- // another auth profile or model rather than ending the user-visible turn.
581
+
625
582
  if (pickTrimmedString(source.failoverReason)) {
626
583
  activity.failoverPending = true;
627
584
  }
@@ -770,8 +727,6 @@ function hashThinkingKey(seed) {
770
727
  return crypto.createHash("sha1").update(seed).digest("hex").slice(0, 16);
771
728
  }
772
729
 
773
- // --- OpenClaw Gateway Client ---
774
-
775
730
  class OpenClawClient extends EventEmitter {
776
731
  constructor(opts = {}) {
777
732
  super();
@@ -786,50 +741,38 @@ class OpenClawClient extends EventEmitter {
786
741
  this._persistencePaths = resolvePersistencePaths(opts.stateDir);
787
742
  this._ws = null;
788
743
  this._stopped = false;
789
- this._pending = new Map(); // id -> { resolve, reject, expectFinal }
744
+ this._pending = new Map();
790
745
  this._identity = null;
791
746
  this._connectNonce = null;
792
747
  this._connectSent = false;
793
748
  this._connectTimer = null;
794
- // --- Socket-generation handshake gate (1008 reconnect-storm fix) ---
795
- // Monotonic counter bumped on every _connect(); _handshakeGeneration is the
796
- // watermark of the latest generation whose connect handshake has RESOLVED.
797
- // A fresh socket is automatically "handshake-incomplete" (its generation is
798
- // strictly greater than the watermark) with no extra reset bookkeeping —
799
- // mirrors the existing _activeRunGeneration idiom. request() refuses any
800
- // non-connect frame on a real ws socket whose generation has not yet
801
- // handshaked, which is what enforces the gateway's "first frame must be
802
- // connect" invariant across reconnect churn.
749
+
750
+ this._establishTimer = null;
751
+
803
752
  this._socketGeneration = 0;
804
753
  this._handshakeGeneration = -1;
805
754
  this._tickIntervalMs = 30000;
806
- this._deviceToken = null; // cached from hello-ok
755
+ this._deviceToken = null;
807
756
 
808
- // --- Reconnection (step 7) ---
809
757
  this._backoffMs = 1000;
810
758
  this._reconnectTimer = null;
811
759
 
812
- // --- Tick watch (step 7) ---
813
760
  this._lastTick = null;
814
761
  this._tickWatchTimer = null;
815
762
 
816
- // --- Agent run state (steps 4-5) ---
817
763
  this._activeRunId = null;
818
764
  this._activeRunSessionKey = null;
819
765
  this._activeRunStartedAtMs = null;
820
766
  this._activeRunGeneration = 0;
821
- this._runTextBuffer = ""; // accumulated assistant text for current run
767
+ this._runTextBuffer = "";
822
768
 
823
- // --- Agent identity (step 6) ---
824
769
  this._agentIdentity = null;
825
770
 
826
- // --- Sequence tracking (step 8) ---
827
771
  this._lastSeq = null;
828
- this._gapDuringRun = false; // set if gap detected while run active
772
+ this._gapDuringRun = false;
829
773
 
830
- // --- History hydration (step 9) ---
831
774
  this._historyResolved = false;
832
- this._eventQueue = []; // queued agent events until history resolves
775
+ this._eventQueue = [];
833
776
  this._historyActivityPollTimer = null;
834
777
  this._historyActivityPollInFlightGeneration = null;
835
778
  this._seenThinkingSummaryIds = new Set();
@@ -840,20 +783,15 @@ class OpenClawClient extends EventEmitter {
840
783
  this._timingLedger.setLogger(this._logger);
841
784
  }
842
785
 
843
- /**
844
- * Begin connecting to the gateway (non-blocking).
845
- */
846
786
  start() {
847
787
  if (this._stopped) return;
848
788
 
849
- // Load or create device identity (only on first start)
850
789
  if (!this._identity) {
851
790
  this._identity = loadOrCreateDeviceIdentity(
852
791
  this._persistencePaths,
853
792
  this._logger,
854
793
  );
855
794
 
856
- // Load cached device token
857
795
  this._deviceToken = loadDeviceToken(
858
796
  this._identity.deviceId,
859
797
  this._persistencePaths,
@@ -866,15 +804,13 @@ class OpenClawClient extends EventEmitter {
866
804
  this._connect();
867
805
  }
868
806
 
869
- /**
870
- * Disconnect and stop.
871
- */
872
807
  stop() {
873
808
  this._stopped = true;
874
809
  if (this._connectTimer) {
875
810
  clearTimeout(this._connectTimer);
876
811
  this._connectTimer = null;
877
812
  }
813
+ this._clearEstablishTimer();
878
814
  if (this._reconnectTimer) {
879
815
  clearTimeout(this._reconnectTimer);
880
816
  this._reconnectTimer = null;
@@ -890,31 +826,15 @@ class OpenClawClient extends EventEmitter {
890
826
  this.emit("status", "stopped");
891
827
  }
892
828
 
893
- /**
894
- * Send a request to the gateway. Returns a promise that resolves with the response payload.
895
- * @param {string} method
896
- * @param {object} [params]
897
- * @param {{ expectFinal?: boolean }} [opts] - If expectFinal is true, skip intermediate
898
- * acks (status: "accepted") and resolve only on the final response.
899
- */
900
829
  request(method, params, opts) {
901
- // Capture the socket + generation at ENTRY so a reconnect that reassigns
902
- // this._ws between entry and the synchronous send can never land this frame
903
- // on a successor socket (kills the successor-socket race variant).
830
+
904
831
  const ws = this._ws;
905
832
  const gen = this._socketGeneration;
906
833
  if (!ws || ws !== this._ws || ws.readyState !== WebSocket.OPEN) {
907
- // PRESERVE this exact literal — relay.test.js asserts it verbatim for the
908
- // no-usable-socket case. The handshake gate below uses a DISTINCT message.
834
+
909
835
  return Promise.reject(new Error("gateway not connected"));
910
836
  }
911
- // Per-socket handshake gate: a non-connect frame must not be the first frame
912
- // on a freshly-OPEN-but-unregistered socket (gateway emits 1008). connect
913
- // BYPASSES the gate (it IS the first allowed frame). The gate only applies
914
- // to real `ws.WebSocket` sockets — the unit harnesses inject plain-object
915
- // fakes ({ readyState: 1, send }) which are intentionally treated as
916
- // already-handshaken so their request("agent"/"ping"/"chat.history") still
917
- // sends with no harness changes.
837
+
918
838
  if (
919
839
  method !== "connect" &&
920
840
  ws instanceof WebSocket &&
@@ -932,9 +852,7 @@ class OpenClawClient extends EventEmitter {
932
852
  const promise = new Promise((resolve, reject) => {
933
853
  this._pending.set(id, { resolve, reject, expectFinal, method, diagnostic });
934
854
  });
935
- // Per-request ACK timeout: reject if the initial ack never arrives. Disarmed
936
- // when an accepted ack arrives (keepPending branch) so a legitimately
937
- // long-running mid-turn run is NOT killed by this timeout.
855
+
938
856
  const timer = setTimeout(() => {
939
857
  const pendingEntry = this._pending.get(id);
940
858
  if (!pendingEntry) return;
@@ -964,15 +882,6 @@ class OpenClawClient extends EventEmitter {
964
882
  return promise;
965
883
  }
966
884
 
967
- // --- Public: messaging (step 4) ---
968
-
969
- /**
970
- * Send a user message to the OpenClaw agent.
971
- * Fire-and-forget: sends the request, streaming events arrive via event handlers.
972
- * @param {string} text - Message text
973
- * @param {string} [sessionKey="main"] - Session key
974
- * @param {object|null} [attachment] - Optional image attachment payload
975
- */
976
885
  sendMessage(text, sessionKey, attachment) {
977
886
  const key = sessionKey || "main";
978
887
  const idempotencyKey = crypto.randomUUID();
@@ -993,8 +902,6 @@ class OpenClawClient extends EventEmitter {
993
902
  ];
994
903
  }
995
904
 
996
- // Resolve on the initial ack (accepted/queued) for immediate feedback.
997
- // Agent streaming events arrive independently via event handlers.
998
905
  return this.request(
999
906
  "agent",
1000
907
  params,
@@ -1012,13 +919,6 @@ class OpenClawClient extends EventEmitter {
1012
919
  });
1013
920
  }
1014
921
 
1015
- // --- Public: agent identity (step 6) ---
1016
-
1017
- /**
1018
- * Fetch agent identity from the gateway. Caches the result.
1019
- * @param {string} [sessionKey] - Optional session key
1020
- * @returns {Promise<{agentId, name, emoji, avatar}>}
1021
- */
1022
922
  async fetchAgentIdentity(sessionKey) {
1023
923
  const params = sessionKey ? { sessionKey } : {};
1024
924
  const result = await this.request("agent.identity.get", params);
@@ -1028,12 +928,6 @@ class OpenClawClient extends EventEmitter {
1028
928
  return result;
1029
929
  }
1030
930
 
1031
- /**
1032
- * Resolve an approval request.
1033
- * @param {string} id - Approval request ID
1034
- * @param {string} decision - "allow-once", "allow-always", or "deny"
1035
- * @returns {Promise}
1036
- */
1037
931
  resolveApproval(id, decision) {
1038
932
  const method =
1039
933
  typeof id === "string" && id.startsWith("plugin:")
@@ -1076,28 +970,21 @@ class OpenClawClient extends EventEmitter {
1076
970
  );
1077
971
  }
1078
972
 
1079
- // --- Internal: connection ---
1080
-
1081
973
  _connect() {
1082
974
  if (this._stopped) return;
1083
975
 
1084
- // Close any existing WebSocket to prevent parallel connections
1085
- // (e.g., from shutdown handler scheduling reconnect before close fires)
1086
976
  if (this._ws) {
1087
- try { this._ws.close(); } catch { /* ignore */ }
977
+ try { this._ws.close(); } catch { }
1088
978
  this._ws = null;
1089
979
  }
1090
980
 
1091
- // Clear a stale 750ms connect-fallback timer left armed by a prior
1092
- // generation that opened then closed before it fired. _connect() resets
1093
- // _connectSent=false below, so a stale timer firing here would re-enter
1094
- // _sendConnect() on the NEW socket — harmless (still a connect frame, never
1095
- // a 1008), but it can produce a redundant connect. Clearing closes the edge.
1096
981
  if (this._connectTimer) {
1097
982
  clearTimeout(this._connectTimer);
1098
983
  this._connectTimer = null;
1099
984
  }
1100
985
 
986
+ this._clearEstablishTimer();
987
+
1101
988
  const url = this._gatewayUrl;
1102
989
  this.emit("status", "connecting");
1103
990
  this._logger.info(`[openclaw] Connecting to ${url}`);
@@ -1105,14 +992,8 @@ class OpenClawClient extends EventEmitter {
1105
992
  this._connectNonce = null;
1106
993
  this._connectSent = false;
1107
994
 
1108
- // Bump the socket generation BEFORE constructing the new socket. The
1109
- // handshake watermark (_handshakeGeneration) intentionally STAYS at the
1110
- // prior value, so the about-to-be-created socket is automatically
1111
- // handshake-incomplete (its generation > watermark) until its own connect
1112
- // resolves. No watermark reset is needed.
1113
995
  this._socketGeneration += 1;
1114
996
 
1115
- // Reset per-connection state
1116
997
  this._timingLedger.clear("connect_reset");
1117
998
  this._lastSeq = null;
1118
999
  this._lastTick = null;
@@ -1123,10 +1004,13 @@ class OpenClawClient extends EventEmitter {
1123
1004
  const ws = new WebSocket(url, { maxPayload: 25 * 1024 * 1024 });
1124
1005
  this._ws = ws;
1125
1006
 
1007
+ this._armEstablishTimer(ws);
1008
+
1126
1009
  ws.on("open", () => {
1010
+
1011
+ this._clearEstablishTimer();
1127
1012
  this._logger.info("[openclaw] WebSocket open, waiting for challenge...");
1128
- // Start a timeout: if we don't receive a challenge, send connect anyway
1129
- // (mirrors the reference client's queueConnect fallback)
1013
+
1130
1014
  this._connectTimer = setTimeout(() => {
1131
1015
  this._sendConnect();
1132
1016
  }, 750);
@@ -1140,14 +1024,14 @@ class OpenClawClient extends EventEmitter {
1140
1024
  const reasonText = reason ? reason.toString() : "";
1141
1025
  this._logger.info(`[openclaw] WebSocket closed: ${code} ${reasonText}`);
1142
1026
  this._ws = null;
1027
+ this._clearEstablishTimer();
1143
1028
  this._stopTickWatch();
1144
1029
  this._stopHistoryActivityPolling();
1145
1030
  this._timingLedger.clear("disconnect");
1146
1031
  this._flushPendingErrors(new Error(`gateway closed (${code}): ${reasonText}`));
1147
1032
  this.emit("disconnected", { code, reason: reasonText });
1148
1033
  this.emit("status", "disconnected");
1149
- // Only schedule reconnect if one isn't already pending
1150
- // (e.g., shutdown handler may have already scheduled with a specific delay)
1034
+
1151
1035
  if (!this._reconnectTimer) {
1152
1036
  this._scheduleReconnect();
1153
1037
  }
@@ -1161,7 +1045,33 @@ class OpenClawClient extends EventEmitter {
1161
1045
  });
1162
1046
  }
1163
1047
 
1164
- // --- Internal: message handling ---
1048
+ _armEstablishTimer(ws) {
1049
+ const establishGeneration = this._socketGeneration;
1050
+ this._establishTimer = setTimeout(() => {
1051
+ this._establishTimer = null;
1052
+ if (this._stopped || ws !== this._ws || establishGeneration !== this._socketGeneration) {
1053
+ return;
1054
+ }
1055
+ this._logger.warn(
1056
+ `[openclaw] Connect establishment timeout (${ESTABLISH_TIMEOUT_MS}ms), terminating socket`
1057
+ );
1058
+ try {
1059
+ ws.terminate();
1060
+ } catch {
1061
+
1062
+ }
1063
+ }, ESTABLISH_TIMEOUT_MS);
1064
+ if (this._establishTimer.unref) {
1065
+ this._establishTimer.unref();
1066
+ }
1067
+ }
1068
+
1069
+ _clearEstablishTimer() {
1070
+ if (this._establishTimer) {
1071
+ clearTimeout(this._establishTimer);
1072
+ this._establishTimer = null;
1073
+ }
1074
+ }
1165
1075
 
1166
1076
  _handleMessage(raw) {
1167
1077
  let parsed;
@@ -1172,21 +1082,17 @@ class OpenClawClient extends EventEmitter {
1172
1082
  return;
1173
1083
  }
1174
1084
 
1175
- // Emit protocol event for every incoming frame (step 10)
1176
1085
  this.emit("protocol", { direction: "in", frame: parsed });
1177
1086
 
1178
- // Event frames: { type: "event", event: "...", payload: ... }
1179
1087
  if (parsed.type === "event") {
1180
1088
  this._handleEvent(parsed);
1181
1089
  return;
1182
1090
  }
1183
1091
 
1184
- // Response frames: { type: "res", id: "...", ok: true/false, payload: ... }
1185
1092
  if (parsed.type === "res") {
1186
1093
  const pending = this._pending.get(parsed.id);
1187
1094
  if (!pending) return;
1188
1095
 
1189
- // If expectFinal, skip intermediate acks (status: "accepted") (step 4)
1190
1096
  const payload = parsed.payload;
1191
1097
  const status = payload && payload.status;
1192
1098
  const keepPending =
@@ -1200,21 +1106,19 @@ class OpenClawClient extends EventEmitter {
1200
1106
  keepPending,
1201
1107
  });
1202
1108
  if (keepPending) {
1203
- // The accepted ack arrived: the run is legitimately long-running, so
1204
- // disarm the ACK timeout (the coarse tick-watch remains the run backstop).
1109
+
1205
1110
  if (pending.timer) {
1206
1111
  clearTimeout(pending.timer);
1207
1112
  pending.timer = null;
1208
1113
  }
1209
- // Track the runId from the ack
1114
+
1210
1115
  if (payload.runId) {
1211
1116
  this._activeRunId = payload.runId;
1212
1117
  this._logger.info(`[openclaw] Agent run accepted: ${payload.runId}`);
1213
1118
  }
1214
- return; // Keep the pending entry, wait for final response
1119
+ return;
1215
1120
  }
1216
1121
 
1217
- // Final settle: clear the ACK timeout before resolving/rejecting.
1218
1122
  if (pending.timer) {
1219
1123
  clearTimeout(pending.timer);
1220
1124
  pending.timer = null;
@@ -1232,7 +1136,7 @@ class OpenClawClient extends EventEmitter {
1232
1136
  if (parsed.error && parsed.error.data !== undefined) {
1233
1137
  err.data = parsed.error.data;
1234
1138
  }
1235
- // Check for retryable hint (step 7)
1139
+
1236
1140
  if (parsed.error && parsed.error.retryable && parsed.error.retryAfterMs) {
1237
1141
  err.retryAfterMs = parsed.error.retryAfterMs;
1238
1142
  }
@@ -1242,10 +1146,8 @@ class OpenClawClient extends EventEmitter {
1242
1146
  }
1243
1147
  }
1244
1148
 
1245
- // --- Internal: event routing ---
1246
-
1247
1149
  _handleEvent(evt) {
1248
- // connect.challenge is handled before sequence tracking
1150
+
1249
1151
  if (evt.event === "connect.challenge") {
1250
1152
  const nonce =
1251
1153
  evt.payload && typeof evt.payload.nonce === "string" ? evt.payload.nonce : null;
@@ -1257,7 +1159,6 @@ class OpenClawClient extends EventEmitter {
1257
1159
  return;
1258
1160
  }
1259
1161
 
1260
- // --- Sequence tracking (step 8) ---
1261
1162
  const seq = typeof evt.seq === "number" ? evt.seq : null;
1262
1163
  if (seq !== null) {
1263
1164
  if (this._lastSeq !== null && seq > this._lastSeq + 1) {
@@ -1266,7 +1167,7 @@ class OpenClawClient extends EventEmitter {
1266
1167
  `[openclaw] Sequence gap: expected ${gapInfo.expected}, received ${gapInfo.received}`
1267
1168
  );
1268
1169
  this.emit("gap", gapInfo);
1269
- // Flag gap during active run for post-run re-fetch (step 8)
1170
+
1270
1171
  if (this._activeRunId) {
1271
1172
  this._gapDuringRun = true;
1272
1173
  }
@@ -1274,29 +1175,25 @@ class OpenClawClient extends EventEmitter {
1274
1175
  this._lastSeq = seq;
1275
1176
  }
1276
1177
 
1277
- // --- Tick handling (step 7) ---
1278
1178
  if (evt.event === "tick") {
1279
1179
  this._lastTick = Date.now();
1280
1180
  return;
1281
1181
  }
1282
1182
 
1283
- // --- Shutdown handling (step 7) ---
1284
1183
  if (evt.event === "shutdown") {
1285
1184
  const payload = evt.payload || {};
1286
1185
  const restartMs = typeof payload.restartExpectedMs === "number" ? payload.restartExpectedMs : 5000;
1287
1186
  this._logger.info(`[openclaw] Gateway shutdown, reconnecting in ${restartMs}ms`);
1288
1187
  this.emit("status", "shutdown");
1289
- // Schedule reconnect after the expected restart delay
1188
+
1290
1189
  this._scheduleReconnect(restartMs);
1291
- // Close the WS immediately to prevent the close handler from scheduling
1292
- // a second reconnect with normal backoff (double-reconnect race).
1190
+
1293
1191
  if (this._ws) {
1294
1192
  this._ws.close(1000, "shutdown");
1295
1193
  }
1296
1194
  return;
1297
1195
  }
1298
1196
 
1299
- // --- Approval events ---
1300
1197
  if (evt.event === "exec.approval.requested") {
1301
1198
  this.emit("approval", evt.payload);
1302
1199
  return;
@@ -1317,7 +1214,6 @@ class OpenClawClient extends EventEmitter {
1317
1214
  return;
1318
1215
  }
1319
1216
 
1320
- // --- Agent events (step 5) ---
1321
1217
  if (evt.event === "agent") {
1322
1218
  const payload = evt.payload || {};
1323
1219
  const data = payload.data || {};
@@ -1330,25 +1226,10 @@ class OpenClawClient extends EventEmitter {
1330
1226
  phase: data.phase,
1331
1227
  data,
1332
1228
  });
1333
- // History-gate decouple (F18, history half): only the run-end COMMIT
1334
- // mutates the persistent message list downstream (lifecycle:end ->
1335
- // emit("message") -> conversationState.addMessage, a blind push), and the
1336
- // history hydrate is a DESTRUCTIVE replace-all (conversation-state.ts
1337
- // hydrate()). To avoid the commit being clobbered/duplicated by a later
1338
- // hydrate, the commit must still run AFTER history resolves. Every other
1339
- // agent event (lifecycle:start, assistant/streaming, tool, error) emits
1340
- // only transient/live effects (activity/streaming/error) that hydrate
1341
- // never touches, so processing them immediately is safe and removes the
1342
- // reconnect responsiveness delay. The intact commit event is queued and
1343
- // replayed via _drainEventQueue, preserving the de-dup contract exactly.
1229
+
1344
1230
  const isCommitEvent = data.phase === "end" && payload.stream === "lifecycle";
1345
1231
  if (isCommitEvent && !this._historyResolved) {
1346
- // Snapshot the commit-relevant LIVE instance state at enqueue time. The
1347
- // lifecycle:end branch reads _runTextBuffer / _activeRunId / _gapDuringRun
1348
- // (and _activeRunSessionKey as the session-key fallback) — all of which a
1349
- // LATER run's lifecycle:start (_beginActiveRun) or an _invalidateActiveRun
1350
- // mutates before the queue drains. Capturing now keeps the deferred commit
1351
- // contemporaneous with its own run, restoring pre-F18 replay semantics.
1232
+
1352
1233
  this._eventQueue.push({
1353
1234
  payload: evt.payload,
1354
1235
  capturedCommit: {
@@ -1365,12 +1246,6 @@ class OpenClawClient extends EventEmitter {
1365
1246
  }
1366
1247
  }
1367
1248
 
1368
- // --- Internal: agent streaming (step 5) ---
1369
-
1370
- /**
1371
- * Handle an agent event payload.
1372
- * Buffers assistant text deltas, emits activity/message events.
1373
- */
1374
1249
  _handleAgentEvent(payload, capturedCommit) {
1375
1250
  if (!payload) return;
1376
1251
 
@@ -1430,19 +1305,14 @@ class OpenClawClient extends EventEmitter {
1430
1305
  break;
1431
1306
 
1432
1307
  case "end": {
1433
- // Prefer the snapshot captured at history-gate enqueue time (F18 commit
1434
- // defer). When the end event is processed IMMEDIATELY (history already
1435
- // resolved), capturedCommit is undefined and live instance state is read
1436
- // exactly as before — byte-for-byte unchanged. When DEFERRED, a later
1437
- // run's start (or an invalidate) may have already clobbered these shared
1438
- // fields, so the snapshot keeps the commit contemporaneous with its run.
1308
+
1439
1309
  const committedActiveRunId = capturedCommit
1440
1310
  ? capturedCommit.activeRunId
1441
1311
  : this._activeRunId;
1442
1312
  const committedActiveRunSessionKey = capturedCommit
1443
1313
  ? capturedCommit.activeRunSessionKey
1444
1314
  : this._activeRunSessionKey;
1445
- // Assemble full response from buffered text
1315
+
1446
1316
  const fullText = capturedCommit ? capturedCommit.fullText : this._runTextBuffer;
1447
1317
  const completedRunId = normalizeRunId(committedActiveRunId) || normalizeRunId(runId);
1448
1318
  const completedSessionKey =
@@ -1451,22 +1321,10 @@ class OpenClawClient extends EventEmitter {
1451
1321
  null;
1452
1322
  const gapDuringRun = capturedCommit ? capturedCommit.gapDuringRun : this._gapDuringRun;
1453
1323
 
1454
- // Invalidate run state before emitting terminal idle so late history polls
1455
- // cannot reopen thinking for a completed run.
1456
1324
  this._timingLedger.recordRunTerminal({
1457
1325
  runId: completedRunId,
1458
1326
  });
1459
- // Reconnect-window guard (F18 successor-run protection): a DEFERRED
1460
- // commit (capturedCommit present) drains AFTER its run ended, and a
1461
- // LATER run's lifecycle:start may already have become the live active
1462
- // run (set _activeRunId, armed history polling, started buffering its
1463
- // own text) before drain. An unconditional invalidate here would tear
1464
- // down that live successor — stop its thinking-summary polling, null
1465
- // its _activeRunId, and clear its _runTextBuffer — wiping state that
1466
- // belongs to a run that has NOT ended. Only invalidate when this commit
1467
- // IS the live active run (its own run, or a non-deferred/live commit
1468
- // where the snapshot is absent). When _activeRunId is already null there
1469
- // is nothing live to protect, so skipping the invalidate is a no-op.
1327
+
1470
1328
  const commitIsLiveActiveRun =
1471
1329
  normalizeRunId(this._activeRunId) === normalizeRunId(committedActiveRunId);
1472
1330
  if (!capturedCommit || commitIsLiveActiveRun) {
@@ -1490,7 +1348,6 @@ class OpenClawClient extends EventEmitter {
1490
1348
  `[openclaw] Agent run ended: ${completedRunId} (${fullText.length} chars)`
1491
1349
  );
1492
1350
 
1493
- // If there was a gap during this run, re-fetch history (step 8)
1494
1351
  if (gapDuringRun) {
1495
1352
  this._logger.info("[openclaw] Gap detected during run, re-fetching history");
1496
1353
  this._fetchHistory(completedSessionKey || "main").catch((err) => {
@@ -1542,7 +1399,6 @@ class OpenClawClient extends EventEmitter {
1542
1399
  "assistant_event",
1543
1400
  );
1544
1401
 
1545
- // Gateway sends accumulated text (full text so far), not deltas
1546
1402
  if (typeof data.text === "string") {
1547
1403
  const previousTextLength = this._runTextBuffer.length;
1548
1404
  const gatewayReceivedAtMs = Date.now();
@@ -1602,10 +1458,7 @@ class OpenClawClient extends EventEmitter {
1602
1458
 
1603
1459
  const poll = () => {
1604
1460
  this._pollHistoryActivity().catch((err) => {
1605
- // Suppress benign transient rejections during reconnect churn: the
1606
- // no-socket "gateway not connected" case AND the new handshake-gate
1607
- // reject (handshake_pending / "gateway handshake in flight"), so the
1608
- // 1008 fix does not emit spurious poll-failed warnings in its own window.
1461
+
1609
1462
  const benignTransient =
1610
1463
  !!err &&
1611
1464
  (err.code === "handshake_pending" ||
@@ -1621,8 +1474,6 @@ class OpenClawClient extends EventEmitter {
1621
1474
  });
1622
1475
  };
1623
1476
 
1624
- // Let setInterval fire the first poll one interval out so the initial
1625
- // chat.history fetch doesn't contend with the first streaming chunk landing.
1626
1477
  this._historyActivityPollTimer = setInterval(
1627
1478
  poll,
1628
1479
  HISTORY_ACTIVITY_POLL_INTERVAL_MS,
@@ -1771,8 +1622,6 @@ class OpenClawClient extends EventEmitter {
1771
1622
  });
1772
1623
  }
1773
1624
 
1774
- // --- Internal: handshake ---
1775
-
1776
1625
  _sendConnect() {
1777
1626
  if (this._connectSent) return;
1778
1627
  this._connectSent = true;
@@ -1788,14 +1637,12 @@ class OpenClawClient extends EventEmitter {
1788
1637
  return;
1789
1638
  }
1790
1639
 
1791
- // Choose auth token: prefer cached device token, fall back to gateway token
1792
1640
  const authToken = this._deviceToken || this._gatewayToken || undefined;
1793
1641
  const canFallback = Boolean(this._deviceToken && this._gatewayToken);
1794
1642
 
1795
1643
  const signedAtMs = Date.now();
1796
1644
  const nonce = this._connectNonce || undefined;
1797
1645
 
1798
- // Build device auth payload
1799
1646
  const payload = buildDeviceAuthPayload({
1800
1647
  deviceId: identity.deviceId,
1801
1648
  clientId: CLIENT_ID,
@@ -1807,10 +1654,8 @@ class OpenClawClient extends EventEmitter {
1807
1654
  nonce,
1808
1655
  });
1809
1656
 
1810
- // Sign with Ed25519 private key
1811
1657
  const signature = signPayload(identity.privateKeyPem, payload);
1812
1658
 
1813
- // Build connect request params
1814
1659
  const params = {
1815
1660
  minProtocol: MIN_PROTOCOL_VERSION,
1816
1661
  maxProtocol: MAX_PROTOCOL_VERSION,
@@ -1835,10 +1680,6 @@ class OpenClawClient extends EventEmitter {
1835
1680
 
1836
1681
  this._logger.info("[openclaw] Sending connect request...");
1837
1682
 
1838
- // Capture the generation this connect is being sent for. The resolve below
1839
- // must only mark THIS generation handshaken — if a disconnect+reconnect
1840
- // bumped _socketGeneration before the connect resolved, this resolve is
1841
- // stale and must NOT open the gate for the new in-flight socket.
1842
1683
  const connectGeneration = this._socketGeneration;
1843
1684
 
1844
1685
  this.request("connect", params)
@@ -1848,19 +1689,13 @@ class OpenClawClient extends EventEmitter {
1848
1689
  `tick=${helloOk.policy && helloOk.policy.tickIntervalMs}ms`
1849
1690
  );
1850
1691
 
1851
- // Reset backoff on successful connect (step 7)
1852
1692
  this._backoffMs = 1000;
1853
1693
 
1854
- // Cache tick interval
1855
- if (helloOk.policy && typeof helloOk.policy.tickIntervalMs === "number") {
1856
- this._tickIntervalMs = helloOk.policy.tickIntervalMs;
1857
- }
1694
+ this._applyConnectPolicy(helloOk.policy);
1858
1695
 
1859
- // Start tick watch (step 7)
1860
1696
  this._lastTick = Date.now();
1861
1697
  this._startTickWatch();
1862
1698
 
1863
- // Cache device token if provided
1864
1699
  if (helloOk.auth && helloOk.auth.deviceToken) {
1865
1700
  this._deviceToken = helloOk.auth.deviceToken;
1866
1701
  storeDeviceToken(
@@ -1873,12 +1708,6 @@ class OpenClawClient extends EventEmitter {
1873
1708
  this._logger.info("[openclaw] Device token cached");
1874
1709
  }
1875
1710
 
1876
- // Open the handshake gate for THIS socket's generation BEFORE emitting
1877
- // "connected" (so refreshUpstreamBootstrap, fired on the connected event,
1878
- // sees an open gate). Guard against a stale resolve: if a reconnect
1879
- // already superseded this socket, _socketGeneration has advanced past
1880
- // connectGeneration and we must NOT mark the new in-flight socket
1881
- // handshaken — that would re-admit the very 1008 race this fix closes.
1882
1711
  if (connectGeneration === this._socketGeneration) {
1883
1712
  this._handshakeGeneration = connectGeneration;
1884
1713
  }
@@ -1889,7 +1718,6 @@ class OpenClawClient extends EventEmitter {
1889
1718
  });
1890
1719
  this.emit("status", "connected");
1891
1720
 
1892
- // Post-connect: fetch agent identity (step 6) and chat history (step 9)
1893
1721
  this._postConnect().catch((err) => {
1894
1722
  this._logger.error(`[openclaw] Post-connect setup failed: ${err.message}`);
1895
1723
  this.emit("error", err);
@@ -1898,7 +1726,6 @@ class OpenClawClient extends EventEmitter {
1898
1726
  .catch((err) => {
1899
1727
  this._logger.error(`[openclaw] Connect failed: ${err.message}`);
1900
1728
 
1901
- // If we were using a cached device token and have a fallback, clear and retry
1902
1729
  if (canFallback) {
1903
1730
  this._logger.info(
1904
1731
  "[openclaw] Clearing cached device token, will use gateway token on next connect"
@@ -1907,6 +1734,11 @@ class OpenClawClient extends EventEmitter {
1907
1734
  clearDeviceToken(this._persistencePaths);
1908
1735
  }
1909
1736
 
1737
+ this.emit("connectFailed", {
1738
+ reason: sanitizeConnectReason(err && err.message),
1739
+ minProtocol: MIN_PROTOCOL_VERSION,
1740
+ maxProtocol: MAX_PROTOCOL_VERSION,
1741
+ });
1910
1742
  this.emit("error", err);
1911
1743
  if (this._ws) {
1912
1744
  this._ws.close(1008, "connect failed");
@@ -1914,32 +1746,22 @@ class OpenClawClient extends EventEmitter {
1914
1746
  });
1915
1747
  }
1916
1748
 
1917
- // --- Internal: post-connect setup (steps 6, 9) ---
1918
-
1919
1749
  async _postConnect() {
1920
- // Fetch agent identity (step 6) — non-blocking, don't gate on this
1750
+
1921
1751
  this.fetchAgentIdentity().catch((err) => {
1922
1752
  this._logger.error(`[openclaw] Agent identity fetch failed: ${err.message}`);
1923
1753
  });
1924
1754
 
1925
- // Fetch chat history (step 9) — blocks agent event processing until done
1926
1755
  try {
1927
1756
  await this._fetchHistory("main");
1928
1757
  } catch (err) {
1929
1758
  this._logger.error(`[openclaw] Chat history fetch failed: ${err.message}`);
1930
1759
  }
1931
1760
 
1932
- // Mark history as resolved and drain queued events
1933
1761
  this._historyResolved = true;
1934
1762
  this._drainEventQueue();
1935
1763
  }
1936
1764
 
1937
- // --- Internal: chat history (step 9) ---
1938
-
1939
- /**
1940
- * Fetch chat history from the gateway.
1941
- * @param {string} sessionKey
1942
- */
1943
1765
  async _fetchHistory(sessionKey) {
1944
1766
  const result = await this.request("chat.history", {
1945
1767
  sessionKey,
@@ -1957,9 +1779,6 @@ class OpenClawClient extends EventEmitter {
1957
1779
  return result;
1958
1780
  }
1959
1781
 
1960
- /**
1961
- * Drain queued agent events that arrived before history resolved.
1962
- */
1963
1782
  _drainEventQueue() {
1964
1783
  const queue = this._eventQueue;
1965
1784
  this._eventQueue = [];
@@ -1968,28 +1787,15 @@ class OpenClawClient extends EventEmitter {
1968
1787
  }
1969
1788
  }
1970
1789
 
1971
- // --- Internal: reconnection (step 7) ---
1972
-
1973
- /**
1974
- * Schedule a reconnect attempt with exponential backoff.
1975
- * @param {number} [delayOverride] - Override the backoff delay (e.g., for shutdown events)
1976
- */
1977
1790
  _scheduleReconnect(delayOverride) {
1978
1791
  if (this._stopped) return;
1979
1792
 
1980
- // Capture the current base before advancing so jitter is computed from the
1981
- // same epoch value that would previously have been used as the raw delay.
1982
1793
  const base = this._backoffMs;
1983
1794
 
1984
- // Advance backoff for next time (unless overridden)
1985
1795
  if (typeof delayOverride !== "number") {
1986
1796
  this._backoffMs = Math.min(this._backoffMs * 2, 30000);
1987
1797
  }
1988
1798
 
1989
- // Apply equal jitter to the scheduled delay so concurrent reconnect storms
1990
- // don't all fire at the same instant. The stored _backoffMs base is NOT
1991
- // mutated — doubling/cap/reset invariants are preserved.
1992
- // delayOverride bypasses jitter (used for e.g. shutdown-driven reconnects).
1993
1799
  const delay =
1994
1800
  typeof delayOverride === "number"
1995
1801
  ? delayOverride
@@ -2006,26 +1812,26 @@ class OpenClawClient extends EventEmitter {
2006
1812
  this._reconnectTimer = null;
2007
1813
  this.start();
2008
1814
  }, delay);
2009
- // Don't prevent process exit while waiting to reconnect
1815
+
2010
1816
  if (this._reconnectTimer.unref) {
2011
1817
  this._reconnectTimer.unref();
2012
1818
  }
2013
1819
  }
2014
1820
 
2015
- // --- Internal: tick watch (step 7) ---
1821
+ _applyConnectPolicy(policy) {
1822
+ if (policy && typeof policy.tickIntervalMs === "number") {
1823
+ this._tickIntervalMs = Math.min(policy.tickIntervalMs, TICK_WATCH_MAX_INTERVAL_MS);
1824
+ }
1825
+ }
2016
1826
 
2017
- /**
2018
- * Start watching for stale connections via tick timeout.
2019
- * If no tick received within 2x tickIntervalMs, close and reconnect.
2020
- */
2021
1827
  _startTickWatch() {
2022
1828
  this._stopTickWatch();
2023
- const interval = Math.max(this._tickIntervalMs, 1000);
1829
+ const pollMs = Math.max(Math.floor(this._tickIntervalMs / 4), 1000);
2024
1830
  this._tickWatchTimer = setInterval(() => {
2025
1831
  if (this._stopped) return;
2026
1832
  if (!this._lastTick) return;
2027
1833
  const elapsed = Date.now() - this._lastTick;
2028
- if (elapsed > this._tickIntervalMs * 2) {
1834
+ if (elapsed > this._tickIntervalMs * TICK_STALE_MULTIPLIER) {
2029
1835
  this._logger.warn(
2030
1836
  `[openclaw] Tick timeout (${elapsed}ms since last tick), closing connection`
2031
1837
  );
@@ -2033,16 +1839,13 @@ class OpenClawClient extends EventEmitter {
2033
1839
  this._ws.close(4000, "tick timeout");
2034
1840
  }
2035
1841
  }
2036
- }, interval);
2037
- // Don't prevent process exit
1842
+ }, pollMs);
1843
+
2038
1844
  if (this._tickWatchTimer.unref) {
2039
1845
  this._tickWatchTimer.unref();
2040
1846
  }
2041
1847
  }
2042
1848
 
2043
- /**
2044
- * Stop the tick watch timer.
2045
- */
2046
1849
  _stopTickWatch() {
2047
1850
  if (this._tickWatchTimer) {
2048
1851
  clearInterval(this._tickWatchTimer);
@@ -2050,12 +1853,9 @@ class OpenClawClient extends EventEmitter {
2050
1853
  }
2051
1854
  }
2052
1855
 
2053
- // --- Internal: helpers ---
2054
-
2055
1856
  _flushPendingErrors(err) {
2056
1857
  for (const [, pending] of this._pending) {
2057
- // Clear the per-request ACK timeout so a flushed entry cannot fire a
2058
- // late reject after the map is cleared.
1858
+
2059
1859
  if (pending.timer) {
2060
1860
  clearTimeout(pending.timer);
2061
1861
  pending.timer = null;