ocuclaw 1.2.4 → 1.3.0

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 (59) hide show
  1. package/README.md +18 -5
  2. package/dist/config/runtime-config.js +81 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +38 -182
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +754 -83
  27. package/dist/runtime/downstream-server.js +700 -534
  28. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  29. package/dist/runtime/plugin-update-service.js +216 -0
  30. package/dist/runtime/protocol-adapter.js +9 -0
  31. package/dist/runtime/provider-usage-select.js +168 -0
  32. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  33. package/dist/runtime/relay-core.js +1209 -204
  34. package/dist/runtime/relay-health-monitor.js +172 -0
  35. package/dist/runtime/relay-operation-registry.js +263 -0
  36. package/dist/runtime/relay-service.js +201 -1
  37. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  38. package/dist/runtime/relay-worker-entry.js +32 -0
  39. package/dist/runtime/relay-worker-health.js +272 -0
  40. package/dist/runtime/relay-worker-protocol.js +285 -0
  41. package/dist/runtime/relay-worker-queue.js +202 -0
  42. package/dist/runtime/relay-worker-supervisor.js +1081 -0
  43. package/dist/runtime/relay-worker-transport.js +1051 -0
  44. package/dist/runtime/session-context-service.js +189 -0
  45. package/dist/runtime/session-service.js +615 -24
  46. package/dist/runtime/upstream-runtime.js +1167 -60
  47. package/dist/tools/device-info-tool.js +242 -0
  48. package/dist/tools/glasses-ui-cron.js +427 -0
  49. package/dist/tools/glasses-ui-descriptors.js +261 -0
  50. package/dist/tools/glasses-ui-limits.js +21 -0
  51. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  52. package/dist/tools/glasses-ui-recipes.js +746 -0
  53. package/dist/tools/glasses-ui-surfaces.js +278 -0
  54. package/dist/tools/glasses-ui-template.js +182 -0
  55. package/dist/tools/glasses-ui-tool.js +1147 -0
  56. package/dist/tools/session-title-tool.js +209 -0
  57. package/dist/version.js +2 -0
  58. package/openclaw.plugin.json +163 -15
  59. package/package.json +12 -4
@@ -1,4 +1,6 @@
1
1
  import * as WebSocketModule from "ws";
2
+ import { createRelayClientNudgeController } from "./relay-client-nudge-controller.js";
3
+ import * as RelayWorkerProtocol from "./relay-worker-protocol.js";
2
4
 
3
5
  // `ws` exposes different server entrypoints across ESM/CJS consumers.
4
6
  const WebSocket =
@@ -6,6 +8,12 @@ const WebSocket =
6
8
  const WebSocketServer =
7
9
  WebSocketModule.WebSocketServer || WebSocketModule.Server || WebSocket.Server;
8
10
 
11
+ // Bounded wait for the single app client to answer a forwarded automation
12
+ // state request. Pending entries are otherwise cleared only by a matching
13
+ // reply or a disconnect, so a connected-but-silent app would leave the
14
+ // requester waiting forever.
15
+ const AUTOMATION_STATE_REPLY_TIMEOUT_MS = 1500;
16
+
9
17
  function normalizeLogger(logger) {
10
18
  if (!logger || typeof logger !== "object") {
11
19
  return console;
@@ -60,53 +68,81 @@ function createDownstreamServer(opts) {
60
68
  const getCurrentStatus = opts.getCurrentStatus;
61
69
  const getCurrentDebugConfig = opts.getCurrentDebugConfig || null;
62
70
  const getCurrentResumeState = opts.getCurrentResumeState || null;
71
+ const getAgentAvatarDataUriByHash =
72
+ typeof opts.getAgentAvatarDataUriByHash === "function"
73
+ ? opts.getAgentAvatarDataUriByHash
74
+ : () => null;
63
75
  const token = opts.token;
64
76
  const onClientConnected = opts.onClientConnected || null;
65
77
  const onClientDisconnected = opts.onClientDisconnected || null;
66
78
  const onTransportControl = opts.onTransportControl || null;
79
+ const relayHealth = opts.relayHealth || null;
80
+ const sendBufferSnapshotForTest =
81
+ typeof opts.sendBufferSnapshotForTest === "function"
82
+ ? opts.sendBufferSnapshotForTest
83
+ : null;
84
+ const getPluginVersion =
85
+ typeof opts.getPluginVersion === "function" ? opts.getPluginVersion : null;
86
+ const getRequiresClientVersion =
87
+ typeof opts.getRequiresClientVersion === "function" ? opts.getRequiresClientVersion : null;
88
+ const pluginId =
89
+ typeof opts.pluginId === "string" && opts.pluginId.trim().length > 0
90
+ ? opts.pluginId.trim()
91
+ : null;
92
+ const runPluginUpdate =
93
+ typeof opts.runPluginUpdate === "function" ? opts.runPluginUpdate : null;
94
+ const runGatewayRestart =
95
+ typeof opts.runGatewayRestart === "function" ? opts.runGatewayRestart : null;
96
+ const sessionService =
97
+ opts.sessionService && typeof opts.sessionService === "object"
98
+ ? opts.sessionService
99
+ : null;
67
100
  const protocolHelloTimeoutMs = Number.isFinite(opts.protocolHelloTimeoutMs)
68
101
  ? Math.max(0, Math.floor(opts.protocolHelloTimeoutMs))
69
- : 120;
102
+ : 10000;
70
103
  const externalDebugToolsEnabled = opts.externalDebugToolsEnabled !== false;
71
104
  const resumeHandshakeTimeoutMs = Number.isFinite(opts.resumeHandshakeTimeoutMs)
72
105
  ? Math.max(0, Math.floor(opts.resumeHandshakeTimeoutMs))
73
- : 120;
74
- const nudgeActiveIntervalMs = Number.isFinite(opts.nudgeActiveIntervalMs)
75
- ? Math.max(1, Math.floor(opts.nudgeActiveIntervalMs))
76
- : 150;
77
- const nudgeSlowIntervalMs = Number.isFinite(opts.nudgeSlowIntervalMs)
78
- ? Math.max(1, Math.floor(opts.nudgeSlowIntervalMs))
79
- : 1000;
80
- const nudgeIdleDeactivateMs = Number.isFinite(opts.nudgeIdleDeactivateMs)
81
- ? Math.max(0, Math.floor(opts.nudgeIdleDeactivateMs))
82
- : 5000;
83
- const nudgeHeartbeatIntervalMs = Number.isFinite(opts.nudgeHeartbeatIntervalMs)
84
- ? Math.max(1, Math.floor(opts.nudgeHeartbeatIntervalMs))
85
- : 10000;
86
- const nudgeHardTimeoutMs = Number.isFinite(opts.nudgeHardTimeoutMs)
87
- ? Math.max(1, Math.floor(opts.nudgeHardTimeoutMs))
88
- : 60000;
89
- const nudgeStaleHeartbeatThresholdMs = nudgeHeartbeatIntervalMs * 2;
90
- const RENDER_NUDGE_FRAME = JSON.stringify({ type: "render_nudge" });
91
-
106
+ : 1500;
92
107
  /** @type {Map<string, WebSocket>} */
93
108
  const clients = new Map();
94
109
  /** @type {Map<string, {selectedVersion: "v2"|null, supportedProtocolVersions: string[], clientName: string|null, clientVersion: string|null, sessionKey: string|null, reason: string|null}>} */
95
110
  const protocolState = new Map();
96
111
  /** @type {Map<string, {pagesSynced: boolean, statusSynced: boolean, approvalsSynced: boolean, debugConfigSynced: boolean, resumeReceived: boolean, timer: any}>} */
97
112
  const syncState = new Map();
98
- /** @type {Map<string, {visibilityState: "hidden"|"visible"|"blurred"|null, streamChars: number|null, lastHeartbeatAtMs: number|null, lastRelayStreamingActivityAtMs: number|null, interactionStage: "idle"|"listening"|"voice_handoff"|"thinking"|"streaming"|"post_turn_drain", cadenceBucket: "idle"|"active_non_stream"|"active_stream", nudgeActive: boolean, nudgeIntervalMs: number|null, nudgeStartedAtMs: number|null, lastNudgeAtMs: number|null, stalledHeartbeatCount: number, nudgeTimer: any, idleDeactivateTimer: any, staleHeartbeatTimer: any, hardTimeoutTimer: any}>} */
99
- const clientNudgeState = new Map();
100
- /** @type {Map<string, {clientDebugEnabled: boolean|null, runtimeDiagnosticsVisible: boolean|null, perfNoisyDebugMuted: boolean|null, perfPayloadLiteMode: boolean|null, activeSessionKey: string|null, bundleIdentity: {kind: string|null, mode: string|null, host: string|null, port: number|null, servedDistPath: string|null}|null, emittedAtMs: number|null, updatedAtMs: number}>} */
113
+ /** @type {Map<string, {clientDebugEnabled: boolean|null, runtimeDiagnosticsVisible: boolean|null, perfNoisyDebugMuted: boolean|null, perfPayloadLiteMode: boolean|null, activeSessionKey: string|null, bundleIdentity: {kind: string|null, mode: string|null, host: string|null, port: number|null, servedDistPath: string|null}|null, runtimeContext: string|null, glasses: {connected: boolean, batteryPercent: number|null, statusAgeMs: number|null}|null, emittedAtMs: number|null, updatedAtMs: number}>} */
101
114
  const clientReadinessSnapshotState = new Map();
102
115
  /** @type {Map<string, {requesterClientId: string, targetClientId: string, createdAtMs: number}>} */
103
116
  const pendingReadinessProbeRequests = new Map();
117
+ /** @type {Map<string, {requesterClientId: string, targetClientId: string, createdAtMs: number, expiryTimer: any}>} */
118
+ const pendingAutomationStateRequests = new Map();
104
119
  /** @type {Map<string, string>} */
105
120
  const unresolvedApprovals = new Map();
121
+ const nudgeController = createRelayClientNudgeController({
122
+ thresholds: {
123
+ nudgeActiveIntervalMs: opts.nudgeActiveIntervalMs,
124
+ nudgeSlowIntervalMs: opts.nudgeSlowIntervalMs,
125
+ nudgeIdleDeactivateMs: opts.nudgeIdleDeactivateMs,
126
+ nudgeHeartbeatIntervalMs: opts.nudgeHeartbeatIntervalMs,
127
+ nudgeHardTimeoutMs: opts.nudgeHardTimeoutMs,
128
+ },
129
+ isAppClient,
130
+ sendFrame(clientId, frame) {
131
+ const ws = clients.get(clientId);
132
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
133
+ sendMessageToClient(clientId, ws, frame);
134
+ return true;
135
+ },
136
+ });
106
137
  let nextClientId = 1;
138
+ let pendingHandlerPromiseCount = 0;
107
139
  const APP_PROTOCOL = {
108
140
  approvalRequest: "ocuclaw.approval.request",
109
141
  approvalResolved: "ocuclaw.approval.resolved",
142
+ automationStateGet: "ocuclaw.automation.state.get",
143
+ automationStateSnapshot: "ocuclaw.automation.state.snapshot",
144
+ avatarBlob: "ocuclaw.avatar.blob",
145
+ avatarFetch: "ocuclaw.avatar.fetch",
110
146
  debugConfigSnapshot: "ocuclaw.debug.config.snapshot",
111
147
  pages: "ocuclaw.view.pages.snapshot",
112
148
  readinessProbeAck: "ocuclaw.readiness.probe.ack",
@@ -114,7 +150,11 @@ function createDownstreamServer(opts) {
114
150
  readinessSnapshot: "ocuclaw.readiness.snapshot",
115
151
  resume: "ocuclaw.sync.resume",
116
152
  resumeAck: "ocuclaw.sync.resume.ack",
153
+ restartGateway: "ocuclaw.restartGateway",
154
+ restartGatewayAck: "ocuclaw.restartGatewayAck",
117
155
  status: "ocuclaw.runtime.status",
156
+ updatePlugin: "ocuclaw.updatePlugin",
157
+ updatePluginResult: "ocuclaw.updatePluginResult",
118
158
  };
119
159
  const REMOVED_V1_APP_TYPES = new Set([
120
160
  "approvalResponse",
@@ -156,7 +196,10 @@ function createDownstreamServer(opts) {
156
196
  }
157
197
 
158
198
  const nextRole =
159
- messageType === "remote-control" ? "control" : "app";
199
+ messageType === "remote-control" ||
200
+ messageType === APP_PROTOCOL.automationStateGet
201
+ ? "control"
202
+ : "app";
160
203
  if (meta.role === "unknown") {
161
204
  meta.role = nextRole;
162
205
  return;
@@ -166,13 +209,6 @@ function createDownstreamServer(opts) {
166
209
  }
167
210
  }
168
211
 
169
- function parseRevision(value) {
170
- if (!Number.isFinite(Number(value))) return null;
171
- const num = Math.floor(Number(value));
172
- if (num < 0) return null;
173
- return num;
174
- }
175
-
176
212
  function parseBool(value) {
177
213
  if (value === true || value === false) return value;
178
214
  if (typeof value === "number") return value !== 0;
@@ -310,6 +346,27 @@ function createDownstreamServer(opts) {
310
346
  };
311
347
  }
312
348
 
349
+ function parseReadinessRuntimeContext(value) {
350
+ const s = parseOptionalTrimmedString(value);
351
+ return s === "native_app" || s === "simulator" || s === "browser" ? s : null;
352
+ }
353
+
354
+ function parseReadinessGlasses(value) {
355
+ if (!value || typeof value !== "object") {
356
+ return null;
357
+ }
358
+ const connected = parseOptionalBoolean(value.connected);
359
+ if (connected === null) {
360
+ return null;
361
+ }
362
+ let batteryPercent = parseOptionalNonNegativeInt(value.batteryPercent);
363
+ if (batteryPercent !== null) {
364
+ batteryPercent = Math.min(100, batteryPercent);
365
+ }
366
+ const statusAgeMs = parseOptionalNonNegativeInt(value.statusAgeMs);
367
+ return { connected, batteryPercent, statusAgeMs };
368
+ }
369
+
313
370
  function parseReadinessSnapshot(value) {
314
371
  if (!value || typeof value !== "object") {
315
372
  return null;
@@ -326,6 +383,8 @@ function createDownstreamServer(opts) {
326
383
  const bundleIdentity = parseReadinessBundleIdentity(
327
384
  value.bundleIdentity || value.bundle,
328
385
  );
386
+ const runtimeContext = parseReadinessRuntimeContext(value.runtimeContext);
387
+ const glasses = parseReadinessGlasses(value.glasses);
329
388
  const emittedAtMs = parseOptionalNonNegativeInt(value.emittedAtMs);
330
389
  if (
331
390
  clientDebugEnabled === null &&
@@ -334,6 +393,8 @@ function createDownstreamServer(opts) {
334
393
  perfPayloadLiteMode === null &&
335
394
  !activeSessionKey &&
336
395
  !bundleIdentity &&
396
+ !runtimeContext &&
397
+ !glasses &&
337
398
  emittedAtMs === null
338
399
  ) {
339
400
  return null;
@@ -345,6 +406,8 @@ function createDownstreamServer(opts) {
345
406
  perfPayloadLiteMode,
346
407
  activeSessionKey: activeSessionKey || null,
347
408
  bundleIdentity,
409
+ runtimeContext,
410
+ glasses,
348
411
  emittedAtMs,
349
412
  updatedAtMs: Date.now(),
350
413
  };
@@ -370,6 +433,23 @@ function createDownstreamServer(opts) {
370
433
  };
371
434
  }
372
435
 
436
+ function parseAutomationStateSnapshot(value) {
437
+ if (!value || value.type !== APP_PROTOCOL.automationStateSnapshot) {
438
+ return null;
439
+ }
440
+ const requestId = parseOptionalTrimmedString(value.requestId);
441
+ if (!requestId) {
442
+ return null;
443
+ }
444
+ return {
445
+ requestId,
446
+ ok: value.ok !== false,
447
+ state: value.state && typeof value.state === "object" ? value.state : null,
448
+ reasonCode: parseOptionalTrimmedString(value.reasonCode),
449
+ message: parseOptionalTrimmedString(value.message),
450
+ };
451
+ }
452
+
373
453
  function describeProtocolClient(state) {
374
454
  if (!state) return "unknown";
375
455
  const clientName = parseOptionalTrimmedString(state.clientName);
@@ -397,7 +477,7 @@ function createDownstreamServer(opts) {
397
477
  return classifyClientKindFromName(clientName);
398
478
  }
399
479
 
400
- function countConnectedAppClients(excludeClientId = null) {
480
+ function countConnectedAppClients(excludeClientId = null, sessionKey = null) {
401
481
  let count = 0;
402
482
  for (const [clientId, ws] of clients) {
403
483
  if (excludeClientId && clientId === excludeClientId) {
@@ -406,7 +486,14 @@ function createDownstreamServer(opts) {
406
486
  if (!ws || ws.readyState !== WebSocket.OPEN) {
407
487
  continue;
408
488
  }
409
- if (classifyClientKind(protocolState.get(clientId)) !== "app") {
489
+ const protocol = protocolState.get(clientId);
490
+ if (classifyClientKind(protocol) !== "app") {
491
+ continue;
492
+ }
493
+ // When sessionKey is provided, only count app clients bound to that
494
+ // session — lets callers ask "is this session's last app gone?"
495
+ // without being masked by apps in other sessions.
496
+ if (sessionKey != null && (!protocol || protocol.sessionKey !== sessionKey)) {
410
497
  continue;
411
498
  }
412
499
  count += 1;
@@ -454,27 +541,16 @@ function createDownstreamServer(opts) {
454
541
  };
455
542
  }
456
543
 
457
- function interactionStageBucket(stage) {
458
- switch (stage) {
459
- case "listening":
460
- case "voice_handoff":
461
- case "thinking":
462
- return "active_non_stream";
463
- case "streaming":
464
- case "post_turn_drain":
465
- return "active_stream";
466
- default:
467
- return "idle";
468
- }
469
- }
470
-
471
544
  function formatProtocolHelloAck(payload) {
472
- return JSON.stringify({
473
- type: "protocolHelloAck",
545
+ const pluginVersion = getPluginVersion ? getPluginVersion() : null;
546
+ const requiresClientVersion = getRequiresClientVersion ? getRequiresClientVersion() : null;
547
+ return RelayWorkerProtocol.formatProtocolHelloAck({
474
548
  protocolVersion: payload.protocolVersion,
475
549
  supportedProtocolVersions: payload.supportedProtocolVersions || ["v2"],
476
- reason: payload.reason || null,
477
- deprecatedV1: false,
550
+ reason: payload.reason,
551
+ pluginVersion,
552
+ requiresClientVersion,
553
+ pluginId,
478
554
  });
479
555
  }
480
556
 
@@ -494,66 +570,6 @@ function createDownstreamServer(opts) {
494
570
  return state;
495
571
  }
496
572
 
497
- function createClientNudgeState() {
498
- return {
499
- visibilityState: null,
500
- streamChars: null,
501
- lastHeartbeatAtMs: null,
502
- lastRelayStreamingActivityAtMs: null,
503
- interactionStage: "idle",
504
- cadenceBucket: "idle",
505
- nudgeActive: false,
506
- nudgeIntervalMs: null,
507
- nudgeStartedAtMs: null,
508
- lastNudgeAtMs: null,
509
- stalledHeartbeatCount: 0,
510
- nudgeTimer: null,
511
- idleDeactivateTimer: null,
512
- staleHeartbeatTimer: null,
513
- hardTimeoutTimer: null,
514
- };
515
- }
516
-
517
- function ensureClientNudgeState(clientId) {
518
- let state = clientNudgeState.get(clientId);
519
- if (!state) {
520
- state = createClientNudgeState();
521
- clientNudgeState.set(clientId, state);
522
- }
523
- return state;
524
- }
525
-
526
- function cloneClientNudgeState(state) {
527
- if (!state) return null;
528
- return {
529
- visibilityState: state.visibilityState || null,
530
- streamChars: Number.isFinite(state.streamChars) ? state.streamChars : null,
531
- lastHeartbeatAtMs: Number.isFinite(state.lastHeartbeatAtMs)
532
- ? state.lastHeartbeatAtMs
533
- : null,
534
- lastRelayStreamingActivityAtMs: Number.isFinite(
535
- state.lastRelayStreamingActivityAtMs,
536
- )
537
- ? state.lastRelayStreamingActivityAtMs
538
- : null,
539
- interactionStage: state.interactionStage || "idle",
540
- cadenceBucket: state.cadenceBucket || "idle",
541
- nudgeActive: !!state.nudgeActive,
542
- nudgeIntervalMs: Number.isFinite(state.nudgeIntervalMs)
543
- ? state.nudgeIntervalMs
544
- : null,
545
- nudgeStartedAtMs: Number.isFinite(state.nudgeStartedAtMs)
546
- ? state.nudgeStartedAtMs
547
- : null,
548
- lastNudgeAtMs: Number.isFinite(state.lastNudgeAtMs)
549
- ? state.lastNudgeAtMs
550
- : null,
551
- stalledHeartbeatCount: Number.isFinite(state.stalledHeartbeatCount)
552
- ? state.stalledHeartbeatCount
553
- : 0,
554
- };
555
- }
556
-
557
573
  function cloneReadinessBundleIdentity(value) {
558
574
  if (!value) return null;
559
575
  return {
@@ -565,6 +581,24 @@ function createDownstreamServer(opts) {
565
581
  };
566
582
  }
567
583
 
584
+ function cloneReadinessGlasses(value) {
585
+ if (!value) return null;
586
+ // The dedicated ocuclaw.readiness.snapshot frame is stored raw via clone
587
+ // (no parseReadinessGlasses pass), so clamp/validate defensively here too.
588
+ const batteryPercent = Number.isFinite(value.batteryPercent)
589
+ ? Math.min(100, Math.max(0, value.batteryPercent))
590
+ : null;
591
+ const statusAgeMs =
592
+ Number.isFinite(value.statusAgeMs) && value.statusAgeMs >= 0
593
+ ? value.statusAgeMs
594
+ : null;
595
+ return {
596
+ connected: value.connected === true,
597
+ batteryPercent,
598
+ statusAgeMs,
599
+ };
600
+ }
601
+
568
602
  function cloneReadinessSnapshot(value) {
569
603
  if (!value) return null;
570
604
  return {
@@ -587,6 +621,13 @@ function createDownstreamServer(opts) {
587
621
  : null,
588
622
  activeSessionKey: value.activeSessionKey || null,
589
623
  bundleIdentity: cloneReadinessBundleIdentity(value.bundleIdentity),
624
+ runtimeContext:
625
+ value.runtimeContext === "native_app" ||
626
+ value.runtimeContext === "simulator" ||
627
+ value.runtimeContext === "browser"
628
+ ? value.runtimeContext
629
+ : null,
630
+ glasses: cloneReadinessGlasses(value.glasses),
590
631
  emittedAtMs: Number.isFinite(value.emittedAtMs) ? value.emittedAtMs : null,
591
632
  updatedAtMs: Number.isFinite(value.updatedAtMs) ? value.updatedAtMs : null,
592
633
  };
@@ -615,6 +656,20 @@ function createDownstreamServer(opts) {
615
656
  }
616
657
  }
617
658
 
659
+ function clearPendingAutomationStateRequestsForClient(clientId) {
660
+ if (!clientId) return;
661
+ for (const [requestId, pending] of pendingAutomationStateRequests) {
662
+ if (!pending) continue;
663
+ if (
664
+ pending.requesterClientId === clientId ||
665
+ pending.targetClientId === clientId
666
+ ) {
667
+ if (pending.expiryTimer) clearTimeout(pending.expiryTimer);
668
+ pendingAutomationStateRequests.delete(requestId);
669
+ }
670
+ }
671
+ }
672
+
618
673
  function buildReadinessClientEntry(clientId) {
619
674
  const protocol = protocolState.get(clientId);
620
675
  return {
@@ -629,21 +684,6 @@ function createDownstreamServer(opts) {
629
684
  };
630
685
  }
631
686
 
632
- function clearClientNudgeTimer(state, key, clearFn = clearTimeout) {
633
- if (!state || !state[key]) return;
634
- clearFn(state[key]);
635
- state[key] = null;
636
- }
637
-
638
- function clearClientNudgeTimers(clientId) {
639
- const state = clientNudgeState.get(clientId);
640
- if (!state) return;
641
- clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
642
- clearClientNudgeTimer(state, "idleDeactivateTimer");
643
- clearClientNudgeTimer(state, "staleHeartbeatTimer");
644
- clearClientNudgeTimer(state, "hardTimeoutTimer");
645
- }
646
-
647
687
  function isStreamingBroadcastType(messageType) {
648
688
  return (
649
689
  messageType === "streaming" ||
@@ -651,356 +691,6 @@ function createDownstreamServer(opts) {
651
691
  );
652
692
  }
653
693
 
654
- function isPagesBroadcastType(messageType) {
655
- return messageType === "pages" || messageType === APP_PROTOCOL.pages;
656
- }
657
-
658
- function isActivityBroadcastType(messageType) {
659
- return messageType === "activity" || messageType === "ocuclaw.activity.update";
660
- }
661
-
662
- function isListenCommittedBroadcastType(messageType) {
663
- return messageType === "listen-committed";
664
- }
665
-
666
- function isListenEndedBroadcastType(messageType) {
667
- return messageType === "listen-ended";
668
- }
669
-
670
- function resetClientStallTracking(state) {
671
- if (!state) return;
672
- state.stalledHeartbeatCount = 0;
673
- }
674
-
675
- function hasStaleHeartbeat(state, now = Date.now()) {
676
- if (!state || !Number.isFinite(state.lastHeartbeatAtMs)) {
677
- return false;
678
- }
679
- return now - state.lastHeartbeatAtMs >= nudgeStaleHeartbeatThresholdMs;
680
- }
681
-
682
- function isVisibilityDegraded(state) {
683
- return (
684
- !!state &&
685
- (state.visibilityState === "hidden" || state.visibilityState === "blurred")
686
- );
687
- }
688
-
689
- function sendRenderNudge(clientId) {
690
- const ws = clients.get(clientId);
691
- if (!ws || ws.readyState !== WebSocket.OPEN) {
692
- stopClientNudges(clientId, "socket_unavailable");
693
- return;
694
- }
695
- sendMessageToClient(clientId, ws, RENDER_NUDGE_FRAME);
696
- ensureClientNudgeState(clientId).lastNudgeAtMs = Date.now();
697
- }
698
-
699
- function scheduleClientHardTimeout(clientId) {
700
- const state = clientNudgeState.get(clientId);
701
- if (!state) return;
702
- clearClientNudgeTimer(state, "hardTimeoutTimer");
703
- if (!state.nudgeActive) return;
704
- state.hardTimeoutTimer = setTimeout(() => {
705
- state.hardTimeoutTimer = null;
706
- setClientInteractionStage(clientId, "idle", {
707
- reason: "nudge_hard_timeout",
708
- deactivateImmediately: true,
709
- });
710
- }, nudgeHardTimeoutMs);
711
- }
712
-
713
- function startClientNudges(
714
- clientId,
715
- intervalMs,
716
- reason = "nudge_start",
717
- sendImmediately = false,
718
- ) {
719
- if (!isAppClient(clientId)) return;
720
- const ws = clients.get(clientId);
721
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
722
- const state = ensureClientNudgeState(clientId);
723
- const nextIntervalMs = Math.max(1, Math.floor(intervalMs));
724
- const wasActive = !!state.nudgeActive;
725
- const intervalChanged = state.nudgeIntervalMs !== nextIntervalMs;
726
- state.cadenceBucket = interactionStageBucket(state.interactionStage);
727
- if (!wasActive) {
728
- state.nudgeActive = true;
729
- state.nudgeStartedAtMs = Date.now();
730
- }
731
- if (wasActive && !intervalChanged) {
732
- return;
733
- }
734
- clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
735
- state.nudgeActive = true;
736
- state.nudgeIntervalMs = nextIntervalMs;
737
- state.nudgeTimer = setInterval(() => {
738
- sendRenderNudge(clientId);
739
- }, nextIntervalMs);
740
- if (!wasActive) {
741
- scheduleClientHardTimeout(clientId);
742
- if (sendImmediately) {
743
- sendRenderNudge(clientId);
744
- }
745
- }
746
- }
747
-
748
- function stopClientNudges(clientId, _reason = "nudge_stop") {
749
- const state = clientNudgeState.get(clientId);
750
- if (!state) return;
751
- clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
752
- clearClientNudgeTimer(state, "idleDeactivateTimer");
753
- clearClientNudgeTimer(state, "hardTimeoutTimer");
754
- state.nudgeActive = false;
755
- state.nudgeIntervalMs = null;
756
- state.nudgeStartedAtMs = null;
757
- resetClientStallTracking(state);
758
- scheduleClientStaleHeartbeatCheck(clientId);
759
- }
760
-
761
- function scheduleClientIdleDeactivation(clientId) {
762
- const state = clientNudgeState.get(clientId);
763
- if (!state) return;
764
- clearClientNudgeTimer(state, "idleDeactivateTimer");
765
- if (!state.nudgeActive || state.interactionStage !== "idle") {
766
- return;
767
- }
768
- state.idleDeactivateTimer = setTimeout(() => {
769
- state.idleDeactivateTimer = null;
770
- const currentState = clientNudgeState.get(clientId);
771
- if (!currentState || currentState.interactionStage !== "idle") {
772
- return;
773
- }
774
- stopClientNudges(clientId, "idle_grace_elapsed");
775
- }, nudgeIdleDeactivateMs);
776
- }
777
-
778
- function scheduleClientStaleHeartbeatCheck(clientId) {
779
- const state = clientNudgeState.get(clientId);
780
- if (!state) return;
781
- clearClientNudgeTimer(state, "staleHeartbeatTimer");
782
- if (
783
- state.nudgeActive ||
784
- interactionStageBucket(state.interactionStage) === "idle" ||
785
- !Number.isFinite(state.lastHeartbeatAtMs)
786
- ) {
787
- return;
788
- }
789
- const delayMs = Math.max(
790
- 0,
791
- (state.lastHeartbeatAtMs + nudgeStaleHeartbeatThresholdMs) - Date.now(),
792
- );
793
- state.staleHeartbeatTimer = setTimeout(() => {
794
- state.staleHeartbeatTimer = null;
795
- const currentState = clientNudgeState.get(clientId);
796
- if (
797
- !currentState ||
798
- currentState.nudgeActive ||
799
- interactionStageBucket(currentState.interactionStage) === "idle"
800
- ) {
801
- return;
802
- }
803
- if (!hasStaleHeartbeat(currentState)) {
804
- scheduleClientStaleHeartbeatCheck(clientId);
805
- return;
806
- }
807
- startClientNudges(
808
- clientId,
809
- nudgeActiveIntervalMs,
810
- "stale_heartbeat_fallback",
811
- true,
812
- );
813
- }, delayMs);
814
- }
815
-
816
- function maybeActivateClientNudges(clientId, reason = "nudge_eval") {
817
- const state = clientNudgeState.get(clientId);
818
- if (!state || !isAppClient(clientId)) return;
819
- state.cadenceBucket = interactionStageBucket(state.interactionStage);
820
- if (state.cadenceBucket === "idle") {
821
- clearClientNudgeTimer(state, "staleHeartbeatTimer");
822
- return;
823
- }
824
- if (state.nudgeActive) {
825
- return;
826
- }
827
- if (isVisibilityDegraded(state) || hasStaleHeartbeat(state)) {
828
- startClientNudges(clientId, nudgeActiveIntervalMs, reason, true);
829
- return;
830
- }
831
- scheduleClientStaleHeartbeatCheck(clientId);
832
- }
833
-
834
- function setClientInteractionStage(clientId, nextStage, options = {}) {
835
- if (!isAppClient(clientId)) return;
836
- const state = ensureClientNudgeState(clientId);
837
- const reason = options.reason || "interaction_stage";
838
- const deactivateImmediately = options.deactivateImmediately === true;
839
- state.interactionStage = nextStage;
840
- state.cadenceBucket = interactionStageBucket(nextStage);
841
- if (state.cadenceBucket !== "active_stream") {
842
- resetClientStallTracking(state);
843
- }
844
- if (state.cadenceBucket === "idle") {
845
- clearClientNudgeTimer(state, "staleHeartbeatTimer");
846
- if (deactivateImmediately) {
847
- stopClientNudges(clientId, reason);
848
- } else {
849
- scheduleClientIdleDeactivation(clientId);
850
- }
851
- return;
852
- }
853
- clearClientNudgeTimer(state, "idleDeactivateTimer");
854
- if (
855
- state.cadenceBucket === "active_non_stream" &&
856
- state.nudgeActive &&
857
- state.nudgeIntervalMs !== nudgeActiveIntervalMs
858
- ) {
859
- startClientNudges(clientId, nudgeActiveIntervalMs, reason, false);
860
- }
861
- maybeActivateClientNudges(clientId, reason);
862
- }
863
-
864
- function observeRelayStreamingActivity(clientId, atMs) {
865
- if (!isAppClient(clientId)) return;
866
- const state = ensureClientNudgeState(clientId);
867
- state.lastRelayStreamingActivityAtMs = atMs;
868
- resetClientStallTracking(state);
869
- if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
870
- startClientNudges(clientId, nudgeActiveIntervalMs, "relay_stream_progress");
871
- return;
872
- }
873
- maybeActivateClientNudges(clientId, "relay_stream_activity");
874
- }
875
-
876
- function updateClientNudgeHeartbeat(clientId, ping) {
877
- const state = ensureClientNudgeState(clientId);
878
- const previousHeartbeatAtMs = Number.isFinite(state.lastHeartbeatAtMs)
879
- ? state.lastHeartbeatAtMs
880
- : null;
881
- const previousStreamChars = Number.isFinite(state.streamChars)
882
- ? state.streamChars
883
- : null;
884
- const nextStreamChars = Number.isFinite(ping.streamChars) ? ping.streamChars : null;
885
- const streamAdvanced =
886
- nextStreamChars !== null &&
887
- (previousStreamChars === null || nextStreamChars > previousStreamChars);
888
- const relayStreamAdvanced =
889
- previousHeartbeatAtMs !== null &&
890
- Number.isFinite(state.lastRelayStreamingActivityAtMs) &&
891
- state.lastRelayStreamingActivityAtMs > previousHeartbeatAtMs;
892
- state.streamChars = nextStreamChars;
893
- if (ping.visibilityState) {
894
- state.visibilityState = ping.visibilityState;
895
- }
896
- state.lastHeartbeatAtMs = Date.now();
897
- state.cadenceBucket = interactionStageBucket(state.interactionStage);
898
- if (state.visibilityState === "visible") {
899
- stopClientNudges(clientId, "heartbeat_visible");
900
- }
901
- if (state.cadenceBucket === "active_stream") {
902
- if (streamAdvanced || relayStreamAdvanced) {
903
- resetClientStallTracking(state);
904
- if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
905
- startClientNudges(
906
- clientId,
907
- nudgeActiveIntervalMs,
908
- streamAdvanced
909
- ? "heartbeat_stream_progress"
910
- : "relay_stream_progress",
911
- false,
912
- );
913
- }
914
- } else if (state.nudgeActive) {
915
- state.stalledHeartbeatCount += 1;
916
- if (
917
- state.stalledHeartbeatCount >= 3 &&
918
- state.nudgeIntervalMs !== nudgeSlowIntervalMs
919
- ) {
920
- startClientNudges(
921
- clientId,
922
- nudgeSlowIntervalMs,
923
- "stream_stalled_decelerated",
924
- false,
925
- );
926
- }
927
- }
928
- } else {
929
- resetClientStallTracking(state);
930
- if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
931
- startClientNudges(clientId, nudgeActiveIntervalMs, "non_stream_fast", false);
932
- }
933
- }
934
- maybeActivateClientNudges(clientId, "heartbeat_update");
935
- }
936
-
937
- function updateClientVisibilityState(clientId, visibilityState) {
938
- const state = ensureClientNudgeState(clientId);
939
- state.visibilityState = visibilityState;
940
- if (visibilityState === "visible") {
941
- stopClientNudges(clientId, "visibility_visible");
942
- return;
943
- }
944
- maybeActivateClientNudges(clientId, "visibility_hidden");
945
- }
946
-
947
- function applyBroadcastInteractionStage(messageType, parsed, relayStreamingActivityAtMs) {
948
- for (const [clientId] of clients) {
949
- if (!isAppClient(clientId)) {
950
- continue;
951
- }
952
- if (relayStreamingActivityAtMs !== null) {
953
- setClientInteractionStage(clientId, "streaming", {
954
- reason: "relay_streaming",
955
- });
956
- observeRelayStreamingActivity(clientId, relayStreamingActivityAtMs);
957
- continue;
958
- }
959
- if (isListenCommittedBroadcastType(messageType)) {
960
- setClientInteractionStage(clientId, "voice_handoff", {
961
- reason: "listen_committed",
962
- });
963
- continue;
964
- }
965
- if (isListenEndedBroadcastType(messageType)) {
966
- setClientInteractionStage(clientId, "idle", {
967
- reason: "listen_ended",
968
- deactivateImmediately: true,
969
- });
970
- continue;
971
- }
972
- if (isActivityBroadcastType(messageType)) {
973
- const activityState = parseOptionalTrimmedString(parsed && parsed.state);
974
- const normalizedActivity = activityState ? activityState.toLowerCase() : null;
975
- const currentStage = ensureClientNudgeState(clientId).interactionStage;
976
- if (normalizedActivity === "thinking") {
977
- setClientInteractionStage(clientId, "thinking", {
978
- reason: "activity_thinking",
979
- });
980
- } else if (normalizedActivity === "idle") {
981
- if (currentStage === "streaming" || currentStage === "post_turn_drain") {
982
- setClientInteractionStage(clientId, "post_turn_drain", {
983
- reason: "activity_idle_stream_drain",
984
- });
985
- } else {
986
- setClientInteractionStage(clientId, "idle", {
987
- reason: "activity_idle",
988
- });
989
- }
990
- }
991
- continue;
992
- }
993
- if (isPagesBroadcastType(messageType)) {
994
- const currentState = ensureClientNudgeState(clientId);
995
- if (interactionStageBucket(currentState.interactionStage) !== "idle") {
996
- setClientInteractionStage(clientId, "post_turn_drain", {
997
- reason: "pages_snapshot",
998
- });
999
- }
1000
- }
1001
- }
1002
- }
1003
-
1004
694
  function updateProtocolSessionKey(clientId, value) {
1005
695
  const sessionKey = parseOptionalTrimmedString(value);
1006
696
  if (!sessionKey) return;
@@ -1061,11 +751,35 @@ function createDownstreamServer(opts) {
1061
751
  }, resumeHandshakeTimeoutMs);
1062
752
  }
1063
753
 
1064
- function sendMessageToClient(clientId, ws, message) {
754
+ function parseMessageTypeForHealth(message) {
755
+ try {
756
+ const parsed = JSON.parse(message);
757
+ return parsed && typeof parsed.type === "string" ? parsed.type : null;
758
+ } catch (_) {
759
+ return null;
760
+ }
761
+ }
762
+
763
+ function sendMessageToClient(clientId, ws, message, knownMessageType) {
1065
764
  if (!ws || ws.readyState !== WebSocket.OPEN) {
1066
765
  return;
1067
766
  }
1068
767
  ws.send(message);
768
+ if (relayHealth && typeof relayHealth.observeSendBuffer === "function") {
769
+ const bufferedAmountBytes = sendBufferSnapshotForTest
770
+ ? sendBufferSnapshotForTest(ws)
771
+ : Number.isFinite(ws.bufferedAmount)
772
+ ? ws.bufferedAmount
773
+ : null;
774
+ relayHealth.observeSendBuffer({
775
+ clientId,
776
+ messageType:
777
+ knownMessageType !== undefined
778
+ ? knownMessageType
779
+ : parseMessageTypeForHealth(message),
780
+ bufferedAmountBytes,
781
+ });
782
+ }
1069
783
  }
1070
784
 
1071
785
  function forwardReadinessProbeAck(clientId, ack) {
@@ -1112,36 +826,177 @@ function createDownstreamServer(opts) {
1112
826
  sendMessageToClient(pending.requesterClientId, requesterWs, message);
1113
827
  }
1114
828
 
829
+ function forwardAutomationStateSnapshot(clientId, snapshot) {
830
+ if (!snapshot || !snapshot.requestId) {
831
+ return;
832
+ }
833
+ const pending = pendingAutomationStateRequests.get(snapshot.requestId);
834
+ if (!pending || pending.targetClientId !== clientId) {
835
+ return;
836
+ }
837
+ if (pending.expiryTimer) clearTimeout(pending.expiryTimer);
838
+ pendingAutomationStateRequests.delete(snapshot.requestId);
839
+ const requesterWs = clients.get(pending.requesterClientId);
840
+ if (!requesterWs || requesterWs.readyState !== WebSocket.OPEN) {
841
+ return;
842
+ }
843
+ const message =
844
+ handler && typeof handler.formatAutomationStateSnapshot === "function"
845
+ ? handler.formatAutomationStateSnapshot({
846
+ ok: snapshot.ok !== false,
847
+ requestId: snapshot.requestId,
848
+ state: snapshot.state || null,
849
+ reasonCode: snapshot.reasonCode || null,
850
+ message: snapshot.message || null,
851
+ })
852
+ : JSON.stringify({
853
+ type: APP_PROTOCOL.automationStateSnapshot,
854
+ ok: snapshot.ok !== false,
855
+ requestId: snapshot.requestId,
856
+ state: snapshot.state || null,
857
+ reasonCode: snapshot.reasonCode || null,
858
+ message: snapshot.message || null,
859
+ });
860
+ sendMessageToClient(pending.requesterClientId, requesterWs, message);
861
+ }
862
+
863
+ function expirePendingAutomationStateRequest(requestId) {
864
+ const pending = pendingAutomationStateRequests.get(requestId);
865
+ if (!pending) {
866
+ return;
867
+ }
868
+ if (pending.expiryTimer) clearTimeout(pending.expiryTimer);
869
+ pendingAutomationStateRequests.delete(requestId);
870
+ const requesterWs = clients.get(pending.requesterClientId);
871
+ if (!requesterWs || requesterWs.readyState !== WebSocket.OPEN) {
872
+ return;
873
+ }
874
+ const message =
875
+ handler && typeof handler.formatAutomationStateSnapshot === "function"
876
+ ? handler.formatAutomationStateSnapshot({
877
+ ok: false,
878
+ requestId,
879
+ state: null,
880
+ reasonCode: "snapshot_unavailable",
881
+ message: "Automation state snapshot is unavailable",
882
+ })
883
+ : JSON.stringify({
884
+ type: APP_PROTOCOL.automationStateSnapshot,
885
+ ok: false,
886
+ requestId,
887
+ state: null,
888
+ reasonCode: "snapshot_unavailable",
889
+ message: "Automation state snapshot is unavailable",
890
+ });
891
+ sendMessageToClient(pending.requesterClientId, requesterWs, message);
892
+ }
893
+
894
+ function buildAutomationStateDispatchResult(senderId, parsed) {
895
+ const requestId = parseOptionalTrimmedString(parsed && parsed.requestId);
896
+ const sessionKey = parseOptionalTrimmedString(parsed && parsed.sessionKey);
897
+ const snapshot = getReadinessSnapshot();
898
+ const targetEntry =
899
+ snapshot &&
900
+ snapshot.connectedClientCount === 1 &&
901
+ snapshot.fanoutRecipientCount === 1 &&
902
+ Array.isArray(snapshot.clients) &&
903
+ snapshot.clients.length === 1
904
+ ? snapshot.clients[0]
905
+ : null;
906
+ const targetClientId =
907
+ targetEntry && typeof targetEntry.clientId === "string"
908
+ ? targetEntry.clientId
909
+ : null;
910
+ const readinessPublished =
911
+ !!(
912
+ targetEntry &&
913
+ targetEntry.readinessSnapshot &&
914
+ Number.isFinite(targetEntry.readinessSnapshot.emittedAtMs)
915
+ );
916
+
917
+ const formatFailure = (reasonCode, message) => ({
918
+ unicast:
919
+ handler && typeof handler.formatAutomationStateSnapshot === "function"
920
+ ? handler.formatAutomationStateSnapshot({
921
+ ok: false,
922
+ requestId,
923
+ reasonCode,
924
+ message,
925
+ })
926
+ : JSON.stringify({
927
+ type: APP_PROTOCOL.automationStateSnapshot,
928
+ ok: false,
929
+ requestId,
930
+ reasonCode,
931
+ message,
932
+ }),
933
+ });
934
+
935
+ if (!requestId) {
936
+ return formatFailure(
937
+ "snapshot_unavailable",
938
+ "automation state request requires requestId",
939
+ );
940
+ }
941
+ if (
942
+ !snapshot ||
943
+ snapshot.connectedClientCount <= 0 ||
944
+ snapshot.fanoutRecipientCount <= 0
945
+ ) {
946
+ return formatFailure("no_downstream_client", "No downstream app client connected");
947
+ }
948
+ if (
949
+ snapshot.connectedClientCount > 1 ||
950
+ snapshot.fanoutRecipientCount > 1 ||
951
+ !targetClientId
952
+ ) {
953
+ return formatFailure(
954
+ "multi_recipient_fanout",
955
+ "Multiple downstream app clients connected",
956
+ );
957
+ }
958
+ if (!readinessPublished) {
959
+ return formatFailure(
960
+ "snapshot_unavailable",
961
+ "Automation state snapshot is unavailable",
962
+ );
963
+ }
964
+ return {
965
+ automationStateRequest: {
966
+ requestId,
967
+ targetClientId,
968
+ message:
969
+ handler && typeof handler.formatAutomationStateRequest === "function"
970
+ ? handler.formatAutomationStateRequest({
971
+ requestId,
972
+ sessionKey: sessionKey || null,
973
+ })
974
+ : JSON.stringify({
975
+ type: APP_PROTOCOL.automationStateGet,
976
+ requestId,
977
+ sessionKey: sessionKey || null,
978
+ }),
979
+ },
980
+ };
981
+ }
982
+
1115
983
  function getServerResumeState() {
1116
984
  if (!getCurrentResumeState) {
1117
985
  return { pagesRevision: null, statusRevision: null };
1118
986
  }
1119
987
  const state = getCurrentResumeState() || {};
1120
988
  return {
1121
- pagesRevision: parseRevision(state.pagesRevision),
1122
- statusRevision: parseRevision(state.statusRevision),
989
+ pagesRevision: RelayWorkerProtocol.parseNonNegativeRevision(
990
+ state.pagesRevision,
991
+ ),
992
+ statusRevision: RelayWorkerProtocol.parseNonNegativeRevision(
993
+ state.statusRevision,
994
+ ),
1123
995
  };
1124
996
  }
1125
997
 
1126
998
  function formatResumeAck(payload) {
1127
- return JSON.stringify({
1128
- type: APP_PROTOCOL.resumeAck,
1129
- reason: payload.reason || null,
1130
- sentPages: !!payload.sentPages,
1131
- sentStatus: !!payload.sentStatus,
1132
- sentApprovals:
1133
- Number.isFinite(payload.sentApprovals) && payload.sentApprovals > 0
1134
- ? Math.floor(payload.sentApprovals)
1135
- : 0,
1136
- pagesRevision:
1137
- payload.pagesRevision === null || payload.pagesRevision === undefined
1138
- ? null
1139
- : payload.pagesRevision,
1140
- statusRevision:
1141
- payload.statusRevision === null || payload.statusRevision === undefined
1142
- ? null
1143
- : payload.statusRevision,
1144
- });
999
+ return RelayWorkerProtocol.formatResumeAck(payload);
1145
1000
  }
1146
1001
 
1147
1002
  function isSnapshotSynced(sync) {
@@ -1195,10 +1050,10 @@ function createDownstreamServer(opts) {
1195
1050
  const status = getCurrentStatus();
1196
1051
  const debugConfig = getCurrentDebugConfig ? getCurrentDebugConfig() : null;
1197
1052
  const state = getServerResumeState();
1198
- const clientPagesRevision = parseRevision(
1053
+ const clientPagesRevision = RelayWorkerProtocol.parseNonNegativeRevision(
1199
1054
  resumeRequest && resumeRequest.pagesRevision,
1200
1055
  );
1201
- const clientStatusRevision = parseRevision(
1056
+ const clientStatusRevision = RelayWorkerProtocol.parseNonNegativeRevision(
1202
1057
  resumeRequest && resumeRequest.statusRevision,
1203
1058
  );
1204
1059
  const clientHasPagesState = parseBool(
@@ -1283,12 +1138,6 @@ function createDownstreamServer(opts) {
1283
1138
  if (opts.httpServer) {
1284
1139
  wss = new WebSocketServer({ noServer: true });
1285
1140
  opts.httpServer.on("upgrade", (req, socket, head) => {
1286
- const url = new URL(req.url, `http://${req.headers.host}`);
1287
- if (url.searchParams.get("token") !== token) {
1288
- socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1289
- socket.destroy();
1290
- return;
1291
- }
1292
1141
  wss.handleUpgrade(req, socket, head, (ws) => {
1293
1142
  wss.emit("connection", ws, req);
1294
1143
  });
@@ -1297,14 +1146,15 @@ function createDownstreamServer(opts) {
1297
1146
  wss = new WebSocketServer({
1298
1147
  host: opts.host,
1299
1148
  port: opts.port,
1300
- verifyClient: ({ req }) => {
1301
- const url = new URL(req.url, `http://${req.headers.host}`);
1302
- return url.searchParams.get("token") === token;
1303
- },
1304
1149
  });
1305
1150
  }
1306
1151
 
1307
1152
  wss.on("connection", (ws, req) => {
1153
+ const requestUrl = new URL(req.url, `http://${req.headers.host}`);
1154
+ if (requestUrl.searchParams.get("token") !== token) {
1155
+ ws.close(4001, "invalid_token");
1156
+ return;
1157
+ }
1308
1158
  const clientId = `client-${nextClientId++}`;
1309
1159
  clients.set(clientId, ws);
1310
1160
  const connectedAtMs = Date.now();
@@ -1321,7 +1171,7 @@ function createDownstreamServer(opts) {
1321
1171
  connectedEventEmitted: false,
1322
1172
  };
1323
1173
  ensureProtocolState(clientId);
1324
- clientNudgeState.set(clientId, createClientNudgeState());
1174
+ nudgeController.addClient(clientId);
1325
1175
  const currentPages = getCurrentPages();
1326
1176
  const currentStatus = getCurrentStatus();
1327
1177
  syncState.set(clientId, {
@@ -1380,7 +1230,7 @@ function createDownstreamServer(opts) {
1380
1230
 
1381
1231
  // Intercept application-level ping — respond with pong, skip handler
1382
1232
  if (enrichedPing) {
1383
- updateClientNudgeHeartbeat(clientId, enrichedPing);
1233
+ nudgeController.updateHeartbeat(clientId, enrichedPing);
1384
1234
  ws.send(JSON.stringify({ type: "pong", ts: parsed.ts }));
1385
1235
  return;
1386
1236
  }
@@ -1486,6 +1336,14 @@ function createDownstreamServer(opts) {
1486
1336
  return;
1487
1337
  }
1488
1338
 
1339
+ const automationStateSnapshot = parseAutomationStateSnapshot(parsed);
1340
+ if (automationStateSnapshot) {
1341
+ if (classifyClientKind(state) === "app") {
1342
+ forwardAutomationStateSnapshot(clientId, automationStateSnapshot);
1343
+ }
1344
+ return;
1345
+ }
1346
+
1489
1347
  if (parsed && parsed.type === APP_PROTOCOL.resume) {
1490
1348
  const sync = syncState.get(clientId);
1491
1349
  if (sync) sync.resumeReceived = true;
@@ -1496,15 +1354,25 @@ function createDownstreamServer(opts) {
1496
1354
  return;
1497
1355
  }
1498
1356
 
1357
+ if (parsed && parsed.type === APP_PROTOCOL.automationStateGet) {
1358
+ const result = handler.handleMessage(clientId, raw);
1359
+ if (result !== null && result !== undefined) {
1360
+ processResult(clientId, result);
1361
+ } else {
1362
+ processResult(clientId, buildAutomationStateDispatchResult(clientId, parsed));
1363
+ }
1364
+ return;
1365
+ }
1366
+
1499
1367
  const visibilityControl = parseVisibilityControl(parsed);
1500
1368
  const drainCompleteControl = parseDrainCompleteControl(parsed);
1501
1369
  const transportControl = visibilityControl || drainCompleteControl;
1502
1370
  if (transportControl) {
1503
1371
  const protocol = protocolState.get(clientId);
1504
1372
  if (transportControl.type === "visibility") {
1505
- updateClientVisibilityState(clientId, transportControl.state);
1373
+ nudgeController.updateVisibilityState(clientId, transportControl.state);
1506
1374
  } else {
1507
- setClientInteractionStage(clientId, "idle", {
1375
+ nudgeController.setInteractionStage(clientId, "idle", {
1508
1376
  reason: "drain_complete",
1509
1377
  deactivateImmediately: true,
1510
1378
  });
@@ -1537,6 +1405,229 @@ function createDownstreamServer(opts) {
1537
1405
  return;
1538
1406
  }
1539
1407
 
1408
+ if (parsed && parsed.type === APP_PROTOCOL.updatePlugin) {
1409
+ if (!runPluginUpdate) {
1410
+ logger.warn(
1411
+ `[downstream] ${clientId} updatePlugin requested but no runPluginUpdate is configured`,
1412
+ );
1413
+ return;
1414
+ }
1415
+ const requestId =
1416
+ parseOptionalTrimmedString(parsed.requestId) || null;
1417
+ logger.info(
1418
+ `[downstream] ${clientId} updatePlugin requested requestId=${requestId || "n/a"}`,
1419
+ );
1420
+ runPluginUpdate()
1421
+ .then((result) => {
1422
+ if (ws.readyState !== WebSocket.OPEN) return;
1423
+ const payload = { type: APP_PROTOCOL.updatePluginResult };
1424
+ if (requestId) payload.requestId = requestId;
1425
+ if (result && result.ok === true) {
1426
+ payload.ok = true;
1427
+ } else {
1428
+ payload.ok = false;
1429
+ if (result && typeof result.reason === "string") {
1430
+ payload.reason = result.reason;
1431
+ }
1432
+ if (result && typeof result.exitCode === "number") {
1433
+ payload.exitCode = result.exitCode;
1434
+ }
1435
+ if (result && typeof result.stderrTail === "string" && result.stderrTail.length > 0) {
1436
+ payload.stderrTail = result.stderrTail;
1437
+ }
1438
+ }
1439
+ sendMessageToClient(clientId, ws, JSON.stringify(payload));
1440
+ })
1441
+ .catch((err) => {
1442
+ logger.error(
1443
+ `[downstream] ${clientId} updatePlugin threw: ${err && err.message ? err.message : err}`,
1444
+ );
1445
+ if (ws.readyState !== WebSocket.OPEN) return;
1446
+ const payload = {
1447
+ type: APP_PROTOCOL.updatePluginResult,
1448
+ ok: false,
1449
+ reason: "spawn_failed",
1450
+ };
1451
+ if (requestId) payload.requestId = requestId;
1452
+ sendMessageToClient(clientId, ws, JSON.stringify(payload));
1453
+ });
1454
+ return;
1455
+ }
1456
+
1457
+ if (parsed && parsed.type === APP_PROTOCOL.restartGateway) {
1458
+ if (!runGatewayRestart) {
1459
+ logger.warn(
1460
+ `[downstream] ${clientId} restartGateway requested but no runGatewayRestart is configured`,
1461
+ );
1462
+ return;
1463
+ }
1464
+ const requestId =
1465
+ parseOptionalTrimmedString(parsed.requestId) || null;
1466
+ logger.info(
1467
+ `[downstream] ${clientId} restartGateway requested requestId=${requestId || "n/a"}`,
1468
+ );
1469
+ runGatewayRestart()
1470
+ .then((result) => {
1471
+ if (ws.readyState !== WebSocket.OPEN) return;
1472
+ const payload = {
1473
+ type: APP_PROTOCOL.restartGatewayAck,
1474
+ ok: !!(result && result.ok),
1475
+ started: !!(result && result.started),
1476
+ };
1477
+ if (requestId) payload.requestId = requestId;
1478
+ if (result && typeof result.reason === "string") {
1479
+ payload.reason = result.reason;
1480
+ }
1481
+ sendMessageToClient(clientId, ws, JSON.stringify(payload));
1482
+ })
1483
+ .catch((err) => {
1484
+ logger.error(
1485
+ `[downstream] ${clientId} restartGateway threw: ${err && err.message ? err.message : err}`,
1486
+ );
1487
+ if (ws.readyState !== WebSocket.OPEN) return;
1488
+ const payload = {
1489
+ type: APP_PROTOCOL.restartGatewayAck,
1490
+ ok: false,
1491
+ started: false,
1492
+ reason: "spawn_failed",
1493
+ };
1494
+ if (requestId) payload.requestId = requestId;
1495
+ sendMessageToClient(clientId, ws, JSON.stringify(payload));
1496
+ });
1497
+ return;
1498
+ }
1499
+
1500
+ if (parsed && parsed.type === "ocuclaw.session.pinned.set") {
1501
+ if (!sessionService) {
1502
+ if (ws.readyState === WebSocket.OPEN) {
1503
+ sendMessageToClient(
1504
+ clientId,
1505
+ ws,
1506
+ JSON.stringify({ type: "error", error: "session_service_unavailable" }),
1507
+ );
1508
+ }
1509
+ return;
1510
+ }
1511
+ const sessionKey = typeof parsed.sessionKey === "string" ? parsed.sessionKey : "";
1512
+ const pinned = parsed.pinned;
1513
+ const kind = parsed.kind;
1514
+ if (
1515
+ !sessionKey ||
1516
+ typeof pinned !== "boolean" ||
1517
+ (kind !== "ocuclaw" && kind !== "evenai")
1518
+ ) {
1519
+ if (ws.readyState === WebSocket.OPEN) {
1520
+ sendMessageToClient(
1521
+ clientId,
1522
+ ws,
1523
+ JSON.stringify({ type: "error", error: "invalid_session_pin_request" }),
1524
+ );
1525
+ }
1526
+ return;
1527
+ }
1528
+ const result = sessionService.setSessionPinned(kind, sessionKey, pinned);
1529
+ if (!result.ok && result.reason === "cap") {
1530
+ if (ws.readyState === WebSocket.OPEN) {
1531
+ sendMessageToClient(
1532
+ clientId,
1533
+ ws,
1534
+ JSON.stringify({ type: "error", error: "pin_cap_reached" }),
1535
+ );
1536
+ }
1537
+ return;
1538
+ }
1539
+ if (!result.ok) {
1540
+ if (ws.readyState === WebSocket.OPEN) {
1541
+ sendMessageToClient(
1542
+ clientId,
1543
+ ws,
1544
+ JSON.stringify({ type: "error", error: "invalid_session_pin_request" }),
1545
+ );
1546
+ }
1547
+ return;
1548
+ }
1549
+ if (typeof sessionService.broadcastSessionsForKind === "function") {
1550
+ Promise.resolve(sessionService.broadcastSessionsForKind(kind)).catch((err) => {
1551
+ logger.error(
1552
+ `[downstream] broadcastSessionsForKind failed: ${err && err.message ? err.message : err}`,
1553
+ );
1554
+ });
1555
+ }
1556
+ return;
1557
+ }
1558
+
1559
+ if (parsed && parsed.type === "ocuclaw.session.delete") {
1560
+ if (!sessionService) {
1561
+ if (ws.readyState === WebSocket.OPEN) {
1562
+ sendMessageToClient(
1563
+ clientId,
1564
+ ws,
1565
+ JSON.stringify({ type: "error", error: "session_service_unavailable" }),
1566
+ );
1567
+ }
1568
+ return;
1569
+ }
1570
+ const sessionKeys = Array.isArray(parsed.sessionKeys) ? parsed.sessionKeys : null;
1571
+ const kind = parsed.kind;
1572
+ const switchBeforeDelete = parsed.switchBeforeDelete === true;
1573
+ if (
1574
+ !sessionKeys ||
1575
+ sessionKeys.length === 0 ||
1576
+ sessionKeys.some((k) => typeof k !== "string" || !k) ||
1577
+ (kind !== "ocuclaw" && kind !== "evenai")
1578
+ ) {
1579
+ if (ws.readyState === WebSocket.OPEN) {
1580
+ sendMessageToClient(
1581
+ clientId,
1582
+ ws,
1583
+ JSON.stringify({ type: "error", error: "invalid_session_delete_request" }),
1584
+ );
1585
+ }
1586
+ return;
1587
+ }
1588
+ const action = switchBeforeDelete
1589
+ ? sessionService.switchAndDeleteSessions(kind, sessionKeys)
1590
+ : sessionService.deleteSessions(kind, sessionKeys);
1591
+ Promise.resolve(action)
1592
+ .then(() => {
1593
+ if (typeof sessionService.broadcastSessionsForKind === "function") {
1594
+ return sessionService.broadcastSessionsForKind(kind);
1595
+ }
1596
+ return null;
1597
+ })
1598
+ .catch((err) => {
1599
+ logger.error(
1600
+ `[downstream] session.delete pipeline failed: ${err && err.message ? err.message : err}`,
1601
+ );
1602
+ });
1603
+ return;
1604
+ }
1605
+
1606
+ if (parsed && parsed.type === APP_PROTOCOL.avatarFetch) {
1607
+ if (state.selectedVersion !== "v2") return;
1608
+ const requestedAgentName =
1609
+ typeof parsed.agentName === "string" ? parsed.agentName : null;
1610
+ const requestedHash =
1611
+ typeof parsed.hash === "string" && /^[0-9a-f]{64}$/.test(parsed.hash)
1612
+ ? parsed.hash
1613
+ : null;
1614
+ if (!requestedAgentName || !requestedHash) {
1615
+ // Malformed request — silently drop. The WebUI guards against this.
1616
+ return;
1617
+ }
1618
+ const dataUri = getAgentAvatarDataUriByHash(requestedHash);
1619
+ const payload = {
1620
+ type: APP_PROTOCOL.avatarBlob,
1621
+ agentName: requestedAgentName,
1622
+ hash: requestedHash,
1623
+ dataUri: dataUri || null,
1624
+ };
1625
+ if (ws.readyState === WebSocket.OPEN) {
1626
+ sendMessageToClient(clientId, ws, JSON.stringify(payload));
1627
+ }
1628
+ return;
1629
+ }
1630
+
1540
1631
  const result = handler.handleMessage(clientId, raw);
1541
1632
  processResult(clientId, result);
1542
1633
  });
@@ -1551,8 +1642,8 @@ function createDownstreamServer(opts) {
1551
1642
  protocolState.delete(clientId);
1552
1643
  clientReadinessSnapshotState.delete(clientId);
1553
1644
  clearPendingReadinessProbesForClient(clientId);
1554
- clearClientNudgeTimers(clientId);
1555
- clientNudgeState.delete(clientId);
1645
+ clearPendingAutomationStateRequestsForClient(clientId);
1646
+ nudgeController.deleteClient(clientId);
1556
1647
  handler.removeClient(clientId);
1557
1648
  clients.delete(clientId);
1558
1649
  const lifetimeMs = Date.now() - connectedAtMs;
@@ -1598,15 +1689,36 @@ function createDownstreamServer(opts) {
1598
1689
  * @param {string} senderId - Client ID that sent the original message
1599
1690
  * @param {object|null|Promise} result - Handler return value
1600
1691
  */
1692
+ function sendUnicastResult(senderId, unicast) {
1693
+ const frames = Array.isArray(unicast) ? unicast : [unicast];
1694
+ const ws = clients.get(senderId);
1695
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
1696
+ for (const frame of frames) {
1697
+ if (typeof frame === "string") {
1698
+ sendMessageToClient(senderId, ws, frame);
1699
+ }
1700
+ }
1701
+ }
1702
+
1601
1703
  function processResult(senderId, result) {
1602
1704
  if (result === null || result === undefined) {
1603
1705
  return;
1604
1706
  }
1605
1707
 
1606
1708
  if (typeof result.then === "function") {
1709
+ pendingHandlerPromiseCount += 1;
1710
+ if (relayHealth && typeof relayHealth.emitQueueDepth === "function") {
1711
+ relayHealth.emitQueueDepth({
1712
+ pendingHandlerPromises: pendingHandlerPromiseCount,
1713
+ });
1714
+ }
1607
1715
  result.then(
1608
- (resolved) => processResult(senderId, resolved),
1716
+ (resolved) => {
1717
+ pendingHandlerPromiseCount = Math.max(0, pendingHandlerPromiseCount - 1);
1718
+ processResult(senderId, resolved);
1719
+ },
1609
1720
  (err) => {
1721
+ pendingHandlerPromiseCount = Math.max(0, pendingHandlerPromiseCount - 1);
1610
1722
  logger.error(
1611
1723
  `[downstream] Error processing message from ${senderId}:`,
1612
1724
  err,
@@ -1617,10 +1729,11 @@ function createDownstreamServer(opts) {
1617
1729
  }
1618
1730
 
1619
1731
  if (result.unicast) {
1620
- const ws = clients.get(senderId);
1621
- if (ws && ws.readyState === WebSocket.OPEN) {
1622
- sendMessageToClient(senderId, ws, result.unicast);
1623
- }
1732
+ sendUnicastResult(senderId, result.unicast);
1733
+ }
1734
+
1735
+ if (result.followup && typeof result.followup.then === "function") {
1736
+ processResult(senderId, result.followup);
1624
1737
  }
1625
1738
 
1626
1739
  if (result.readinessProbe) {
@@ -1671,6 +1784,58 @@ function createDownstreamServer(opts) {
1671
1784
  }
1672
1785
  }
1673
1786
 
1787
+ if (result.automationStateRequest) {
1788
+ const requestId = parseOptionalTrimmedString(
1789
+ result.automationStateRequest.requestId,
1790
+ );
1791
+ const targetClientId = parseOptionalTrimmedString(
1792
+ result.automationStateRequest.targetClientId,
1793
+ );
1794
+ const message =
1795
+ typeof result.automationStateRequest.message === "string"
1796
+ ? result.automationStateRequest.message
1797
+ : null;
1798
+ const targetWs = targetClientId ? clients.get(targetClientId) : null;
1799
+ if (
1800
+ !requestId ||
1801
+ !targetClientId ||
1802
+ !message ||
1803
+ !targetWs ||
1804
+ targetWs.readyState !== WebSocket.OPEN ||
1805
+ !isAppClient(targetClientId)
1806
+ ) {
1807
+ const requesterWs = clients.get(senderId);
1808
+ if (
1809
+ requesterWs &&
1810
+ requesterWs.readyState === WebSocket.OPEN &&
1811
+ handler &&
1812
+ typeof handler.formatAutomationStateSnapshot === "function"
1813
+ ) {
1814
+ sendMessageToClient(
1815
+ senderId,
1816
+ requesterWs,
1817
+ handler.formatAutomationStateSnapshot({
1818
+ ok: false,
1819
+ requestId,
1820
+ reasonCode: "snapshot_unavailable",
1821
+ message: "Automation state snapshot is unavailable",
1822
+ }),
1823
+ );
1824
+ }
1825
+ } else {
1826
+ pendingAutomationStateRequests.set(requestId, {
1827
+ requesterClientId: senderId,
1828
+ targetClientId,
1829
+ createdAtMs: Date.now(),
1830
+ expiryTimer: setTimeout(
1831
+ () => expirePendingAutomationStateRequest(requestId),
1832
+ AUTOMATION_STATE_REPLY_TIMEOUT_MS,
1833
+ ),
1834
+ });
1835
+ sendMessageToClient(targetClientId, targetWs, message);
1836
+ }
1837
+ }
1838
+
1674
1839
  if (result.broadcast) {
1675
1840
  if (Array.isArray(result.broadcast)) {
1676
1841
  for (const msg of result.broadcast) broadcast(msg);
@@ -1731,11 +1896,7 @@ function createDownstreamServer(opts) {
1731
1896
  : null;
1732
1897
  for (const [clientId, ws] of clients) {
1733
1898
  if (ws.readyState === WebSocket.OPEN) {
1734
- sendMessageToClient(clientId, ws, message);
1735
- if (relayStreamingActivityAtMs !== null) {
1736
- ensureClientNudgeState(clientId).lastRelayStreamingActivityAtMs =
1737
- relayStreamingActivityAtMs;
1738
- }
1899
+ sendMessageToClient(clientId, ws, message, messageType);
1739
1900
  if (
1740
1901
  messageType === "pages" ||
1741
1902
  messageType === "status" ||
@@ -1746,7 +1907,11 @@ function createDownstreamServer(opts) {
1746
1907
  }
1747
1908
  }
1748
1909
  }
1749
- applyBroadcastInteractionStage(messageType, parsed, relayStreamingActivityAtMs);
1910
+ nudgeController.applyBroadcastInteractionStage(
1911
+ messageType,
1912
+ parsed,
1913
+ relayStreamingActivityAtMs,
1914
+ );
1750
1915
  }
1751
1916
 
1752
1917
  /**
@@ -1772,7 +1937,7 @@ function createDownstreamServer(opts) {
1772
1937
  if (ws.readyState !== WebSocket.OPEN) {
1773
1938
  continue;
1774
1939
  }
1775
- sendMessageToClient(clientId, ws, message);
1940
+ sendMessageToClient(clientId, ws, message, messageType);
1776
1941
  if (
1777
1942
  messageType === "pages" ||
1778
1943
  messageType === "status" ||
@@ -1807,8 +1972,8 @@ function createDownstreamServer(opts) {
1807
1972
  return Array.from(clients.keys());
1808
1973
  }
1809
1974
 
1810
- function getConnectedAppCount(excludeClientId = null) {
1811
- return countConnectedAppClients(excludeClientId);
1975
+ function getConnectedAppCount(excludeClientId = null, sessionKey = null) {
1976
+ return countConnectedAppClients(excludeClientId, sessionKey);
1812
1977
  }
1813
1978
 
1814
1979
  function getReadinessSnapshot() {
@@ -1853,12 +2018,13 @@ function createDownstreamServer(opts) {
1853
2018
  if (sync.timer) clearTimeout(sync.timer);
1854
2019
  }
1855
2020
  syncState.clear();
1856
- for (const [clientId] of clientNudgeState) {
1857
- clearClientNudgeTimers(clientId);
1858
- }
1859
- clientNudgeState.clear();
2021
+ nudgeController.clear();
1860
2022
  clientReadinessSnapshotState.clear();
1861
2023
  pendingReadinessProbeRequests.clear();
2024
+ for (const [, pending] of pendingAutomationStateRequests) {
2025
+ if (pending && pending.expiryTimer) clearTimeout(pending.expiryTimer);
2026
+ }
2027
+ pendingAutomationStateRequests.clear();
1862
2028
  for (const [, ws] of clients) {
1863
2029
  ws.close();
1864
2030
  }
@@ -1875,7 +2041,7 @@ function createDownstreamServer(opts) {
1875
2041
  getReadinessSnapshot,
1876
2042
  closeConnectedAppClients,
1877
2043
  getClientNudgeState(clientId) {
1878
- return cloneClientNudgeState(clientNudgeState.get(clientId) || null);
2044
+ return nudgeController.getClientState(clientId);
1879
2045
  },
1880
2046
  close,
1881
2047
  get httpServer() {