ocuclaw 0.1.0 → 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 +63 -8
  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 +41 -184
  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 +909 -68
  27. package/dist/runtime/downstream-server.js +1004 -512
  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 +1357 -210
  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 +656 -38
  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,54 +68,93 @@ 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();
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}>} */
114
+ const clientReadinessSnapshotState = new Map();
115
+ /** @type {Map<string, {requesterClientId: string, targetClientId: string, createdAtMs: number}>} */
116
+ const pendingReadinessProbeRequests = new Map();
117
+ /** @type {Map<string, {requesterClientId: string, targetClientId: string, createdAtMs: number, expiryTimer: any}>} */
118
+ const pendingAutomationStateRequests = new Map();
100
119
  /** @type {Map<string, string>} */
101
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
+ });
102
137
  let nextClientId = 1;
138
+ let pendingHandlerPromiseCount = 0;
103
139
  const APP_PROTOCOL = {
104
140
  approvalRequest: "ocuclaw.approval.request",
105
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",
106
146
  debugConfigSnapshot: "ocuclaw.debug.config.snapshot",
107
147
  pages: "ocuclaw.view.pages.snapshot",
148
+ readinessProbeAck: "ocuclaw.readiness.probe.ack",
149
+ readinessProbeRequest: "ocuclaw.readiness.probe.request",
150
+ readinessSnapshot: "ocuclaw.readiness.snapshot",
108
151
  resume: "ocuclaw.sync.resume",
109
152
  resumeAck: "ocuclaw.sync.resume.ack",
153
+ restartGateway: "ocuclaw.restartGateway",
154
+ restartGatewayAck: "ocuclaw.restartGatewayAck",
110
155
  status: "ocuclaw.runtime.status",
156
+ updatePlugin: "ocuclaw.updatePlugin",
157
+ updatePluginResult: "ocuclaw.updatePluginResult",
111
158
  };
112
159
  const REMOVED_V1_APP_TYPES = new Set([
113
160
  "approvalResponse",
@@ -149,7 +196,10 @@ function createDownstreamServer(opts) {
149
196
  }
150
197
 
151
198
  const nextRole =
152
- messageType === "remote-control" ? "control" : "app";
199
+ messageType === "remote-control" ||
200
+ messageType === APP_PROTOCOL.automationStateGet
201
+ ? "control"
202
+ : "app";
153
203
  if (meta.role === "unknown") {
154
204
  meta.role = nextRole;
155
205
  return;
@@ -159,13 +209,6 @@ function createDownstreamServer(opts) {
159
209
  }
160
210
  }
161
211
 
162
- function parseRevision(value) {
163
- if (!Number.isFinite(Number(value))) return null;
164
- const num = Math.floor(Number(value));
165
- if (num < 0) return null;
166
- return num;
167
- }
168
-
169
212
  function parseBool(value) {
170
213
  if (value === true || value === false) return value;
171
214
  if (typeof value === "number") return value !== 0;
@@ -174,6 +217,20 @@ function createDownstreamServer(opts) {
174
217
  return normalized === "true" || normalized === "1" || normalized === "yes";
175
218
  }
176
219
 
220
+ function parseOptionalBoolean(value) {
221
+ if (value === true || value === false) return value;
222
+ if (typeof value === "number") return value !== 0;
223
+ if (typeof value !== "string") return null;
224
+ const normalized = value.trim().toLowerCase();
225
+ if (normalized === "true" || normalized === "1" || normalized === "yes") {
226
+ return true;
227
+ }
228
+ if (normalized === "false" || normalized === "0" || normalized === "no") {
229
+ return false;
230
+ }
231
+ return null;
232
+ }
233
+
177
234
  function parseOptionalTrimmedString(value) {
178
235
  if (typeof value !== "string") return null;
179
236
  const trimmed = value.trim();
@@ -262,6 +319,134 @@ function createDownstreamServer(opts) {
262
319
  parseOptionalTrimmedString(value.clientId),
263
320
  clientVersion: parseOptionalTrimmedString(value.clientVersion),
264
321
  sessionKey: parseOptionalTrimmedString(value.sessionKey),
322
+ readinessSnapshot: parseReadinessSnapshot(value.readinessSnapshot),
323
+ };
324
+ }
325
+
326
+ function parseReadinessBundleIdentity(value) {
327
+ if (!value || typeof value !== "object") {
328
+ return null;
329
+ }
330
+ const kind = parseOptionalTrimmedString(value.kind || value.lane);
331
+ const mode = parseOptionalTrimmedString(value.mode);
332
+ const host = parseOptionalTrimmedString(value.host);
333
+ const port = parseOptionalNonNegativeInt(value.port);
334
+ const servedDistPath =
335
+ parseOptionalTrimmedString(value.servedDistPath) ||
336
+ parseOptionalTrimmedString(value.staticDir);
337
+ if (!kind && !mode && !host && port === null && !servedDistPath) {
338
+ return null;
339
+ }
340
+ return {
341
+ kind: kind || null,
342
+ mode: mode || null,
343
+ host: host || null,
344
+ port,
345
+ servedDistPath: servedDistPath || null,
346
+ };
347
+ }
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
+
370
+ function parseReadinessSnapshot(value) {
371
+ if (!value || typeof value !== "object") {
372
+ return null;
373
+ }
374
+ const clientDebugEnabled = parseOptionalBoolean(value.clientDebugEnabled);
375
+ const runtimeDiagnosticsVisible = parseOptionalBoolean(
376
+ value.runtimeDiagnosticsVisible,
377
+ );
378
+ const perfNoisyDebugMuted = parseOptionalBoolean(value.perfNoisyDebugMuted);
379
+ const perfPayloadLiteMode = parseOptionalBoolean(value.perfPayloadLiteMode);
380
+ const activeSessionKey =
381
+ parseOptionalTrimmedString(value.activeSessionKey) ||
382
+ parseOptionalTrimmedString(value.sessionKey);
383
+ const bundleIdentity = parseReadinessBundleIdentity(
384
+ value.bundleIdentity || value.bundle,
385
+ );
386
+ const runtimeContext = parseReadinessRuntimeContext(value.runtimeContext);
387
+ const glasses = parseReadinessGlasses(value.glasses);
388
+ const emittedAtMs = parseOptionalNonNegativeInt(value.emittedAtMs);
389
+ if (
390
+ clientDebugEnabled === null &&
391
+ runtimeDiagnosticsVisible === null &&
392
+ perfNoisyDebugMuted === null &&
393
+ perfPayloadLiteMode === null &&
394
+ !activeSessionKey &&
395
+ !bundleIdentity &&
396
+ !runtimeContext &&
397
+ !glasses &&
398
+ emittedAtMs === null
399
+ ) {
400
+ return null;
401
+ }
402
+ return {
403
+ clientDebugEnabled,
404
+ runtimeDiagnosticsVisible,
405
+ perfNoisyDebugMuted,
406
+ perfPayloadLiteMode,
407
+ activeSessionKey: activeSessionKey || null,
408
+ bundleIdentity,
409
+ runtimeContext,
410
+ glasses,
411
+ emittedAtMs,
412
+ updatedAtMs: Date.now(),
413
+ };
414
+ }
415
+
416
+ function parseReadinessProbeAck(value) {
417
+ if (!value || value.type !== APP_PROTOCOL.readinessProbeAck) {
418
+ return null;
419
+ }
420
+ const requestId = parseOptionalTrimmedString(value.requestId);
421
+ if (!requestId) {
422
+ return null;
423
+ }
424
+ return {
425
+ requestId,
426
+ ok: value.ok !== false,
427
+ reasonCode: parseOptionalTrimmedString(value.reasonCode),
428
+ message: parseOptionalTrimmedString(value.message),
429
+ activeSessionKey:
430
+ parseOptionalTrimmedString(value.activeSessionKey) ||
431
+ parseOptionalTrimmedString(value.sessionKey),
432
+ emittedAtMs: parseOptionalNonNegativeInt(value.emittedAtMs),
433
+ };
434
+ }
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),
265
450
  };
266
451
  }
267
452
 
@@ -292,7 +477,7 @@ function createDownstreamServer(opts) {
292
477
  return classifyClientKindFromName(clientName);
293
478
  }
294
479
 
295
- function countConnectedAppClients(excludeClientId = null) {
480
+ function countConnectedAppClients(excludeClientId = null, sessionKey = null) {
296
481
  let count = 0;
297
482
  for (const [clientId, ws] of clients) {
298
483
  if (excludeClientId && clientId === excludeClientId) {
@@ -301,7 +486,14 @@ function createDownstreamServer(opts) {
301
486
  if (!ws || ws.readyState !== WebSocket.OPEN) {
302
487
  continue;
303
488
  }
304
- 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)) {
305
497
  continue;
306
498
  }
307
499
  count += 1;
@@ -349,27 +541,16 @@ function createDownstreamServer(opts) {
349
541
  };
350
542
  }
351
543
 
352
- function interactionStageBucket(stage) {
353
- switch (stage) {
354
- case "listening":
355
- case "voice_handoff":
356
- case "thinking":
357
- return "active_non_stream";
358
- case "streaming":
359
- case "post_turn_drain":
360
- return "active_stream";
361
- default:
362
- return "idle";
363
- }
364
- }
365
-
366
544
  function formatProtocolHelloAck(payload) {
367
- return JSON.stringify({
368
- type: "protocolHelloAck",
545
+ const pluginVersion = getPluginVersion ? getPluginVersion() : null;
546
+ const requiresClientVersion = getRequiresClientVersion ? getRequiresClientVersion() : null;
547
+ return RelayWorkerProtocol.formatProtocolHelloAck({
369
548
  protocolVersion: payload.protocolVersion,
370
549
  supportedProtocolVersions: payload.supportedProtocolVersions || ["v2"],
371
- reason: payload.reason || null,
372
- deprecatedV1: false,
550
+ reason: payload.reason,
551
+ pluginVersion,
552
+ requiresClientVersion,
553
+ pluginId,
373
554
  });
374
555
  }
375
556
 
@@ -389,436 +570,125 @@ function createDownstreamServer(opts) {
389
570
  return state;
390
571
  }
391
572
 
392
- function createClientNudgeState() {
573
+ function cloneReadinessBundleIdentity(value) {
574
+ if (!value) return null;
393
575
  return {
394
- visibilityState: null,
395
- streamChars: null,
396
- lastHeartbeatAtMs: null,
397
- lastRelayStreamingActivityAtMs: null,
398
- interactionStage: "idle",
399
- cadenceBucket: "idle",
400
- nudgeActive: false,
401
- nudgeIntervalMs: null,
402
- nudgeStartedAtMs: null,
403
- lastNudgeAtMs: null,
404
- stalledHeartbeatCount: 0,
405
- nudgeTimer: null,
406
- idleDeactivateTimer: null,
407
- staleHeartbeatTimer: null,
408
- hardTimeoutTimer: null,
576
+ kind: value.kind || null,
577
+ mode: value.mode || null,
578
+ host: value.host || null,
579
+ port: Number.isFinite(value.port) ? value.port : null,
580
+ servedDistPath: value.servedDistPath || null,
409
581
  };
410
582
  }
411
583
 
412
- function ensureClientNudgeState(clientId) {
413
- let state = clientNudgeState.get(clientId);
414
- if (!state) {
415
- state = createClientNudgeState();
416
- clientNudgeState.set(clientId, state);
417
- }
418
- return state;
419
- }
420
-
421
- function cloneClientNudgeState(state) {
422
- if (!state) return null;
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;
423
595
  return {
424
- visibilityState: state.visibilityState || null,
425
- streamChars: Number.isFinite(state.streamChars) ? state.streamChars : null,
426
- lastHeartbeatAtMs: Number.isFinite(state.lastHeartbeatAtMs)
427
- ? state.lastHeartbeatAtMs
428
- : null,
429
- lastRelayStreamingActivityAtMs: Number.isFinite(
430
- state.lastRelayStreamingActivityAtMs,
431
- )
432
- ? state.lastRelayStreamingActivityAtMs
433
- : null,
434
- interactionStage: state.interactionStage || "idle",
435
- cadenceBucket: state.cadenceBucket || "idle",
436
- nudgeActive: !!state.nudgeActive,
437
- nudgeIntervalMs: Number.isFinite(state.nudgeIntervalMs)
438
- ? state.nudgeIntervalMs
439
- : null,
440
- nudgeStartedAtMs: Number.isFinite(state.nudgeStartedAtMs)
441
- ? state.nudgeStartedAtMs
442
- : null,
443
- lastNudgeAtMs: Number.isFinite(state.lastNudgeAtMs)
444
- ? state.lastNudgeAtMs
445
- : null,
446
- stalledHeartbeatCount: Number.isFinite(state.stalledHeartbeatCount)
447
- ? state.stalledHeartbeatCount
448
- : 0,
596
+ connected: value.connected === true,
597
+ batteryPercent,
598
+ statusAgeMs,
449
599
  };
450
600
  }
451
601
 
452
- function clearClientNudgeTimer(state, key, clearFn = clearTimeout) {
453
- if (!state || !state[key]) return;
454
- clearFn(state[key]);
455
- state[key] = null;
456
- }
457
-
458
- function clearClientNudgeTimers(clientId) {
459
- const state = clientNudgeState.get(clientId);
460
- if (!state) return;
461
- clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
462
- clearClientNudgeTimer(state, "idleDeactivateTimer");
463
- clearClientNudgeTimer(state, "staleHeartbeatTimer");
464
- clearClientNudgeTimer(state, "hardTimeoutTimer");
465
- }
466
-
467
- function isStreamingBroadcastType(messageType) {
468
- return (
469
- messageType === "streaming" ||
470
- messageType === "ocuclaw.message.stream.delta"
471
- );
472
- }
473
-
474
- function isPagesBroadcastType(messageType) {
475
- return messageType === "pages" || messageType === APP_PROTOCOL.pages;
476
- }
477
-
478
- function isActivityBroadcastType(messageType) {
479
- return messageType === "activity" || messageType === "ocuclaw.activity.update";
480
- }
481
-
482
- function isListenCommittedBroadcastType(messageType) {
483
- return messageType === "listen-committed";
484
- }
485
-
486
- function isListenEndedBroadcastType(messageType) {
487
- return messageType === "listen-ended";
488
- }
489
-
490
- function resetClientStallTracking(state) {
491
- if (!state) return;
492
- state.stalledHeartbeatCount = 0;
493
- }
494
-
495
- function hasStaleHeartbeat(state, now = Date.now()) {
496
- if (!state || !Number.isFinite(state.lastHeartbeatAtMs)) {
497
- return false;
498
- }
499
- return now - state.lastHeartbeatAtMs >= nudgeStaleHeartbeatThresholdMs;
500
- }
501
-
502
- function isVisibilityDegraded(state) {
503
- return (
504
- !!state &&
505
- (state.visibilityState === "hidden" || state.visibilityState === "blurred")
506
- );
507
- }
508
-
509
- function sendRenderNudge(clientId) {
510
- const ws = clients.get(clientId);
511
- if (!ws || ws.readyState !== WebSocket.OPEN) {
512
- stopClientNudges(clientId, "socket_unavailable");
513
- return;
514
- }
515
- sendMessageToClient(clientId, ws, RENDER_NUDGE_FRAME);
516
- ensureClientNudgeState(clientId).lastNudgeAtMs = Date.now();
517
- }
518
-
519
- function scheduleClientHardTimeout(clientId) {
520
- const state = clientNudgeState.get(clientId);
521
- if (!state) return;
522
- clearClientNudgeTimer(state, "hardTimeoutTimer");
523
- if (!state.nudgeActive) return;
524
- state.hardTimeoutTimer = setTimeout(() => {
525
- state.hardTimeoutTimer = null;
526
- setClientInteractionStage(clientId, "idle", {
527
- reason: "nudge_hard_timeout",
528
- deactivateImmediately: true,
529
- });
530
- }, nudgeHardTimeoutMs);
531
- }
532
-
533
- function startClientNudges(
534
- clientId,
535
- intervalMs,
536
- reason = "nudge_start",
537
- sendImmediately = false,
538
- ) {
539
- if (!isAppClient(clientId)) return;
540
- const ws = clients.get(clientId);
541
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
542
- const state = ensureClientNudgeState(clientId);
543
- const nextIntervalMs = Math.max(1, Math.floor(intervalMs));
544
- const wasActive = !!state.nudgeActive;
545
- const intervalChanged = state.nudgeIntervalMs !== nextIntervalMs;
546
- state.cadenceBucket = interactionStageBucket(state.interactionStage);
547
- if (!wasActive) {
548
- state.nudgeActive = true;
549
- state.nudgeStartedAtMs = Date.now();
550
- }
551
- if (wasActive && !intervalChanged) {
552
- return;
553
- }
554
- clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
555
- state.nudgeActive = true;
556
- state.nudgeIntervalMs = nextIntervalMs;
557
- state.nudgeTimer = setInterval(() => {
558
- sendRenderNudge(clientId);
559
- }, nextIntervalMs);
560
- if (!wasActive) {
561
- scheduleClientHardTimeout(clientId);
562
- if (sendImmediately) {
563
- sendRenderNudge(clientId);
564
- }
565
- }
566
- }
567
-
568
- function stopClientNudges(clientId, _reason = "nudge_stop") {
569
- const state = clientNudgeState.get(clientId);
570
- if (!state) return;
571
- clearClientNudgeTimer(state, "nudgeTimer", clearInterval);
572
- clearClientNudgeTimer(state, "idleDeactivateTimer");
573
- clearClientNudgeTimer(state, "hardTimeoutTimer");
574
- state.nudgeActive = false;
575
- state.nudgeIntervalMs = null;
576
- state.nudgeStartedAtMs = null;
577
- resetClientStallTracking(state);
578
- scheduleClientStaleHeartbeatCheck(clientId);
602
+ function cloneReadinessSnapshot(value) {
603
+ if (!value) return null;
604
+ return {
605
+ clientDebugEnabled:
606
+ value.clientDebugEnabled === true || value.clientDebugEnabled === false
607
+ ? value.clientDebugEnabled
608
+ : null,
609
+ runtimeDiagnosticsVisible:
610
+ value.runtimeDiagnosticsVisible === true ||
611
+ value.runtimeDiagnosticsVisible === false
612
+ ? value.runtimeDiagnosticsVisible
613
+ : null,
614
+ perfNoisyDebugMuted:
615
+ value.perfNoisyDebugMuted === true || value.perfNoisyDebugMuted === false
616
+ ? value.perfNoisyDebugMuted
617
+ : null,
618
+ perfPayloadLiteMode:
619
+ value.perfPayloadLiteMode === true || value.perfPayloadLiteMode === false
620
+ ? value.perfPayloadLiteMode
621
+ : null,
622
+ activeSessionKey: value.activeSessionKey || null,
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),
631
+ emittedAtMs: Number.isFinite(value.emittedAtMs) ? value.emittedAtMs : null,
632
+ updatedAtMs: Number.isFinite(value.updatedAtMs) ? value.updatedAtMs : null,
633
+ };
579
634
  }
580
635
 
581
- function scheduleClientIdleDeactivation(clientId) {
582
- const state = clientNudgeState.get(clientId);
583
- if (!state) return;
584
- clearClientNudgeTimer(state, "idleDeactivateTimer");
585
- if (!state.nudgeActive || state.interactionStage !== "idle") {
586
- return;
587
- }
588
- state.idleDeactivateTimer = setTimeout(() => {
589
- state.idleDeactivateTimer = null;
590
- const currentState = clientNudgeState.get(clientId);
591
- if (!currentState || currentState.interactionStage !== "idle") {
592
- return;
593
- }
594
- stopClientNudges(clientId, "idle_grace_elapsed");
595
- }, nudgeIdleDeactivateMs);
636
+ function updateClientReadinessSnapshot(clientId, snapshot) {
637
+ if (!clientId || !snapshot) return null;
638
+ const next = cloneReadinessSnapshot({
639
+ ...snapshot,
640
+ updatedAtMs: Date.now(),
641
+ });
642
+ clientReadinessSnapshotState.set(clientId, next);
643
+ return next;
596
644
  }
597
645
 
598
- function scheduleClientStaleHeartbeatCheck(clientId) {
599
- const state = clientNudgeState.get(clientId);
600
- if (!state) return;
601
- clearClientNudgeTimer(state, "staleHeartbeatTimer");
602
- if (
603
- state.nudgeActive ||
604
- interactionStageBucket(state.interactionStage) === "idle" ||
605
- !Number.isFinite(state.lastHeartbeatAtMs)
606
- ) {
607
- return;
608
- }
609
- const delayMs = Math.max(
610
- 0,
611
- (state.lastHeartbeatAtMs + nudgeStaleHeartbeatThresholdMs) - Date.now(),
612
- );
613
- state.staleHeartbeatTimer = setTimeout(() => {
614
- state.staleHeartbeatTimer = null;
615
- const currentState = clientNudgeState.get(clientId);
646
+ function clearPendingReadinessProbesForClient(clientId) {
647
+ if (!clientId) return;
648
+ for (const [requestId, pending] of pendingReadinessProbeRequests) {
649
+ if (!pending) continue;
616
650
  if (
617
- !currentState ||
618
- currentState.nudgeActive ||
619
- interactionStageBucket(currentState.interactionStage) === "idle"
651
+ pending.requesterClientId === clientId ||
652
+ pending.targetClientId === clientId
620
653
  ) {
621
- return;
622
- }
623
- if (!hasStaleHeartbeat(currentState)) {
624
- scheduleClientStaleHeartbeatCheck(clientId);
625
- return;
626
- }
627
- startClientNudges(
628
- clientId,
629
- nudgeActiveIntervalMs,
630
- "stale_heartbeat_fallback",
631
- true,
632
- );
633
- }, delayMs);
634
- }
635
-
636
- function maybeActivateClientNudges(clientId, reason = "nudge_eval") {
637
- const state = clientNudgeState.get(clientId);
638
- if (!state || !isAppClient(clientId)) return;
639
- state.cadenceBucket = interactionStageBucket(state.interactionStage);
640
- if (state.cadenceBucket === "idle") {
641
- clearClientNudgeTimer(state, "staleHeartbeatTimer");
642
- return;
643
- }
644
- if (state.nudgeActive) {
645
- return;
646
- }
647
- if (isVisibilityDegraded(state) || hasStaleHeartbeat(state)) {
648
- startClientNudges(clientId, nudgeActiveIntervalMs, reason, true);
649
- return;
650
- }
651
- scheduleClientStaleHeartbeatCheck(clientId);
652
- }
653
-
654
- function setClientInteractionStage(clientId, nextStage, options = {}) {
655
- if (!isAppClient(clientId)) return;
656
- const state = ensureClientNudgeState(clientId);
657
- const reason = options.reason || "interaction_stage";
658
- const deactivateImmediately = options.deactivateImmediately === true;
659
- state.interactionStage = nextStage;
660
- state.cadenceBucket = interactionStageBucket(nextStage);
661
- if (state.cadenceBucket !== "active_stream") {
662
- resetClientStallTracking(state);
663
- }
664
- if (state.cadenceBucket === "idle") {
665
- clearClientNudgeTimer(state, "staleHeartbeatTimer");
666
- if (deactivateImmediately) {
667
- stopClientNudges(clientId, reason);
668
- } else {
669
- scheduleClientIdleDeactivation(clientId);
654
+ pendingReadinessProbeRequests.delete(requestId);
670
655
  }
671
- return;
672
656
  }
673
- clearClientNudgeTimer(state, "idleDeactivateTimer");
674
- if (
675
- state.cadenceBucket === "active_non_stream" &&
676
- state.nudgeActive &&
677
- state.nudgeIntervalMs !== nudgeActiveIntervalMs
678
- ) {
679
- startClientNudges(clientId, nudgeActiveIntervalMs, reason, false);
680
- }
681
- maybeActivateClientNudges(clientId, reason);
682
657
  }
683
658
 
684
- function observeRelayStreamingActivity(clientId, atMs) {
685
- if (!isAppClient(clientId)) return;
686
- const state = ensureClientNudgeState(clientId);
687
- state.lastRelayStreamingActivityAtMs = atMs;
688
- resetClientStallTracking(state);
689
- if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
690
- startClientNudges(clientId, nudgeActiveIntervalMs, "relay_stream_progress");
691
- return;
692
- }
693
- maybeActivateClientNudges(clientId, "relay_stream_activity");
694
- }
695
-
696
- function updateClientNudgeHeartbeat(clientId, ping) {
697
- const state = ensureClientNudgeState(clientId);
698
- const previousHeartbeatAtMs = Number.isFinite(state.lastHeartbeatAtMs)
699
- ? state.lastHeartbeatAtMs
700
- : null;
701
- const previousStreamChars = Number.isFinite(state.streamChars)
702
- ? state.streamChars
703
- : null;
704
- const nextStreamChars = Number.isFinite(ping.streamChars) ? ping.streamChars : null;
705
- const streamAdvanced =
706
- nextStreamChars !== null &&
707
- (previousStreamChars === null || nextStreamChars > previousStreamChars);
708
- const relayStreamAdvanced =
709
- previousHeartbeatAtMs !== null &&
710
- Number.isFinite(state.lastRelayStreamingActivityAtMs) &&
711
- state.lastRelayStreamingActivityAtMs > previousHeartbeatAtMs;
712
- state.streamChars = nextStreamChars;
713
- if (ping.visibilityState) {
714
- state.visibilityState = ping.visibilityState;
715
- }
716
- state.lastHeartbeatAtMs = Date.now();
717
- state.cadenceBucket = interactionStageBucket(state.interactionStage);
718
- if (state.visibilityState === "visible") {
719
- stopClientNudges(clientId, "heartbeat_visible");
720
- }
721
- if (state.cadenceBucket === "active_stream") {
722
- if (streamAdvanced || relayStreamAdvanced) {
723
- resetClientStallTracking(state);
724
- if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
725
- startClientNudges(
726
- clientId,
727
- nudgeActiveIntervalMs,
728
- streamAdvanced
729
- ? "heartbeat_stream_progress"
730
- : "relay_stream_progress",
731
- false,
732
- );
733
- }
734
- } else if (state.nudgeActive) {
735
- state.stalledHeartbeatCount += 1;
736
- if (
737
- state.stalledHeartbeatCount >= 3 &&
738
- state.nudgeIntervalMs !== nudgeSlowIntervalMs
739
- ) {
740
- startClientNudges(
741
- clientId,
742
- nudgeSlowIntervalMs,
743
- "stream_stalled_decelerated",
744
- false,
745
- );
746
- }
747
- }
748
- } else {
749
- resetClientStallTracking(state);
750
- if (state.nudgeActive && state.nudgeIntervalMs !== nudgeActiveIntervalMs) {
751
- startClientNudges(clientId, nudgeActiveIntervalMs, "non_stream_fast", false);
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);
752
669
  }
753
670
  }
754
- maybeActivateClientNudges(clientId, "heartbeat_update");
755
671
  }
756
672
 
757
- function updateClientVisibilityState(clientId, visibilityState) {
758
- const state = ensureClientNudgeState(clientId);
759
- state.visibilityState = visibilityState;
760
- if (visibilityState === "visible") {
761
- stopClientNudges(clientId, "visibility_visible");
762
- return;
763
- }
764
- maybeActivateClientNudges(clientId, "visibility_hidden");
673
+ function buildReadinessClientEntry(clientId) {
674
+ const protocol = protocolState.get(clientId);
675
+ return {
676
+ clientId,
677
+ clientKind: classifyClientKind(protocol),
678
+ clientName: protocol && protocol.clientName ? protocol.clientName : null,
679
+ clientVersion: protocol && protocol.clientVersion ? protocol.clientVersion : null,
680
+ protocolSessionKey: protocol && protocol.sessionKey ? protocol.sessionKey : null,
681
+ readinessSnapshot: cloneReadinessSnapshot(
682
+ clientReadinessSnapshotState.get(clientId) || null,
683
+ ),
684
+ };
765
685
  }
766
686
 
767
- function applyBroadcastInteractionStage(messageType, parsed, relayStreamingActivityAtMs) {
768
- for (const [clientId] of clients) {
769
- if (!isAppClient(clientId)) {
770
- continue;
771
- }
772
- if (relayStreamingActivityAtMs !== null) {
773
- setClientInteractionStage(clientId, "streaming", {
774
- reason: "relay_streaming",
775
- });
776
- observeRelayStreamingActivity(clientId, relayStreamingActivityAtMs);
777
- continue;
778
- }
779
- if (isListenCommittedBroadcastType(messageType)) {
780
- setClientInteractionStage(clientId, "voice_handoff", {
781
- reason: "listen_committed",
782
- });
783
- continue;
784
- }
785
- if (isListenEndedBroadcastType(messageType)) {
786
- setClientInteractionStage(clientId, "idle", {
787
- reason: "listen_ended",
788
- deactivateImmediately: true,
789
- });
790
- continue;
791
- }
792
- if (isActivityBroadcastType(messageType)) {
793
- const activityState = parseOptionalTrimmedString(parsed && parsed.state);
794
- const normalizedActivity = activityState ? activityState.toLowerCase() : null;
795
- const currentStage = ensureClientNudgeState(clientId).interactionStage;
796
- if (normalizedActivity === "thinking") {
797
- setClientInteractionStage(clientId, "thinking", {
798
- reason: "activity_thinking",
799
- });
800
- } else if (normalizedActivity === "idle") {
801
- if (currentStage === "streaming" || currentStage === "post_turn_drain") {
802
- setClientInteractionStage(clientId, "post_turn_drain", {
803
- reason: "activity_idle_stream_drain",
804
- });
805
- } else {
806
- setClientInteractionStage(clientId, "idle", {
807
- reason: "activity_idle",
808
- });
809
- }
810
- }
811
- continue;
812
- }
813
- if (isPagesBroadcastType(messageType)) {
814
- const currentState = ensureClientNudgeState(clientId);
815
- if (interactionStageBucket(currentState.interactionStage) !== "idle") {
816
- setClientInteractionStage(clientId, "post_turn_drain", {
817
- reason: "pages_snapshot",
818
- });
819
- }
820
- }
821
- }
687
+ function isStreamingBroadcastType(messageType) {
688
+ return (
689
+ messageType === "streaming" ||
690
+ messageType === "ocuclaw.message.stream.delta"
691
+ );
822
692
  }
823
693
 
824
694
  function updateProtocolSessionKey(clientId, value) {
@@ -881,11 +751,233 @@ function createDownstreamServer(opts) {
881
751
  }, resumeHandshakeTimeoutMs);
882
752
  }
883
753
 
884
- 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) {
885
764
  if (!ws || ws.readyState !== WebSocket.OPEN) {
886
765
  return;
887
766
  }
888
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
+ }
783
+ }
784
+
785
+ function forwardReadinessProbeAck(clientId, ack) {
786
+ if (!ack || !ack.requestId) {
787
+ return;
788
+ }
789
+ const pending = pendingReadinessProbeRequests.get(ack.requestId);
790
+ if (!pending || pending.targetClientId !== clientId) {
791
+ return;
792
+ }
793
+ pendingReadinessProbeRequests.delete(ack.requestId);
794
+ const requesterWs = clients.get(pending.requesterClientId);
795
+ if (!requesterWs || requesterWs.readyState !== WebSocket.OPEN) {
796
+ return;
797
+ }
798
+ const protocol = protocolState.get(clientId);
799
+ const message =
800
+ handler && typeof handler.formatReadinessProbeAck === "function"
801
+ ? handler.formatReadinessProbeAck({
802
+ ok: ack.ok !== false,
803
+ requestId: ack.requestId,
804
+ reasonCode: ack.reasonCode || null,
805
+ message: ack.message || null,
806
+ activeSessionKey: ack.activeSessionKey || null,
807
+ emittedAtMs: ack.emittedAtMs,
808
+ clientId,
809
+ clientName: protocol && protocol.clientName ? protocol.clientName : null,
810
+ clientVersion:
811
+ protocol && protocol.clientVersion ? protocol.clientVersion : null,
812
+ })
813
+ : JSON.stringify({
814
+ type: APP_PROTOCOL.readinessProbeAck,
815
+ ok: ack.ok !== false,
816
+ requestId: ack.requestId,
817
+ reasonCode: ack.reasonCode || null,
818
+ message: ack.message || null,
819
+ activeSessionKey: ack.activeSessionKey || null,
820
+ emittedAtMs: ack.emittedAtMs,
821
+ clientId,
822
+ clientName: protocol && protocol.clientName ? protocol.clientName : null,
823
+ clientVersion:
824
+ protocol && protocol.clientVersion ? protocol.clientVersion : null,
825
+ });
826
+ sendMessageToClient(pending.requesterClientId, requesterWs, message);
827
+ }
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
+ };
889
981
  }
890
982
 
891
983
  function getServerResumeState() {
@@ -894,30 +986,17 @@ function createDownstreamServer(opts) {
894
986
  }
895
987
  const state = getCurrentResumeState() || {};
896
988
  return {
897
- pagesRevision: parseRevision(state.pagesRevision),
898
- statusRevision: parseRevision(state.statusRevision),
989
+ pagesRevision: RelayWorkerProtocol.parseNonNegativeRevision(
990
+ state.pagesRevision,
991
+ ),
992
+ statusRevision: RelayWorkerProtocol.parseNonNegativeRevision(
993
+ state.statusRevision,
994
+ ),
899
995
  };
900
996
  }
901
997
 
902
998
  function formatResumeAck(payload) {
903
- return JSON.stringify({
904
- type: APP_PROTOCOL.resumeAck,
905
- reason: payload.reason || null,
906
- sentPages: !!payload.sentPages,
907
- sentStatus: !!payload.sentStatus,
908
- sentApprovals:
909
- Number.isFinite(payload.sentApprovals) && payload.sentApprovals > 0
910
- ? Math.floor(payload.sentApprovals)
911
- : 0,
912
- pagesRevision:
913
- payload.pagesRevision === null || payload.pagesRevision === undefined
914
- ? null
915
- : payload.pagesRevision,
916
- statusRevision:
917
- payload.statusRevision === null || payload.statusRevision === undefined
918
- ? null
919
- : payload.statusRevision,
920
- });
999
+ return RelayWorkerProtocol.formatResumeAck(payload);
921
1000
  }
922
1001
 
923
1002
  function isSnapshotSynced(sync) {
@@ -971,10 +1050,10 @@ function createDownstreamServer(opts) {
971
1050
  const status = getCurrentStatus();
972
1051
  const debugConfig = getCurrentDebugConfig ? getCurrentDebugConfig() : null;
973
1052
  const state = getServerResumeState();
974
- const clientPagesRevision = parseRevision(
1053
+ const clientPagesRevision = RelayWorkerProtocol.parseNonNegativeRevision(
975
1054
  resumeRequest && resumeRequest.pagesRevision,
976
1055
  );
977
- const clientStatusRevision = parseRevision(
1056
+ const clientStatusRevision = RelayWorkerProtocol.parseNonNegativeRevision(
978
1057
  resumeRequest && resumeRequest.statusRevision,
979
1058
  );
980
1059
  const clientHasPagesState = parseBool(
@@ -1059,12 +1138,6 @@ function createDownstreamServer(opts) {
1059
1138
  if (opts.httpServer) {
1060
1139
  wss = new WebSocketServer({ noServer: true });
1061
1140
  opts.httpServer.on("upgrade", (req, socket, head) => {
1062
- const url = new URL(req.url, `http://${req.headers.host}`);
1063
- if (url.searchParams.get("token") !== token) {
1064
- socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1065
- socket.destroy();
1066
- return;
1067
- }
1068
1141
  wss.handleUpgrade(req, socket, head, (ws) => {
1069
1142
  wss.emit("connection", ws, req);
1070
1143
  });
@@ -1073,14 +1146,15 @@ function createDownstreamServer(opts) {
1073
1146
  wss = new WebSocketServer({
1074
1147
  host: opts.host,
1075
1148
  port: opts.port,
1076
- verifyClient: ({ req }) => {
1077
- const url = new URL(req.url, `http://${req.headers.host}`);
1078
- return url.searchParams.get("token") === token;
1079
- },
1080
1149
  });
1081
1150
  }
1082
1151
 
1083
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
+ }
1084
1158
  const clientId = `client-${nextClientId++}`;
1085
1159
  clients.set(clientId, ws);
1086
1160
  const connectedAtMs = Date.now();
@@ -1097,7 +1171,7 @@ function createDownstreamServer(opts) {
1097
1171
  connectedEventEmitted: false,
1098
1172
  };
1099
1173
  ensureProtocolState(clientId);
1100
- clientNudgeState.set(clientId, createClientNudgeState());
1174
+ nudgeController.addClient(clientId);
1101
1175
  const currentPages = getCurrentPages();
1102
1176
  const currentStatus = getCurrentStatus();
1103
1177
  syncState.set(clientId, {
@@ -1156,7 +1230,7 @@ function createDownstreamServer(opts) {
1156
1230
 
1157
1231
  // Intercept application-level ping — respond with pong, skip handler
1158
1232
  if (enrichedPing) {
1159
- updateClientNudgeHeartbeat(clientId, enrichedPing);
1233
+ nudgeController.updateHeartbeat(clientId, enrichedPing);
1160
1234
  ws.send(JSON.stringify({ type: "pong", ts: parsed.ts }));
1161
1235
  return;
1162
1236
  }
@@ -1191,6 +1265,9 @@ function createDownstreamServer(opts) {
1191
1265
  clientVersion: protocolHello.clientVersion,
1192
1266
  sessionKey: protocolHello.sessionKey,
1193
1267
  });
1268
+ if (protocolHello.readinessSnapshot) {
1269
+ updateClientReadinessSnapshot(clientId, protocolHello.readinessSnapshot);
1270
+ }
1194
1271
  logger.info(
1195
1272
  `[downstream] ${clientId} identified protocol=${state.selectedVersion || "n/a"} client=${describeProtocolClient(state)} kind=${classifyClientKind(state)} session=${state.sessionKey || "n/a"}`,
1196
1273
  );
@@ -1244,6 +1321,29 @@ function createDownstreamServer(opts) {
1244
1321
  return;
1245
1322
  }
1246
1323
 
1324
+ if (parsed && parsed.type === APP_PROTOCOL.readinessSnapshot) {
1325
+ if (classifyClientKind(state) === "app") {
1326
+ updateClientReadinessSnapshot(clientId, parsed);
1327
+ }
1328
+ return;
1329
+ }
1330
+
1331
+ const readinessProbeAck = parseReadinessProbeAck(parsed);
1332
+ if (readinessProbeAck) {
1333
+ if (classifyClientKind(state) === "app") {
1334
+ forwardReadinessProbeAck(clientId, readinessProbeAck);
1335
+ }
1336
+ return;
1337
+ }
1338
+
1339
+ const automationStateSnapshot = parseAutomationStateSnapshot(parsed);
1340
+ if (automationStateSnapshot) {
1341
+ if (classifyClientKind(state) === "app") {
1342
+ forwardAutomationStateSnapshot(clientId, automationStateSnapshot);
1343
+ }
1344
+ return;
1345
+ }
1346
+
1247
1347
  if (parsed && parsed.type === APP_PROTOCOL.resume) {
1248
1348
  const sync = syncState.get(clientId);
1249
1349
  if (sync) sync.resumeReceived = true;
@@ -1254,15 +1354,25 @@ function createDownstreamServer(opts) {
1254
1354
  return;
1255
1355
  }
1256
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
+
1257
1367
  const visibilityControl = parseVisibilityControl(parsed);
1258
1368
  const drainCompleteControl = parseDrainCompleteControl(parsed);
1259
1369
  const transportControl = visibilityControl || drainCompleteControl;
1260
1370
  if (transportControl) {
1261
1371
  const protocol = protocolState.get(clientId);
1262
1372
  if (transportControl.type === "visibility") {
1263
- updateClientVisibilityState(clientId, transportControl.state);
1373
+ nudgeController.updateVisibilityState(clientId, transportControl.state);
1264
1374
  } else {
1265
- setClientInteractionStage(clientId, "idle", {
1375
+ nudgeController.setInteractionStage(clientId, "idle", {
1266
1376
  reason: "drain_complete",
1267
1377
  deactivateImmediately: true,
1268
1378
  });
@@ -1295,6 +1405,229 @@ function createDownstreamServer(opts) {
1295
1405
  return;
1296
1406
  }
1297
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
+
1298
1631
  const result = handler.handleMessage(clientId, raw);
1299
1632
  processResult(clientId, result);
1300
1633
  });
@@ -1307,8 +1640,10 @@ function createDownstreamServer(opts) {
1307
1640
  const protocol = protocolState.get(clientId);
1308
1641
  syncState.delete(clientId);
1309
1642
  protocolState.delete(clientId);
1310
- clearClientNudgeTimers(clientId);
1311
- clientNudgeState.delete(clientId);
1643
+ clientReadinessSnapshotState.delete(clientId);
1644
+ clearPendingReadinessProbesForClient(clientId);
1645
+ clearPendingAutomationStateRequestsForClient(clientId);
1646
+ nudgeController.deleteClient(clientId);
1312
1647
  handler.removeClient(clientId);
1313
1648
  clients.delete(clientId);
1314
1649
  const lifetimeMs = Date.now() - connectedAtMs;
@@ -1354,15 +1689,36 @@ function createDownstreamServer(opts) {
1354
1689
  * @param {string} senderId - Client ID that sent the original message
1355
1690
  * @param {object|null|Promise} result - Handler return value
1356
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
+
1357
1703
  function processResult(senderId, result) {
1358
1704
  if (result === null || result === undefined) {
1359
1705
  return;
1360
1706
  }
1361
1707
 
1362
1708
  if (typeof result.then === "function") {
1709
+ pendingHandlerPromiseCount += 1;
1710
+ if (relayHealth && typeof relayHealth.emitQueueDepth === "function") {
1711
+ relayHealth.emitQueueDepth({
1712
+ pendingHandlerPromises: pendingHandlerPromiseCount,
1713
+ });
1714
+ }
1363
1715
  result.then(
1364
- (resolved) => processResult(senderId, resolved),
1716
+ (resolved) => {
1717
+ pendingHandlerPromiseCount = Math.max(0, pendingHandlerPromiseCount - 1);
1718
+ processResult(senderId, resolved);
1719
+ },
1365
1720
  (err) => {
1721
+ pendingHandlerPromiseCount = Math.max(0, pendingHandlerPromiseCount - 1);
1366
1722
  logger.error(
1367
1723
  `[downstream] Error processing message from ${senderId}:`,
1368
1724
  err,
@@ -1373,9 +1729,110 @@ function createDownstreamServer(opts) {
1373
1729
  }
1374
1730
 
1375
1731
  if (result.unicast) {
1376
- const ws = clients.get(senderId);
1377
- if (ws && ws.readyState === WebSocket.OPEN) {
1378
- sendMessageToClient(senderId, ws, result.unicast);
1732
+ sendUnicastResult(senderId, result.unicast);
1733
+ }
1734
+
1735
+ if (result.followup && typeof result.followup.then === "function") {
1736
+ processResult(senderId, result.followup);
1737
+ }
1738
+
1739
+ if (result.readinessProbe) {
1740
+ const requestId = parseOptionalTrimmedString(
1741
+ result.readinessProbe.requestId,
1742
+ );
1743
+ const targetClientId = parseOptionalTrimmedString(
1744
+ result.readinessProbe.targetClientId,
1745
+ );
1746
+ const message =
1747
+ typeof result.readinessProbe.message === "string"
1748
+ ? result.readinessProbe.message
1749
+ : null;
1750
+ const targetWs = targetClientId ? clients.get(targetClientId) : null;
1751
+ if (
1752
+ !requestId ||
1753
+ !targetClientId ||
1754
+ !message ||
1755
+ !targetWs ||
1756
+ targetWs.readyState !== WebSocket.OPEN ||
1757
+ !isAppClient(targetClientId)
1758
+ ) {
1759
+ const requesterWs = clients.get(senderId);
1760
+ if (
1761
+ requesterWs &&
1762
+ requesterWs.readyState === WebSocket.OPEN &&
1763
+ handler &&
1764
+ typeof handler.formatReadinessProbeAck === "function"
1765
+ ) {
1766
+ sendMessageToClient(
1767
+ senderId,
1768
+ requesterWs,
1769
+ handler.formatReadinessProbeAck({
1770
+ ok: false,
1771
+ requestId,
1772
+ reasonCode: "no_downstream_client",
1773
+ message: "No downstream app client connected",
1774
+ }),
1775
+ );
1776
+ }
1777
+ } else {
1778
+ pendingReadinessProbeRequests.set(requestId, {
1779
+ requesterClientId: senderId,
1780
+ targetClientId,
1781
+ createdAtMs: Date.now(),
1782
+ });
1783
+ sendMessageToClient(targetClientId, targetWs, message);
1784
+ }
1785
+ }
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);
1379
1836
  }
1380
1837
  }
1381
1838
 
@@ -1439,11 +1896,7 @@ function createDownstreamServer(opts) {
1439
1896
  : null;
1440
1897
  for (const [clientId, ws] of clients) {
1441
1898
  if (ws.readyState === WebSocket.OPEN) {
1442
- sendMessageToClient(clientId, ws, message);
1443
- if (relayStreamingActivityAtMs !== null) {
1444
- ensureClientNudgeState(clientId).lastRelayStreamingActivityAtMs =
1445
- relayStreamingActivityAtMs;
1446
- }
1899
+ sendMessageToClient(clientId, ws, message, messageType);
1447
1900
  if (
1448
1901
  messageType === "pages" ||
1449
1902
  messageType === "status" ||
@@ -1454,7 +1907,11 @@ function createDownstreamServer(opts) {
1454
1907
  }
1455
1908
  }
1456
1909
  }
1457
- applyBroadcastInteractionStage(messageType, parsed, relayStreamingActivityAtMs);
1910
+ nudgeController.applyBroadcastInteractionStage(
1911
+ messageType,
1912
+ parsed,
1913
+ relayStreamingActivityAtMs,
1914
+ );
1458
1915
  }
1459
1916
 
1460
1917
  /**
@@ -1480,7 +1937,7 @@ function createDownstreamServer(opts) {
1480
1937
  if (ws.readyState !== WebSocket.OPEN) {
1481
1938
  continue;
1482
1939
  }
1483
- sendMessageToClient(clientId, ws, message);
1940
+ sendMessageToClient(clientId, ws, message, messageType);
1484
1941
  if (
1485
1942
  messageType === "pages" ||
1486
1943
  messageType === "status" ||
@@ -1515,8 +1972,39 @@ function createDownstreamServer(opts) {
1515
1972
  return Array.from(clients.keys());
1516
1973
  }
1517
1974
 
1518
- function getConnectedAppCount(excludeClientId = null) {
1519
- return countConnectedAppClients(excludeClientId);
1975
+ function getConnectedAppCount(excludeClientId = null, sessionKey = null) {
1976
+ return countConnectedAppClients(excludeClientId, sessionKey);
1977
+ }
1978
+
1979
+ function getReadinessSnapshot() {
1980
+ const clientsOut = [];
1981
+ let latestUpdatedAtMs = null;
1982
+ for (const [clientId, ws] of clients) {
1983
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
1984
+ continue;
1985
+ }
1986
+ if (!isAppClient(clientId)) {
1987
+ continue;
1988
+ }
1989
+ const entry = buildReadinessClientEntry(clientId);
1990
+ clientsOut.push(entry);
1991
+ const updatedAtMs =
1992
+ entry &&
1993
+ entry.readinessSnapshot &&
1994
+ Number.isFinite(entry.readinessSnapshot.updatedAtMs)
1995
+ ? entry.readinessSnapshot.updatedAtMs
1996
+ : null;
1997
+ if (updatedAtMs !== null && (latestUpdatedAtMs === null || updatedAtMs > latestUpdatedAtMs)) {
1998
+ latestUpdatedAtMs = updatedAtMs;
1999
+ }
2000
+ }
2001
+ clientsOut.sort((left, right) => String(left.clientId).localeCompare(String(right.clientId)));
2002
+ return {
2003
+ connectedClientCount: clientsOut.length,
2004
+ fanoutRecipientCount: clientsOut.length,
2005
+ updatedAtMs: latestUpdatedAtMs,
2006
+ clients: clientsOut,
2007
+ };
1520
2008
  }
1521
2009
 
1522
2010
  /**
@@ -1530,10 +2018,13 @@ function createDownstreamServer(opts) {
1530
2018
  if (sync.timer) clearTimeout(sync.timer);
1531
2019
  }
1532
2020
  syncState.clear();
1533
- for (const [clientId] of clientNudgeState) {
1534
- clearClientNudgeTimers(clientId);
2021
+ nudgeController.clear();
2022
+ clientReadinessSnapshotState.clear();
2023
+ pendingReadinessProbeRequests.clear();
2024
+ for (const [, pending] of pendingAutomationStateRequests) {
2025
+ if (pending && pending.expiryTimer) clearTimeout(pending.expiryTimer);
1535
2026
  }
1536
- clientNudgeState.clear();
2027
+ pendingAutomationStateRequests.clear();
1537
2028
  for (const [, ws] of clients) {
1538
2029
  ws.close();
1539
2030
  }
@@ -1547,9 +2038,10 @@ function createDownstreamServer(opts) {
1547
2038
  unicast,
1548
2039
  getClientIds,
1549
2040
  getConnectedAppCount,
2041
+ getReadinessSnapshot,
1550
2042
  closeConnectedAppClients,
1551
2043
  getClientNudgeState(clientId) {
1552
- return cloneClientNudgeState(clientNudgeState.get(clientId) || null);
2044
+ return nudgeController.getClientState(clientId);
1553
2045
  },
1554
2046
  close,
1555
2047
  get httpServer() {