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
@@ -0,0 +1,1051 @@
1
+ import * as http from "node:http";
2
+ import { monitorEventLoopDelay } from "node:perf_hooks";
3
+ import * as WebSocketModule from "ws";
4
+ import {
5
+ APP_PROTOCOL,
6
+ WORKER_FEATURES,
7
+ estimateJsonByteLength,
8
+ formatProtocolHelloAck,
9
+ formatResumeAck,
10
+ formatSendAck,
11
+ normalizeRequestId,
12
+ parseMessageType,
13
+ parseNonNegativeRevision,
14
+ } from "./relay-worker-protocol.js";
15
+ import { createWorkerMessageSendQueue } from "./relay-worker-queue.js";
16
+ import { createRelayWorkerHealthMonitor } from "./relay-worker-health.js";
17
+ import { createApprovalReplayCache } from "./relay-worker-approval-replay-cache.js";
18
+ import { createRelayClientNudgeController } from "./relay-client-nudge-controller.js";
19
+
20
+ const WebSocket = WebSocketModule.default || WebSocketModule.WebSocket || WebSocketModule;
21
+ const WebSocketServer = WebSocketModule.WebSocketServer || WebSocketModule.Server || WebSocket.Server;
22
+ const SEND_BUFFER_HIGH_WATER_BYTES = 262_144;
23
+
24
+ function normalizeLogger(logger) {
25
+ if (!logger || typeof logger !== "object") return console;
26
+ return {
27
+ info: typeof logger.info === "function" ? logger.info.bind(logger) : console.log,
28
+ warn: typeof logger.warn === "function" ? logger.warn.bind(logger) : console.warn,
29
+ error: typeof logger.error === "function" ? logger.error.bind(logger) : console.error,
30
+ debug: typeof logger.debug === "function" ? logger.debug.bind(logger) : console.debug,
31
+ };
32
+ }
33
+
34
+ function normalizeStringList(value) {
35
+ if (!Array.isArray(value)) return [];
36
+ return value.map(normalizeRequestId).filter(Boolean);
37
+ }
38
+
39
+ function responseEnded(res) {
40
+ return res.writableEnded || res.destroyed;
41
+ }
42
+
43
+ function parseFrame(frame) {
44
+ try {
45
+ return JSON.parse(frame);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function createRelayWorkerTransport(options = {}) {
52
+ const logger = normalizeLogger(options.logger);
53
+ const postToMain = typeof options.postToMain === "function" ? options.postToMain : () => {};
54
+ const now = typeof options.now === "function" ? options.now : () => Date.now();
55
+ let manifest = null;
56
+ let httpServer = null;
57
+ let wss = null;
58
+ let nextClientId = 1;
59
+ let expireTimer = null;
60
+ let healthTimer = null;
61
+ let loopDelayMonitor = null;
62
+ const clients = new Map();
63
+ const protocolState = new Map();
64
+ const outboundQueues = new Map();
65
+ const sockets = new Set();
66
+ const pendingHttp = new Map();
67
+ const cache = {
68
+ pages: null,
69
+ status: null,
70
+ debugConfig: null,
71
+ pagesRevision: null,
72
+ statusRevision: null,
73
+ lastMainFrameAtMs: null,
74
+ lastMainStatusAtMs: null,
75
+ };
76
+ let queue = null;
77
+ let health = null;
78
+ let currentAgentAvatar = null;
79
+ let nudgeController = null;
80
+ let approvalReplay = null;
81
+
82
+ function isAppClient(clientId) {
83
+ return (protocolState.get(clientId) || {}).clientKind === "app";
84
+ }
85
+
86
+ function normalizeReadinessSnapshot(value) {
87
+ return value && typeof value === "object" && !Array.isArray(value)
88
+ ? { ...value }
89
+ : null;
90
+ }
91
+
92
+ function emitWorkerHealth(frame) {
93
+ const type = parseMessageType(frame);
94
+ if (type === APP_PROTOCOL.workerHealth) {
95
+ try {
96
+ const parsed = JSON.parse(frame);
97
+ if (parsed.workerStatus === "main_disconnected") return;
98
+ } catch {
99
+ return;
100
+ }
101
+ }
102
+ broadcastApp(frame, { afterCoalescable: true });
103
+ }
104
+
105
+ function emitDebug(event, severity, data) {
106
+ postToMain({
107
+ kind: "debug",
108
+ category: "relay.worker.health",
109
+ event,
110
+ severity,
111
+ data,
112
+ workerEpoch: manifest ? manifest.workerEpoch : 0,
113
+ });
114
+ }
115
+
116
+ function isMainStale() {
117
+ if (!manifest || cache.lastMainFrameAtMs === null) return true;
118
+ return now() - cache.lastMainFrameAtMs >= manifest.health.mainStaleResumeThresholdMs;
119
+ }
120
+
121
+ function cacheState() {
122
+ if (!cache.pages && !cache.status && !cache.debugConfig) return "empty";
123
+ if (isMainStale()) return "stale";
124
+ return "fresh";
125
+ }
126
+
127
+ function applyInitialCache(initialCache) {
128
+ if (!initialCache || typeof initialCache !== "object") return;
129
+ if (typeof initialCache.pages === "string") cache.pages = initialCache.pages;
130
+ if (typeof initialCache.status === "string") cache.status = initialCache.status;
131
+ if (typeof initialCache.debugConfig === "string") cache.debugConfig = initialCache.debugConfig;
132
+ const pagesRevision = parseNonNegativeRevision(initialCache.pagesRevision);
133
+ const statusRevision = parseNonNegativeRevision(initialCache.statusRevision);
134
+ if (pagesRevision !== null) cache.pagesRevision = pagesRevision;
135
+ if (statusRevision !== null) cache.statusRevision = statusRevision;
136
+ if (Number.isFinite(Number(initialCache.lastMainFrameAtMs))) {
137
+ cache.lastMainFrameAtMs = Math.floor(Number(initialCache.lastMainFrameAtMs));
138
+ } else if (cache.pages || cache.status || cache.debugConfig) {
139
+ cache.lastMainFrameAtMs = now();
140
+ }
141
+ if (Number.isFinite(Number(initialCache.lastMainStatusAtMs))) {
142
+ cache.lastMainStatusAtMs = Math.floor(Number(initialCache.lastMainStatusAtMs));
143
+ } else if (cache.status) {
144
+ cache.lastMainStatusAtMs = cache.lastMainFrameAtMs || now();
145
+ }
146
+ if (
147
+ initialCache.agentAvatar &&
148
+ typeof initialCache.agentAvatar === "object" &&
149
+ typeof initialCache.agentAvatar.hash === "string" &&
150
+ initialCache.agentAvatar.hash &&
151
+ typeof initialCache.agentAvatar.dataUri === "string" &&
152
+ initialCache.agentAvatar.dataUri
153
+ ) {
154
+ currentAgentAvatar = {
155
+ hash: initialCache.agentAvatar.hash,
156
+ dataUri: initialCache.agentAvatar.dataUri,
157
+ };
158
+ }
159
+ }
160
+
161
+ function ensureOutboundQueue(clientId) {
162
+ let queue = outboundQueues.get(clientId);
163
+ if (!queue) {
164
+ queue = {
165
+ control: [],
166
+ transactional: [],
167
+ coalescableByType: new Map(),
168
+ postCoalescable: [],
169
+ bestEffort: [],
170
+ draining: false,
171
+ };
172
+ outboundQueues.set(clientId, queue);
173
+ }
174
+ return queue;
175
+ }
176
+
177
+ function isImportantTransactionalFrame(type, parsed) {
178
+ if (
179
+ type === APP_PROTOCOL.messageSendAck ||
180
+ type === "ocuclaw.approval.resolve.ack" ||
181
+ type === "ocuclaw.approval.request" ||
182
+ type === "ocuclaw.approval.resolved" ||
183
+ type === "ocuclaw.remote.control"
184
+ ) {
185
+ return true;
186
+ }
187
+ if (
188
+ typeof type === "string" &&
189
+ (type.endsWith(".ack") || type.endsWith(".result") || type.endsWith(".applied"))
190
+ ) {
191
+ return true;
192
+ }
193
+ return !!normalizeRequestId(parsed && parsed.requestId);
194
+ }
195
+
196
+ function enqueueFrame(clientId, frame, options = {}) {
197
+ const ws = clients.get(clientId);
198
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
199
+ const parsedFrame = parseFrame(frame);
200
+ // F11 fan-out: reuse the type the main thread already parsed (threaded via
201
+ // options.knownType from the broadcast/unicast call site) instead of
202
+ // re-deriving it per client. parsedFrame is still needed below for
203
+ // isImportantTransactionalFrame's requestId read, so parseFrame stays.
204
+ const type = options.knownType !== undefined ? options.knownType : parseMessageType(parsedFrame);
205
+ const q = ensureOutboundQueue(clientId);
206
+ if (
207
+ options.afterCoalescable === true &&
208
+ (type === APP_PROTOCOL.resumeAck || type === APP_PROTOCOL.workerHealth)
209
+ ) {
210
+ q.postCoalescable.push(frame);
211
+ } else if (
212
+ type === "pong" ||
213
+ type === APP_PROTOCOL.protocolHelloAck ||
214
+ type === APP_PROTOCOL.resumeAck ||
215
+ type === APP_PROTOCOL.workerHealth ||
216
+ type === APP_PROTOCOL.workerOperationReceived ||
217
+ type === APP_PROTOCOL.operationReceived
218
+ ) {
219
+ q.control.push(frame);
220
+ } else if (isImportantTransactionalFrame(type, parsedFrame)) {
221
+ q.transactional.push(frame);
222
+ } else if (
223
+ type === APP_PROTOCOL.pages ||
224
+ type === APP_PROTOCOL.status ||
225
+ type === APP_PROTOCOL.debugConfigSnapshot
226
+ ) {
227
+ q.coalescableByType.set(type, frame);
228
+ } else {
229
+ q.bestEffort.push(frame);
230
+ while (q.bestEffort.length > 100) {
231
+ // Newest-wins eviction: drop the oldest queued best-effort frame.
232
+ const dropped = q.bestEffort.shift();
233
+ emitDebug("worker_best_effort_frame_dropped", "warn", {
234
+ clientId,
235
+ droppedType: parseMessageType(parseFrame(dropped)),
236
+ queueDepth: q.bestEffort.length,
237
+ });
238
+ }
239
+ }
240
+ drainClientQueue(clientId);
241
+ }
242
+
243
+ function nextQueuedFrame(q) {
244
+ if (q.control.length) return q.control.shift();
245
+ if (q.transactional.length) return q.transactional.shift();
246
+ if (q.coalescableByType.size) {
247
+ const first = q.coalescableByType.entries().next().value;
248
+ q.coalescableByType.delete(first[0]);
249
+ return first[1];
250
+ }
251
+ if (q.postCoalescable.length) return q.postCoalescable.shift();
252
+ if (q.bestEffort.length) return q.bestEffort.shift();
253
+ return null;
254
+ }
255
+
256
+ function hasQueuedFrames(q) {
257
+ return !!(
258
+ q &&
259
+ (q.control.length ||
260
+ q.transactional.length ||
261
+ q.coalescableByType.size ||
262
+ q.postCoalescable.length ||
263
+ q.bestEffort.length)
264
+ );
265
+ }
266
+
267
+ function drainClientQueue(clientId) {
268
+ const ws = clients.get(clientId);
269
+ const q = outboundQueues.get(clientId);
270
+ if (!ws || !q || q.draining || ws.readyState !== WebSocket.OPEN) return;
271
+ q.draining = true;
272
+ queueMicrotask(() => {
273
+ try {
274
+ let frame;
275
+ while ((frame = nextQueuedFrame(q))) {
276
+ ws.send(frame);
277
+ if (Number.isFinite(ws.bufferedAmount) && ws.bufferedAmount > SEND_BUFFER_HIGH_WATER_BYTES) {
278
+ emitDebug("worker_client_send_buffer_high_water", "warn", {
279
+ clientId,
280
+ bufferedAmountBytes: ws.bufferedAmount,
281
+ });
282
+ break;
283
+ }
284
+ }
285
+ } finally {
286
+ q.draining = false;
287
+ if (hasQueuedFrames(q)) setTimeout(() => drainClientQueue(clientId), 0);
288
+ }
289
+ });
290
+ }
291
+
292
+ function broadcastApp(frame, options = {}) {
293
+ for (const [clientId, ws] of clients) {
294
+ if (ws.readyState !== WebSocket.OPEN) continue;
295
+ if ((protocolState.get(clientId) || {}).clientKind !== "app") continue;
296
+ enqueueFrame(clientId, frame, options);
297
+ }
298
+ }
299
+
300
+ function sendCachedResume(clientId, ws, parsed) {
301
+ const previousWorkerEpoch = Number.isFinite(Number(parsed.workerEpoch))
302
+ ? Math.floor(Number(parsed.workerEpoch))
303
+ : null;
304
+ const workerOnlyPendingRequestIds = normalizeStringList(parsed.workerOnlyPendingRequestIds);
305
+ let sentPages = false;
306
+ let sentStatus = false;
307
+
308
+ if (cache.pages) {
309
+ const clientPagesRevision = parseNonNegativeRevision(parsed.pagesRevision);
310
+ const hasPagesState = parsed.hasPagesState === true;
311
+ if (!hasPagesState || clientPagesRevision !== cache.pagesRevision) {
312
+ enqueueFrame(clientId, cache.pages);
313
+ sentPages = true;
314
+ }
315
+ }
316
+ if (cache.status) {
317
+ enqueueFrame(clientId, cache.status);
318
+ sentStatus = true;
319
+ }
320
+ let sentApprovals = 0;
321
+ if (approvalReplay) {
322
+ for (const approvalFrame of approvalReplay.activeFrames()) {
323
+ enqueueFrame(clientId, approvalFrame);
324
+ sentApprovals += 1;
325
+ }
326
+ }
327
+ if (cache.debugConfig && (protocolState.get(clientId) || {}).clientKind === "app") {
328
+ enqueueFrame(clientId, cache.debugConfig);
329
+ }
330
+
331
+ const mainStale = isMainStale();
332
+ const ack = formatResumeAck({
333
+ reason: "resume",
334
+ sentPages,
335
+ sentStatus,
336
+ sentApprovals,
337
+ pagesRevision: cache.pagesRevision,
338
+ statusRevision: cache.statusRevision,
339
+ workerEpoch: manifest.workerEpoch,
340
+ previousWorkerEpoch,
341
+ workerRestarted: previousWorkerEpoch !== null && previousWorkerEpoch !== manifest.workerEpoch,
342
+ mainStale,
343
+ cacheState: cacheState(),
344
+ resumeProvisional: mainStale,
345
+ cachedPagesRevision: cache.pagesRevision,
346
+ cachedStatusRevision: cache.statusRevision,
347
+ workerOnlyPendingRequestIds,
348
+ unresolvedWorkerPendingRequestIds: workerOnlyPendingRequestIds,
349
+ });
350
+ setTimeout(() => enqueueFrame(clientId, ack, { afterCoalescable: true }), 0);
351
+
352
+ if (workerOnlyPendingRequestIds.length > 0) {
353
+ postToMain({
354
+ kind: "operation.reconcile",
355
+ requestIds: workerOnlyPendingRequestIds,
356
+ clientId,
357
+ workerEpoch: manifest.workerEpoch,
358
+ });
359
+ }
360
+ }
361
+
362
+ function handleProtocolHello(clientId, ws, parsed) {
363
+ const supported = Array.isArray(parsed.supportedProtocolVersions)
364
+ ? parsed.supportedProtocolVersions
365
+ : [];
366
+ if (!supported.includes("v2")) {
367
+ ws.close(1008, "protocol_v2_required");
368
+ return;
369
+ }
370
+ const clientKind = parsed.clientName === "debugctl" ? "debug" : "app";
371
+ if (clientKind === "debug" && manifest.externalDebugToolsEnabled !== true) {
372
+ ws.close(1008, "external_debug_tools_disabled");
373
+ return;
374
+ }
375
+
376
+ protocolState.set(clientId, {
377
+ protocolVersion: "v2",
378
+ clientKind,
379
+ clientName: typeof parsed.clientName === "string" ? parsed.clientName : null,
380
+ clientVersion: typeof parsed.clientVersion === "string" ? parsed.clientVersion : null,
381
+ sessionKey: typeof parsed.sessionKey === "string" ? parsed.sessionKey : null,
382
+ });
383
+ const state = protocolState.get(clientId);
384
+ if (state.clientKind === "app" && nudgeController) {
385
+ nudgeController.addClient(clientId);
386
+ }
387
+ postToMain({
388
+ kind: "client.identified",
389
+ clientId,
390
+ clientKind: state.clientKind,
391
+ clientName: state.clientName,
392
+ clientVersion: state.clientVersion,
393
+ sessionKey: state.sessionKey,
394
+ readinessSnapshot:
395
+ parsed.readinessSnapshot && typeof parsed.readinessSnapshot === "object"
396
+ ? parsed.readinessSnapshot
397
+ : null,
398
+ workerEpoch: manifest.workerEpoch,
399
+ connectedAtMs: now(),
400
+ });
401
+ enqueueFrame(clientId, formatProtocolHelloAck({
402
+ protocolVersion: "v2",
403
+ supportedProtocolVersions: manifest.supportedProtocolVersions,
404
+ reason: "negotiated_v2",
405
+ pluginVersion: manifest.pluginVersion,
406
+ requiresClientVersion: manifest.requiresClientVersion,
407
+ pluginId: manifest.pluginId,
408
+ workerEpoch: manifest.workerEpoch,
409
+ workerFeatures: WORKER_FEATURES,
410
+ }));
411
+ }
412
+
413
+ function handleSend(clientId, ws, parsed, raw) {
414
+ const requestId = normalizeRequestId(parsed.requestId);
415
+ if (!requestId) {
416
+ enqueueFrame(clientId, formatSendAck(null, "rejected", "Missing required field: requestId", "invalid_request"));
417
+ return;
418
+ }
419
+
420
+ const text = typeof parsed.text === "string" ? parsed.text : "";
421
+ if (!text.trim() && !parsed.attachment) {
422
+ enqueueFrame(clientId, formatSendAck(requestId, "rejected", "Missing required field: text", "invalid_request"));
423
+ return;
424
+ }
425
+
426
+ const result = queue.enqueue({
427
+ clientId,
428
+ requestId,
429
+ text,
430
+ sessionKey: typeof parsed.sessionKey === "string" ? parsed.sessionKey : null,
431
+ attachment: parsed.attachment || null,
432
+ });
433
+ health.updateQueueDepth(queue.depthSnapshot());
434
+
435
+ if (result.receipt) enqueueFrame(clientId, result.receipt);
436
+ if (result.finalFrame) {
437
+ enqueueFrame(clientId, result.finalFrame);
438
+ return;
439
+ }
440
+
441
+ postToMain({
442
+ kind: "app.message",
443
+ clientId,
444
+ raw,
445
+ requestId,
446
+ operation: "message.send",
447
+ workerEpoch: manifest.workerEpoch,
448
+ queuedAtMs: now(),
449
+ byteLength: estimateJsonByteLength(parsed),
450
+ });
451
+ }
452
+
453
+ function handleText(clientId, ws, raw) {
454
+ let parsed = null;
455
+ try {
456
+ parsed = JSON.parse(raw);
457
+ } catch {
458
+ enqueueFrame(clientId, JSON.stringify({ type: "error", error: "Invalid JSON" }));
459
+ return;
460
+ }
461
+
462
+ if (parsed && parsed.type === "ping") {
463
+ if (nudgeController && isAppClient(clientId)) {
464
+ nudgeController.updateHeartbeat(clientId, parsed);
465
+ }
466
+ enqueueFrame(clientId, JSON.stringify({ type: "pong", ts: parsed.ts }));
467
+ return;
468
+ }
469
+ if (parsed && parsed.type === "protocolHello") {
470
+ handleProtocolHello(clientId, ws, parsed);
471
+ return;
472
+ }
473
+
474
+ const state = protocolState.get(clientId);
475
+ if (!state || state.protocolVersion !== "v2") {
476
+ ws.close(1008, "protocol_hello_required");
477
+ return;
478
+ }
479
+ if (parsed && parsed.type === APP_PROTOCOL.readinessSnapshot) {
480
+ if (isAppClient(clientId)) {
481
+ postToMain({
482
+ kind: "client.readinessSnapshot",
483
+ clientId,
484
+ readinessSnapshot: normalizeReadinessSnapshot(parsed),
485
+ workerEpoch: manifest.workerEpoch,
486
+ updatedAtMs: now(),
487
+ });
488
+ }
489
+ return;
490
+ }
491
+
492
+ if (parsed && parsed.type === APP_PROTOCOL.readinessProbeAck) {
493
+ if (isAppClient(clientId)) {
494
+ postToMain({
495
+ kind: "client.readinessProbeAck",
496
+ clientId,
497
+ ack: { ...parsed },
498
+ workerEpoch: manifest.workerEpoch,
499
+ receivedAtMs: now(),
500
+ });
501
+ }
502
+ return;
503
+ }
504
+
505
+ if (parsed && parsed.type === APP_PROTOCOL.automationStateSnapshot) {
506
+ if (isAppClient(clientId)) {
507
+ postToMain({
508
+ kind: "client.automationStateSnapshot",
509
+ clientId,
510
+ snapshot: { ...parsed },
511
+ workerEpoch: manifest.workerEpoch,
512
+ receivedAtMs: now(),
513
+ });
514
+ }
515
+ return;
516
+ }
517
+
518
+ if (parsed && parsed.type === APP_PROTOCOL.visibility) {
519
+ if (isAppClient(clientId)) {
520
+ const state =
521
+ parsed.state === "hidden" ||
522
+ parsed.state === "visible" ||
523
+ parsed.state === "blurred"
524
+ ? parsed.state
525
+ : null;
526
+ if (state) {
527
+ if (nudgeController) {
528
+ nudgeController.updateVisibilityState(clientId, state);
529
+ }
530
+ const ps = protocolState.get(clientId) || {};
531
+ postToMain({
532
+ kind: "client.visibility",
533
+ clientId,
534
+ state,
535
+ sessionKey: ps.sessionKey || null,
536
+ clientKind: ps.clientKind || null,
537
+ clientName: ps.clientName || null,
538
+ clientVersion: ps.clientVersion || null,
539
+ protocolVersion: ps.protocolVersion || null,
540
+ workerEpoch: manifest.workerEpoch,
541
+ });
542
+ }
543
+ }
544
+ return;
545
+ }
546
+
547
+ if (parsed && parsed.type === "drain_complete") {
548
+ if (nudgeController && isAppClient(clientId)) {
549
+ nudgeController.setInteractionStage(clientId, "idle", {
550
+ reason: "drain_complete",
551
+ deactivateImmediately: true,
552
+ });
553
+ }
554
+ return;
555
+ }
556
+
557
+ if (parsed && parsed.type === APP_PROTOCOL.resumeAck.replace(".ack", "")) {
558
+ sendCachedResume(clientId, ws, parsed);
559
+ return;
560
+ }
561
+ if (parsed && parsed.type === APP_PROTOCOL.messageSend) {
562
+ handleSend(clientId, ws, parsed, raw);
563
+ return;
564
+ }
565
+ if (parsed && parsed.type === APP_PROTOCOL.avatarFetch) {
566
+ const requestedAgentName =
567
+ typeof parsed.agentName === "string" && parsed.agentName ? parsed.agentName : null;
568
+ const requestedHash =
569
+ typeof parsed.hash === "string" && /^[0-9a-f]{64}$/.test(parsed.hash)
570
+ ? parsed.hash
571
+ : null;
572
+ if (!requestedAgentName || !requestedHash) {
573
+ // Malformed request — silently drop; mirrors the non-worker handler.
574
+ return;
575
+ }
576
+ const dataUri =
577
+ currentAgentAvatar && currentAgentAvatar.hash === requestedHash
578
+ ? currentAgentAvatar.dataUri
579
+ : null;
580
+ enqueueFrame(
581
+ clientId,
582
+ JSON.stringify({
583
+ type: APP_PROTOCOL.avatarBlob,
584
+ agentName: requestedAgentName,
585
+ hash: requestedHash,
586
+ dataUri,
587
+ }),
588
+ );
589
+ return;
590
+ }
591
+
592
+ postToMain({
593
+ kind: "app.message",
594
+ clientId,
595
+ raw,
596
+ requestId: normalizeRequestId(parsed.requestId),
597
+ operation: parseMessageType(parsed),
598
+ workerEpoch: manifest.workerEpoch,
599
+ queuedAtMs: now(),
600
+ byteLength: estimateJsonByteLength(parsed),
601
+ });
602
+ }
603
+
604
+ function handleHttpRequest(req, res) {
605
+ const url = new URL(req.url || "/", "http://127.0.0.1");
606
+ const routes = (manifest.routes && manifest.routes.mainForwardedHttpPaths) || [];
607
+ if (!routes.includes(url.pathname)) {
608
+ res.statusCode = 404;
609
+ res.setHeader("content-type", "text/plain; charset=utf-8");
610
+ res.end("not found");
611
+ return;
612
+ }
613
+
614
+ const chunks = [];
615
+ let total = 0;
616
+ let tooLarge = false;
617
+ req.on("data", (chunk) => {
618
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
619
+ total += buffer.length;
620
+ if (total > manifest.rpc.httpMaxBodyBytes) {
621
+ tooLarge = true;
622
+ return;
623
+ }
624
+ chunks.push(buffer);
625
+ });
626
+ req.on("error", (err) => {
627
+ logger.warn("relay worker HTTP request error", err);
628
+ if (!responseEnded(res)) {
629
+ res.statusCode = 400;
630
+ res.setHeader("content-type", "text/plain; charset=utf-8");
631
+ res.end("request error");
632
+ }
633
+ });
634
+ req.on("end", () => {
635
+ if (tooLarge) {
636
+ res.statusCode = 413;
637
+ res.setHeader("content-type", "text/plain; charset=utf-8");
638
+ res.end("request body too large");
639
+ return;
640
+ }
641
+
642
+ const requestId = `http-${manifest.workerEpoch}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
643
+ const timer = setTimeout(() => {
644
+ pendingHttp.delete(requestId);
645
+ if (!responseEnded(res)) {
646
+ res.statusCode = 503;
647
+ res.setHeader("content-type", "text/plain; charset=utf-8");
648
+ res.end("OpenClaw did not respond before the relay worker HTTP timeout.");
649
+ }
650
+ }, manifest.rpc.httpRequestTimeoutMs);
651
+ pendingHttp.set(requestId, { res, timer });
652
+ res.once("close", () => {
653
+ const pending = pendingHttp.get(requestId);
654
+ if (!pending) return;
655
+ pendingHttp.delete(requestId);
656
+ clearTimeout(pending.timer);
657
+ postToMain({
658
+ kind: "http.cancel",
659
+ requestId,
660
+ workerEpoch: manifest.workerEpoch,
661
+ });
662
+ });
663
+ postToMain({
664
+ kind: "http.request",
665
+ requestId,
666
+ method: req.method,
667
+ url: req.url,
668
+ headers: req.headers,
669
+ bodyBase64: Buffer.concat(chunks).toString("base64"),
670
+ bodyBytes: total,
671
+ workerEpoch: manifest.workerEpoch,
672
+ });
673
+ });
674
+ }
675
+
676
+ function handleMainMessage(message) {
677
+ if (!message || typeof message !== "object") return;
678
+ if (message.kind === "main.heartbeat") {
679
+ if (health) {
680
+ health.recordMainHeartbeat({
681
+ cachedPagesRevision: cache.pagesRevision,
682
+ cachedStatusRevision: cache.statusRevision,
683
+ lastMainStatusAtMs: cache.lastMainStatusAtMs,
684
+ });
685
+ }
686
+ return;
687
+ }
688
+ if (message.kind === "main.avatar") {
689
+ const next = message.agentAvatar;
690
+ if (
691
+ next &&
692
+ typeof next === "object" &&
693
+ typeof next.hash === "string" &&
694
+ next.hash &&
695
+ typeof next.dataUri === "string" &&
696
+ next.dataUri
697
+ ) {
698
+ currentAgentAvatar = { hash: next.hash, dataUri: next.dataUri };
699
+ } else {
700
+ currentAgentAvatar = null;
701
+ }
702
+ return;
703
+ }
704
+ if (message.kind === "worker.closeClients") {
705
+ const clientIds = normalizeStringList(message.clientIds);
706
+ const reason =
707
+ typeof message.reason === "string" && message.reason.trim()
708
+ ? message.reason.trim()
709
+ : "server_close";
710
+ for (const clientId of clientIds) {
711
+ const ws = clients.get(clientId);
712
+ if (!ws || !isAppClient(clientId)) continue;
713
+ if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
714
+ ws.close(1000, reason);
715
+ }
716
+ }
717
+ return;
718
+ }
719
+ if (message.kind === "main.frame") {
720
+ const type = typeof message.type === "string" ? message.type : parseMessageType(message.frame);
721
+ let parsedFrame = null;
722
+ const shouldApplyNudgeStage =
723
+ nudgeController &&
724
+ (message.target === "broadcast" || message.target === "broadcastApp");
725
+ if (
726
+ type === APP_PROTOCOL.messageSendAck ||
727
+ type === APP_PROTOCOL.operationReceived ||
728
+ type === APP_PROTOCOL.pages ||
729
+ type === APP_PROTOCOL.status ||
730
+ type === APP_PROTOCOL.debugConfigSnapshot ||
731
+ type === APP_PROTOCOL.approvalRequest ||
732
+ type === APP_PROTOCOL.approvalResolved ||
733
+ shouldApplyNudgeStage
734
+ ) {
735
+ parsedFrame = parseFrame(message.frame);
736
+ }
737
+ if (type === APP_PROTOCOL.approvalRequest) {
738
+ const approvalId =
739
+ parsedFrame && typeof parsedFrame.id === "string" && parsedFrame.id.trim()
740
+ ? parsedFrame.id.trim()
741
+ : null;
742
+ if (approvalId && approvalReplay) {
743
+ const frameExpiresAtMs =
744
+ parsedFrame && Number.isFinite(Number(parsedFrame.expiresAtMs))
745
+ ? Math.floor(Number(parsedFrame.expiresAtMs))
746
+ : 0;
747
+ approvalReplay.set(approvalId, message.frame, frameExpiresAtMs);
748
+ }
749
+ }
750
+ if (type === APP_PROTOCOL.approvalResolved) {
751
+ const approvalId =
752
+ parsedFrame && typeof parsedFrame.id === "string" && parsedFrame.id.trim()
753
+ ? parsedFrame.id.trim()
754
+ : null;
755
+ if (approvalId && approvalReplay) approvalReplay.remove(approvalId);
756
+ }
757
+ if (type === APP_PROTOCOL.messageSendAck || type === APP_PROTOCOL.operationReceived) {
758
+ const requestId = normalizeRequestId(parsedFrame && parsedFrame.requestId);
759
+ if (requestId && queue) {
760
+ queue.settle(requestId);
761
+ health.updateQueueDepth(queue.depthSnapshot());
762
+ }
763
+ }
764
+ if (type === APP_PROTOCOL.pages) {
765
+ cache.pages = message.frame;
766
+ const frameRevision = parseNonNegativeRevision(parsedFrame && parsedFrame.revision);
767
+ if (frameRevision !== null) cache.pagesRevision = frameRevision;
768
+ }
769
+ if (type === APP_PROTOCOL.status) {
770
+ cache.status = message.frame;
771
+ cache.lastMainStatusAtMs = now();
772
+ const frameRevision = parseNonNegativeRevision(parsedFrame && parsedFrame.revision);
773
+ if (frameRevision !== null) cache.statusRevision = frameRevision;
774
+ }
775
+ if (type === APP_PROTOCOL.debugConfigSnapshot) cache.debugConfig = message.frame;
776
+ if (message.revisions) {
777
+ const pagesRevision = parseNonNegativeRevision(message.revisions.pagesRevision);
778
+ const statusRevision = parseNonNegativeRevision(message.revisions.statusRevision);
779
+ if (pagesRevision !== null) cache.pagesRevision = pagesRevision;
780
+ if (statusRevision !== null) cache.statusRevision = statusRevision;
781
+ }
782
+ cache.lastMainFrameAtMs = now();
783
+ if (message.target === "broadcast" || message.target === "broadcastApp") {
784
+ broadcastApp(message.frame, { knownType: type });
785
+ }
786
+ if (message.target === "unicast" && message.clientId && clients.has(message.clientId)) {
787
+ enqueueFrame(message.clientId, message.frame);
788
+ }
789
+ if (shouldApplyNudgeStage) {
790
+ nudgeController.applyBroadcastInteractionStage(
791
+ type,
792
+ parsedFrame,
793
+ type === "streaming" || type === "ocuclaw.message.stream.delta" ? now() : null,
794
+ );
795
+ }
796
+ if (health) {
797
+ health.recordMainFrame({
798
+ type,
799
+ cachedPagesRevision: cache.pagesRevision,
800
+ cachedStatusRevision: cache.statusRevision,
801
+ lastMainStatusAtMs: cache.lastMainStatusAtMs,
802
+ });
803
+ }
804
+ return;
805
+ }
806
+ if (message.kind === "http.response") {
807
+ const pending = pendingHttp.get(message.requestId);
808
+ if (!pending) return;
809
+ pendingHttp.delete(message.requestId);
810
+ clearTimeout(pending.timer);
811
+ const body = Buffer.from(message.bodyBase64 || "", "base64");
812
+ if (body.length > manifest.rpc.httpMaxResponseBytes) {
813
+ pending.res.statusCode = 502;
814
+ pending.res.setHeader("content-type", "text/plain; charset=utf-8");
815
+ pending.res.end("OpenClaw response exceeded relay worker HTTP response limit.");
816
+ return;
817
+ }
818
+ pending.res.statusCode = Number.isFinite(message.statusCode) ? message.statusCode : 200;
819
+ for (const [key, value] of Object.entries(message.headers || {})) {
820
+ pending.res.setHeader(key, value);
821
+ }
822
+ pending.res.end(body);
823
+ return;
824
+ }
825
+ if (message.kind === "operation.reconcile.result") {
826
+ const results = queue.reconcileOldEpochPending({
827
+ requestIds: message.requestIds,
828
+ mainResults: message.results,
829
+ });
830
+ for (const result of results) {
831
+ if (message.clientId && clients.has(message.clientId)) {
832
+ if (result.receiptFrame) enqueueFrame(message.clientId, result.receiptFrame);
833
+ if (result.finalFrame) enqueueFrame(message.clientId, result.finalFrame);
834
+ }
835
+ }
836
+ return;
837
+ }
838
+ }
839
+
840
+ async function start(nextManifest) {
841
+ manifest = nextManifest;
842
+ nudgeController = createRelayClientNudgeController({
843
+ thresholds: manifest.nudge || {},
844
+ isAppClient,
845
+ sendFrame(clientId, frame) {
846
+ const ws = clients.get(clientId);
847
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
848
+ enqueueFrame(clientId, frame);
849
+ return true;
850
+ },
851
+ now,
852
+ });
853
+ applyInitialCache(manifest.initialCache);
854
+ queue = createWorkerMessageSendQueue({
855
+ workerEpoch: manifest.workerEpoch,
856
+ maxEntries: manifest.queue.messageSendMaxEntries,
857
+ ttlMs: manifest.queue.messageSendTtlMs,
858
+ retainedFinalTtlMs: manifest.queue.retainedFinalTtlMs,
859
+ });
860
+ approvalReplay = createApprovalReplayCache({
861
+ now,
862
+ ttlMs: manifest.queue.approvalReplayTtlMs,
863
+ maxEntries: manifest.queue.approvalReplayMaxEntries,
864
+ });
865
+ health = createRelayWorkerHealthMonitor({
866
+ workerEpoch: manifest.workerEpoch,
867
+ thresholds: manifest.health,
868
+ emitFrame: emitWorkerHealth,
869
+ emitDebug,
870
+ });
871
+ if (cache.lastMainFrameAtMs !== null) {
872
+ health.recordMainFrame({
873
+ cachedPagesRevision: cache.pagesRevision,
874
+ cachedStatusRevision: cache.statusRevision,
875
+ lastMainStatusAtMs: cache.lastMainStatusAtMs,
876
+ });
877
+ } else {
878
+ emitDebug("worker_health_transition", "info", {
879
+ from: null,
880
+ to: "main_disconnected",
881
+ workerEpoch: manifest.workerEpoch,
882
+ });
883
+ }
884
+ httpServer = http.createServer(handleHttpRequest);
885
+ httpServer.on("connection", (socket) => {
886
+ sockets.add(socket);
887
+ socket.on("close", () => {
888
+ sockets.delete(socket);
889
+ });
890
+ });
891
+ wss = new WebSocketServer({ noServer: true });
892
+ httpServer.on("upgrade", (req, socket, head) => {
893
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
894
+ });
895
+ wss.on("connection", (ws, req) => {
896
+ const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
897
+ if (requestUrl.searchParams.get("token") !== manifest.relayToken) {
898
+ ws.close(4001, "invalid_token");
899
+ return;
900
+ }
901
+ const clientId = `worker-client-${nextClientId++}`;
902
+ clients.set(clientId, ws);
903
+ protocolState.set(clientId, {
904
+ protocolVersion: null,
905
+ clientKind: "unknown",
906
+ clientName: null,
907
+ clientVersion: null,
908
+ sessionKey: null,
909
+ });
910
+ ws.on("message", (data, isBinary) => {
911
+ if (isBinary) return;
912
+ handleText(clientId, ws, data.toString());
913
+ });
914
+ ws.on("error", (err) => {
915
+ logger.debug("relay worker WebSocket client error", err);
916
+ });
917
+ ws.on("close", (code, reason) => {
918
+ clients.delete(clientId);
919
+ protocolState.delete(clientId);
920
+ outboundQueues.delete(clientId);
921
+ if (nudgeController) nudgeController.deleteClient(clientId);
922
+ const closeReasonStr =
923
+ reason == null
924
+ ? ""
925
+ : Buffer.isBuffer(reason)
926
+ ? reason.toString("utf8")
927
+ : String(reason);
928
+ postToMain({
929
+ kind: "client.disconnected",
930
+ clientId,
931
+ workerEpoch: manifest.workerEpoch,
932
+ disconnectedAtMs: now(),
933
+ closeCode: Number.isFinite(code) ? code : null,
934
+ closeReasonTail: closeReasonStr ? closeReasonStr.slice(-120) : null,
935
+ });
936
+ });
937
+ });
938
+
939
+ await new Promise((resolve, reject) => {
940
+ httpServer.once("error", reject);
941
+ httpServer.listen(manifest.port, manifest.host, () => {
942
+ httpServer.off("error", reject);
943
+ resolve();
944
+ });
945
+ });
946
+ loopDelayMonitor = monitorEventLoopDelay({ resolution: 50 });
947
+ loopDelayMonitor.enable();
948
+ expireTimer = setInterval(() => {
949
+ const expired = queue.expire();
950
+ health.updateQueueDepth(queue.depthSnapshot());
951
+ for (const item of expired) {
952
+ if (item.clientId && clients.has(item.clientId)) {
953
+ enqueueFrame(item.clientId, item.finalFrame);
954
+ }
955
+ }
956
+ }, Math.max(10, Math.min(1000, manifest.queue.messageSendTtlMs)));
957
+ healthTimer = setInterval(() => {
958
+ health.updateLoopLagP95Ms(sampleLoopLagP95Ms());
959
+ health.updateSendBufferHighWaterClients(countSendBufferHighWaterClients());
960
+ health.sample();
961
+ }, manifest.health.heartbeatIntervalMs);
962
+ postToMain({ kind: "worker.ready", workerEpoch: manifest.workerEpoch, address: address() });
963
+ }
964
+
965
+ function sampleLoopLagP95Ms() {
966
+ if (!loopDelayMonitor) return null;
967
+ const p95Ns = loopDelayMonitor.percentile(95);
968
+ loopDelayMonitor.reset();
969
+ return Number.isFinite(p95Ns) ? Math.round(p95Ns / 1e6) : null;
970
+ }
971
+
972
+ function countSendBufferHighWaterClients() {
973
+ let count = 0;
974
+ for (const [clientId, ws] of clients) {
975
+ if ((protocolState.get(clientId) || {}).clientKind !== "app") continue;
976
+ if (
977
+ ws.readyState === WebSocket.OPEN &&
978
+ Number.isFinite(ws.bufferedAmount) &&
979
+ ws.bufferedAmount > SEND_BUFFER_HIGH_WATER_BYTES
980
+ ) {
981
+ count += 1;
982
+ }
983
+ }
984
+ return count;
985
+ }
986
+
987
+ function address() {
988
+ return httpServer && typeof httpServer.address === "function" ? httpServer.address() : null;
989
+ }
990
+
991
+ function close() {
992
+ if (expireTimer) clearInterval(expireTimer);
993
+ if (healthTimer) clearInterval(healthTimer);
994
+ expireTimer = null;
995
+ healthTimer = null;
996
+ if (loopDelayMonitor) {
997
+ loopDelayMonitor.disable();
998
+ loopDelayMonitor = null;
999
+ }
1000
+ for (const pending of pendingHttp.values()) {
1001
+ clearTimeout(pending.timer);
1002
+ if (!responseEnded(pending.res)) {
1003
+ pending.res.destroy();
1004
+ }
1005
+ }
1006
+ pendingHttp.clear();
1007
+ for (const ws of clients.values()) ws.terminate();
1008
+ clients.clear();
1009
+ protocolState.clear();
1010
+ outboundQueues.clear();
1011
+ if (nudgeController) nudgeController.clear();
1012
+ nudgeController = null;
1013
+ if (approvalReplay) approvalReplay.clear();
1014
+ for (const socket of sockets) socket.destroy();
1015
+ sockets.clear();
1016
+ if (wss) wss.close();
1017
+ return new Promise((resolve) => {
1018
+ if (!httpServer) {
1019
+ resolve();
1020
+ return;
1021
+ }
1022
+ const server = httpServer;
1023
+ let resolved = false;
1024
+ function finish() {
1025
+ if (resolved) return;
1026
+ resolved = true;
1027
+ if (httpServer === server) {
1028
+ httpServer = null;
1029
+ wss = null;
1030
+ }
1031
+ resolve();
1032
+ }
1033
+ const fallback = setTimeout(finish, 50);
1034
+ if (typeof fallback.unref === "function") fallback.unref();
1035
+ httpServer.close(() => {
1036
+ clearTimeout(fallback);
1037
+ finish();
1038
+ });
1039
+ if (typeof httpServer.closeAllConnections === "function") {
1040
+ httpServer.closeAllConnections();
1041
+ }
1042
+ });
1043
+ }
1044
+
1045
+ return {
1046
+ start,
1047
+ close,
1048
+ address,
1049
+ handleMainMessage,
1050
+ };
1051
+ }