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
@@ -38,13 +38,6 @@ function resolveOpenclawClient(openclawClientOverride, runtimeConfig, logger, st
38
38
  });
39
39
  }
40
40
 
41
- // Shared cross-context relay singleton. OpenClaw can load the plugin in
42
- // multiple isolated registration contexts (gateway startup, agent runs,
43
- // tool discovery) and each call to createOcuClawRelayService produces a
44
- // fresh service instance. Only one of those instances actually starts the
45
- // WebSocket-bearing relay; the others need to reach the same relay so that
46
- // tools registered in those contexts (e.g. render_glasses_ui) can call
47
- // sendGlassesUiRender / onGlassesUiResult against the real running relay.
48
41
  const SHARED_RELAY_SYMBOL = Symbol.for("ocuclaw.shared.relay");
49
42
 
50
43
  function getSharedRelay() {
@@ -110,9 +103,13 @@ export function createOcuClawRelayService(opts = {}) {
110
103
  token: config.relayToken,
111
104
  sessionLimit: config.sessionLimit,
112
105
  sonioxApiKey: config.sonioxApiKey,
113
- debugPayloadMaxBytes: config.debugPayloadMaxBytes,
106
+ cartesiaApiKey: config.cartesiaApiKey,
114
107
  debugNoisyPolicies: config.debugNoisyPolicies,
115
108
  externalDebugToolsEnabled: config.externalDebugToolsEnabled,
109
+ allowDebugUpload: config.allowDebugUpload,
110
+ debugUploadMaxZipBytes: config.debugUploadMaxZipBytes,
111
+ debugUploadCapturePreset: config.debugUploadCapturePreset,
112
+ debugBundleSaveDir: config.debugBundleSaveDir,
116
113
  evenAiEnabled: config.evenAiEnabled,
117
114
  evenAiToken: config.evenAiToken,
118
115
  evenAiSystemPrompt: config.evenAiSystemPrompt,
@@ -145,10 +142,7 @@ export function createOcuClawRelayService(opts = {}) {
145
142
  }
146
143
  }
147
144
  if (typeof nextRelay.onDeviceInfoResponse === "function" && pendingDeviceInfoResponseHandlers.length > 0) {
148
- // splice(0) atomically drains the buffer so the unsubscribe returned to
149
- // pre-start() callers becomes a safe no-op (it indexOf-s an empty list).
150
- // After flush, handler lifetime is owned by the live relay's own
151
- // unsubscribe; the pending buffer is no longer the source of truth.
145
+
152
146
  for (const handler of pendingDeviceInfoResponseHandlers.splice(0)) {
153
147
  nextRelay.onDeviceInfoResponse(handler);
154
148
  }
@@ -192,11 +186,7 @@ export function createOcuClawRelayService(opts = {}) {
192
186
  }
193
187
 
194
188
  function resolveLiveRelay() {
195
- // Prefer the local-instance relay (started in this context); fall back to
196
- // the cross-context shared relay (started in a sibling plugin-load context
197
- // such as the gateway main process). This matters because OpenClaw can
198
- // invoke a plugin's register() in multiple isolated contexts, but only
199
- // one of them holds the running WebSocket relay.
189
+
200
190
  return relay || getSharedRelay();
201
191
  }
202
192
  return {
@@ -218,9 +208,7 @@ export function createOcuClawRelayService(opts = {}) {
218
208
  }
219
209
  liveRelay.sendGlassesUiSurfaceUpdate(params);
220
210
  },
221
- // Permanent glasses.lifecycle debug category passthrough (nav reconcile +
222
- // cron pause/resume/tick). No-op until the relay is live; events are only
223
- // recorded when the category is enabled via debug-set.
211
+
224
212
  emitGlassesUiLifecycle(event, severity, data) {
225
213
  const liveRelay = resolveLiveRelay();
226
214
  if (liveRelay && typeof liveRelay.emitGlassesUiLifecycle === "function") {
@@ -235,7 +223,7 @@ export function createOcuClawRelayService(opts = {}) {
235
223
  if (liveRelay && typeof liveRelay.onGlassesUiResult === "function") {
236
224
  return liveRelay.onGlassesUiResult(handler);
237
225
  }
238
- // Relay not started yet — buffer until start() can register the handler.
226
+
239
227
  pendingGlassesUiResultHandlers.push(handler);
240
228
  return () => {
241
229
  const idx = pendingGlassesUiResultHandlers.indexOf(handler);
@@ -260,7 +248,7 @@ export function createOcuClawRelayService(opts = {}) {
260
248
  if (liveRelay && typeof liveRelay.onAppClientDisconnect === "function") {
261
249
  return liveRelay.onAppClientDisconnect(handler);
262
250
  }
263
- // Relay not started yet — buffer until start() can register the handler.
251
+
264
252
  pendingAppClientDisconnectHandlers.push(handler);
265
253
  return () => {
266
254
  const idx = pendingAppClientDisconnectHandlers.indexOf(handler);
@@ -282,7 +270,7 @@ export function createOcuClawRelayService(opts = {}) {
282
270
  if (liveRelay && typeof liveRelay.onDeviceInfoResponse === "function") {
283
271
  return liveRelay.onDeviceInfoResponse(handler);
284
272
  }
285
- // Relay not started yet — buffer until start() can register the handler.
273
+
286
274
  pendingDeviceInfoResponseHandlers.push(handler);
287
275
  return () => {
288
276
  const idx = pendingDeviceInfoResponseHandlers.indexOf(handler);
@@ -303,12 +291,7 @@ export function createOcuClawRelayService(opts = {}) {
303
291
  trackedThrowawayKeys: [],
304
292
  };
305
293
  },
306
- // These reads back the session-title tool, the distiller gates, and the
307
- // Channel-2 before_prompt_build hook — all of which can run in a sibling
308
- // plugin-register context whose own `relay` is null. Resolve the live
309
- // (possibly shared) relay so they reach the running instance, mirroring
310
- // hasConnectedAppClient(); otherwise the distiller skips every run and the
311
- // Channel-2 stop-notices never fire.
294
+
312
295
  getSessionTitle(sessionKey) {
313
296
  const liveRelay = resolveLiveRelay();
314
297
  if (liveRelay && typeof liveRelay.getSessionTitle === "function") {
@@ -321,7 +304,7 @@ export function createOcuClawRelayService(opts = {}) {
321
304
  if (liveRelay && typeof liveRelay.hasRecordedUserMessage === "function") {
322
305
  return liveRelay.hasRecordedUserMessage(sessionKey);
323
306
  }
324
- // Fail-closed when no relay is running: block titling.
307
+
325
308
  return false;
326
309
  },
327
310
  isNeuralSessionNamesEnabled(sessionKey) {
@@ -352,9 +335,7 @@ export function createOcuClawRelayService(opts = {}) {
352
335
  }
353
336
  return { emoji: false, pace: false };
354
337
  },
355
- // Distiller-sidecar passthroughs. Use the live relay (possibly a sibling
356
- // register-context's shared relay) since the distiller may fire from a
357
- // context whose own `relay` is null.
338
+
358
339
  getSessionTitleRecord(sessionKey) {
359
340
  const liveRelay = resolveLiveRelay();
360
341
  if (liveRelay && typeof liveRelay.getSessionTitleRecord === "function") {
@@ -419,9 +400,7 @@ export function createOcuClawRelayService(opts = {}) {
419
400
  return () => {};
420
401
  },
421
402
  peekSessionKey() {
422
- // The set_session_title tool reads this before writing; like the adjacent
423
- // session-title accessors it must reach the live (possibly shared) relay,
424
- // or an explicit rename from a sibling tool context fails no_active_session.
403
+
425
404
  const liveRelay = resolveLiveRelay();
426
405
  if (liveRelay && typeof liveRelay.peekSessionKey === "function") {
427
406
  return liveRelay.peekSessionKey();
@@ -447,6 +426,31 @@ export function createOcuClawRelayService(opts = {}) {
447
426
  }
448
427
  return false;
449
428
  },
429
+ isGlassesSendBufferOverHighWater() {
430
+
431
+ const liveRelay = resolveLiveRelay();
432
+ if (liveRelay && typeof liveRelay.isGlassesSendBufferOverHighWater === "function") {
433
+ return liveRelay.isGlassesSendBufferOverHighWater();
434
+ }
435
+ return false;
436
+ },
437
+
438
+ dispatchGlassesWake(params) {
439
+ const liveRelay = resolveLiveRelay();
440
+ if (!liveRelay || typeof liveRelay.dispatchGlassesWake !== "function") {
441
+
442
+ return Promise.reject(new Error("ocuclaw relay not started"));
443
+ }
444
+ return liveRelay.dispatchGlassesWake(params);
445
+ },
446
+ isAgentTurnBusy(sessionKey) {
447
+ const liveRelay = resolveLiveRelay();
448
+ if (liveRelay && typeof liveRelay.isAgentTurnBusy === "function") {
449
+ return liveRelay.isAgentTurnBusy(sessionKey);
450
+ }
451
+
452
+ return false;
453
+ },
450
454
  start,
451
455
  stop,
452
456
  };
@@ -10,7 +10,7 @@ export function createApprovalReplayCache(options = {}) {
10
10
  const now = typeof options.now === "function" ? options.now : () => Date.now();
11
11
  const ttlMs = normalizeNonNegativeInteger(options.ttlMs, DEFAULT_TTL_MS);
12
12
  const maxEntries = normalizeNonNegativeInteger(options.maxEntries, DEFAULT_MAX_ENTRIES);
13
- const entries = new Map(); // id -> { frame, cachedAtMs, frameExpiresAtMs }
13
+ const entries = new Map();
14
14
 
15
15
  function nowMs() {
16
16
  return normalizeNonNegativeInteger(now(), Date.now());
@@ -27,8 +27,7 @@ const transport = createRelayWorkerTransport({
27
27
  postToMain(message) {
28
28
  parentPort.postMessage(message);
29
29
  },
30
- // Worker-thread console output never reaches the gateway's log file;
31
- // forward log lines to the supervisor, which owns the real plugin logger.
30
+
32
31
  logger: {
33
32
  info(...args) { postWorkerLog("info", args); },
34
33
  warn(...args) { postWorkerLog("warn", args); },
@@ -167,8 +167,7 @@ export function createRelayWorkerHealthMonitor(options = {}) {
167
167
  return true;
168
168
  }
169
169
  if (loopLagP95Ms !== null) {
170
- // Hysteresis: enter at the degraded threshold; once degraded, stay degraded
171
- // until lag drops below the (lower) recovered threshold.
170
+
172
171
  const enterThreshold =
173
172
  status === "degraded"
174
173
  ? thresholds.loopLagRecoveredP95Ms
@@ -179,14 +178,7 @@ export function createRelayWorkerHealthMonitor(options = {}) {
179
178
  }
180
179
 
181
180
  function computeStatus() {
182
- // Precedence is deliberate: `degraded` (worker strain) is reported before
183
- // `main_disconnected`. On a live worker the two cannot co-occur — live
184
- // message.send queue depth tops at 31 (enqueue rejects at the cap before set)
185
- // and `main_disconnected` is only the pre-first-heartbeat boot state — so this
186
- // order is never observably wrong. Any future reorder is conditional on BOTH a
187
- // product-copy reason to prefer a "waiting" label during a disconnect AND a
188
- // degraded condition becoming live-reachable, and must ship with a degraded
189
- // secondary-text line + distinct badge in the phone UI.
181
+
190
182
  if (isDegraded()) {
191
183
  return "degraded";
192
184
  }
@@ -61,6 +61,8 @@ export const DEFAULT_WORKER_RPC_LIMITS = Object.freeze({
61
61
  httpRequestTimeoutMs: 60_000,
62
62
  httpMaxBodyBytes: 65_536,
63
63
  httpMaxResponseBytes: 262_144,
64
+
65
+ wsMaxMessageBytes: 25 * 1024 * 1024,
64
66
  });
65
67
 
66
68
  const ALLOWED_WORKER_STATUSES = new Set([
@@ -196,11 +198,14 @@ export function formatMainOperationReceived(data) {
196
198
  });
197
199
  }
198
200
 
199
- export function formatSendAck(requestId, status, error, errorCode) {
201
+ export function formatSendAck(requestId, status, error, errorCode, data = {}) {
200
202
  const id = normalizeRequestId(requestId);
201
203
  const msg = { type: APP_PROTOCOL.messageSendAck, requestId: id, status };
202
204
  if (error !== undefined) msg.error = error;
203
205
  if (errorCode !== undefined) msg.errorCode = errorCode;
206
+ if (data && typeof data.runId === "string" && data.runId.trim()) {
207
+ msg.runId = data.runId.trim();
208
+ }
204
209
  return JSON.stringify(msg);
205
210
  }
206
211
 
@@ -109,6 +109,9 @@ export function createRelayWorkerSupervisor(options = {}) {
109
109
  let closing = false;
110
110
  let activeOperationBarrier = null;
111
111
  let mainHeartbeatTimer = null;
112
+ let restartTimer = null;
113
+ let restartAttempt = 0;
114
+ let workerReadyWatchdog = null;
112
115
  const clients = new Map();
113
116
  const pendingReadinessProbeRequests = new Map();
114
117
  const pendingAutomationStateRequests = new Map();
@@ -133,6 +136,8 @@ export function createRelayWorkerSupervisor(options = {}) {
133
136
  resolveReady = resolve;
134
137
  rejectReady = reject;
135
138
  });
139
+
140
+ readyPromise.catch(() => {});
136
141
  startPromise = readyPromise;
137
142
  }
138
143
 
@@ -181,6 +186,57 @@ export function createRelayWorkerSupervisor(options = {}) {
181
186
  if (typeof mainHeartbeatTimer.unref === "function") mainHeartbeatTimer.unref();
182
187
  }
183
188
 
189
+ function workerRestartBackoffBaseMs() {
190
+ return Number.isFinite(options.workerRestartBackoffBaseMs)
191
+ ? Math.max(0, Math.floor(options.workerRestartBackoffBaseMs))
192
+ : 250;
193
+ }
194
+
195
+ function workerRestartBackoffMaxMs() {
196
+ return Number.isFinite(options.workerRestartBackoffMaxMs)
197
+ ? Math.max(0, Math.floor(options.workerRestartBackoffMaxMs))
198
+ : 30000;
199
+ }
200
+
201
+ function workerReadyWatchdogMs() {
202
+
203
+ return Number.isFinite(options.workerReadyWatchdogMs) && options.workerReadyWatchdogMs > 0
204
+ ? Math.floor(options.workerReadyWatchdogMs)
205
+ : 10000;
206
+ }
207
+
208
+ function clearWorkerReadyWatchdog() {
209
+ if (workerReadyWatchdog) {
210
+ clearTimeout(workerReadyWatchdog);
211
+ workerReadyWatchdog = null;
212
+ }
213
+ }
214
+
215
+ function scheduleWorkerRestart() {
216
+ if (restartTimer || closing) return;
217
+ const base = Math.min(
218
+ workerRestartBackoffMaxMs(),
219
+ workerRestartBackoffBaseMs() * 2 ** Math.min(restartAttempt, 7),
220
+ );
221
+ const delay = base / 2 + Math.random() * (base / 2);
222
+ restartAttempt += 1;
223
+ if (typeof options.emitDebug === "function") {
224
+ options.emitDebug(
225
+ "relay.worker.health",
226
+ "worker_restart_backoff",
227
+ "warn",
228
+ null,
229
+ () => ({ attempt: restartAttempt, delayMs: Math.round(delay) }),
230
+ );
231
+ }
232
+ restartTimer = setTimeout(() => {
233
+ restartTimer = null;
234
+ if (closing) return;
235
+ startWorker();
236
+ }, delay);
237
+ if (typeof restartTimer.unref === "function") restartTimer.unref();
238
+ }
239
+
184
240
  function buildManifest() {
185
241
  workerEpoch += 1;
186
242
  return {
@@ -307,9 +363,7 @@ export function createRelayWorkerSupervisor(options = {}) {
307
363
 
308
364
  function postMainFrame(target, frame, clientId) {
309
365
  if (typeof frame !== "string") return;
310
- // audit #19: parse the frame once. parseMessageType is itself a full JSON.parse,
311
- // so deriving type from a single parseFrame avoids a second parse on the hot
312
- // pages/status path below (and is neutral — still one parse — on other frames).
366
+
313
367
  const parsed = parseFrame(frame);
314
368
  const type = parsed && typeof parsed.type === "string" ? parsed.type : null;
315
369
  const message = {
@@ -329,16 +383,7 @@ export function createRelayWorkerSupervisor(options = {}) {
329
383
  : { statusRevision: revision };
330
384
  }
331
385
  }
332
- // F17: requestId-scope the operation barrier. The barrier exists to
333
- // preserve send-ordering in the worker path, but it used to divert EVERY
334
- // broadcast/broadcastApp frame for the whole send→ack window — stalling
335
- // unrelated live broadcasts (streaming deltas / activity / typing / status
336
- // from a prior, still-streaming turn) until flush. Only hold a broadcast
337
- // that is causally tied to the in-flight barrier.requestId; let everything
338
- // else pass straight through. The send's own operation-received and ack are
339
- // unicast (never diverted) and the op-received is posted synchronously
340
- // ahead of the async ack, so "operation-received precedes that op's ack"
341
- // holds independent of this barrier.
386
+
342
387
  if (
343
388
  activeOperationBarrier &&
344
389
  (target === "broadcast" || target === "broadcastApp") &&
@@ -506,6 +551,8 @@ export function createRelayWorkerSupervisor(options = {}) {
506
551
  function handleMessage(message) {
507
552
  if (!message || typeof message !== "object") return;
508
553
  if (message.kind === "worker.ready") {
554
+ restartAttempt = 0;
555
+ clearWorkerReadyWatchdog();
509
556
  addressValue = message.address || null;
510
557
  wssEvents.emit("listening");
511
558
  if (resolveReady) {
@@ -517,11 +564,14 @@ export function createRelayWorkerSupervisor(options = {}) {
517
564
  }
518
565
  if (message.kind === "worker.error") {
519
566
  logger.warn(`[relay-worker] ${message.message || "worker error"}`);
567
+
568
+ if (rejectReady && worker && typeof worker.terminate === "function") {
569
+ worker.terminate();
570
+ }
520
571
  return;
521
572
  }
522
573
  if (message.kind === "worker.log") {
523
- // Worker-thread console output never reaches the gateway's structured
524
- // log file, so the worker forwards its log lines here instead.
574
+
525
575
  const level =
526
576
  message.level === "warn" || message.level === "error" || message.level === "debug"
527
577
  ? message.level
@@ -649,11 +699,7 @@ export function createRelayWorkerSupervisor(options = {}) {
649
699
  disconnectedEntry.clientKind === "app" &&
650
700
  typeof options.onAppClientDisconnect === "function"
651
701
  ) {
652
- // Drain a session's live-refresh crons only when THAT session's last
653
- // app client disconnects (round-2: a same-session duplicate keeps it
654
- // live; round-6: per-session so A drains independently of B). The
655
- // disconnecting client is already removed from `clients`, so the
656
- // excludeClientId is belt-and-suspenders.
702
+
657
703
  const drainSessionKey =
658
704
  typeof disconnectedEntry.sessionKey === "string" && disconnectedEntry.sessionKey
659
705
  ? disconnectedEntry.sessionKey
@@ -705,10 +751,7 @@ export function createRelayWorkerSupervisor(options = {}) {
705
751
  const pending = requestId ? pendingReadinessProbeRequests.get(requestId) : null;
706
752
  if (!pending || pending.targetClientId !== message.clientId) return;
707
753
  pendingReadinessProbeRequests.delete(requestId);
708
- // Probe acks carry the app's CURRENT activeSessionKey — ground truth.
709
- // Refresh the registry entry with it: the app's standalone snapshot
710
- // republication can race a reconnecting socket and get lost, leaving the
711
- // hello-frozen key here until the next app boot (quirk 8's second layer).
754
+
712
755
  if (ack && ack.ok !== false && typeof ack.activeSessionKey === "string" && ack.activeSessionKey) {
713
756
  const ackEntry = clients.get(message.clientId);
714
757
  if (ackEntry && ackEntry.readinessSnapshot) {
@@ -777,6 +820,13 @@ export function createRelayWorkerSupervisor(options = {}) {
777
820
  postMainFrame("unicast", frame, pending.requesterClientId);
778
821
  return;
779
822
  }
823
+ if (message.kind === "worker.backpressure") {
824
+
825
+ if (typeof options.onWorkerBackpressure === "function") {
826
+ options.onWorkerBackpressure(message);
827
+ }
828
+ return;
829
+ }
780
830
  if (message.kind === "debug" && typeof options.emitDebug === "function") {
781
831
  options.emitDebug(
782
832
  message.category || "relay.worker.health",
@@ -802,20 +852,21 @@ export function createRelayWorkerSupervisor(options = {}) {
802
852
  nextWorker.on("message", handleMessage);
803
853
  nextWorker.on("error", (err) => {
804
854
  logger.error(`[relay-worker] worker error: ${err && err.message ? err.message : err}`);
805
- if (rejectReady) rejectReady(err);
855
+
806
856
  wssEvents.emit("error", err);
807
857
  });
808
858
  const startedWorker = nextWorker;
809
859
  nextWorker.on("exit", (code) => {
810
- const unexpected = !closing;
811
- if (worker === startedWorker) worker = null;
860
+
861
+ const wasActive = worker === startedWorker;
862
+ if (!wasActive) return;
863
+ worker = null;
812
864
  stopMainHeartbeat();
813
- if (unexpected) {
865
+ clearWorkerReadyWatchdog();
866
+ if (!closing) {
814
867
  const err = new Error(`relay worker exited with code ${code}`);
815
868
  logger.warn(`[relay-worker] ${err.message}`);
816
- if (rejectReady) rejectReady(err);
817
- resolveReady = null;
818
- rejectReady = null;
869
+ const wasPreReady = Boolean(rejectReady);
819
870
  addressValue = null;
820
871
  clients.clear();
821
872
  pendingReadinessProbeRequests.clear();
@@ -823,18 +874,43 @@ export function createRelayWorkerSupervisor(options = {}) {
823
874
  if (wssEvents.listenerCount("error") > 0) {
824
875
  wssEvents.emit("error", err);
825
876
  }
826
- resetReadyPromise();
827
- startWorker();
877
+
878
+ if (!wasPreReady) {
879
+ resetReadyPromise();
880
+ }
881
+ scheduleWorkerRestart();
828
882
  }
829
883
  });
830
884
  const manifest = buildManifest();
831
885
  postToWorker(manifest);
832
886
  startMainHeartbeat(manifest.workerEpoch);
887
+
888
+ clearWorkerReadyWatchdog();
889
+ workerReadyWatchdog = setTimeout(() => {
890
+ workerReadyWatchdog = null;
891
+ if (closing || worker !== startedWorker) return;
892
+ logger.warn(
893
+ `[relay-worker] worker did not report ready within ${workerReadyWatchdogMs()}ms; terminating`,
894
+ );
895
+ if (typeof startedWorker.terminate === "function") startedWorker.terminate();
896
+ }, workerReadyWatchdogMs());
897
+ if (typeof workerReadyWatchdog.unref === "function") workerReadyWatchdog.unref();
833
898
  return nextWorker;
834
899
  }
835
900
 
836
901
  function close() {
837
902
  closing = true;
903
+
904
+ if (rejectReady) {
905
+ rejectReady(new Error("relay worker supervisor closed before ready"));
906
+ resolveReady = null;
907
+ rejectReady = null;
908
+ }
909
+ if (restartTimer) {
910
+ clearTimeout(restartTimer);
911
+ restartTimer = null;
912
+ }
913
+ clearWorkerReadyWatchdog();
838
914
  stopMainHeartbeat();
839
915
  if (!worker) {
840
916
  startPromise = null;
@@ -936,12 +1012,6 @@ export function createRelayWorkerSupervisor(options = {}) {
936
1012
  });
937
1013
  }
938
1014
 
939
- // The WebUI's reconnect hello embeds its readiness snapshot WITHOUT
940
- // emittedAtMs (toProtocolHelloReadinessSnapshotJson omits it), but the
941
- // automation-state gate requires a finite emittedAtMs to count the client
942
- // as published. Stamp ingest time so a reconnect hello counts as a
943
- // publication — otherwise every relay restart leaves `inspect state`
944
- // returning snapshot_unavailable until the sim/app client is cycled.
945
1015
  function normalizeIngestedReadinessSnapshot(snapshot) {
946
1016
  if (!snapshot || typeof snapshot !== "object") {
947
1017
  return null;