ocuclaw 1.2.4 → 1.3.1

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 (60) hide show
  1. package/README.md +21 -6
  2. package/dist/config/runtime-config.js +84 -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 +56 -182
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +754 -83
  27. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  28. package/dist/runtime/plugin-version-service.js +23 -0
  29. package/dist/runtime/protocol-adapter.js +9 -0
  30. package/dist/runtime/provider-usage-select.js +168 -0
  31. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  32. package/dist/runtime/relay-core.js +1293 -225
  33. package/dist/runtime/relay-health-monitor.js +172 -0
  34. package/dist/runtime/relay-operation-registry.js +263 -0
  35. package/dist/runtime/relay-service.js +201 -1
  36. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  37. package/dist/runtime/relay-worker-entry.js +32 -0
  38. package/dist/runtime/relay-worker-health.js +272 -0
  39. package/dist/runtime/relay-worker-protocol.js +281 -0
  40. package/dist/runtime/relay-worker-queue.js +202 -0
  41. package/dist/runtime/relay-worker-supervisor.js +1004 -0
  42. package/dist/runtime/relay-worker-transport.js +1051 -0
  43. package/dist/runtime/session-context-service.js +189 -0
  44. package/dist/runtime/session-service.js +638 -27
  45. package/dist/runtime/upstream-runtime.js +1167 -60
  46. package/dist/tools/device-info-tool.js +242 -0
  47. package/dist/tools/glasses-ui-cron.js +427 -0
  48. package/dist/tools/glasses-ui-descriptors.js +261 -0
  49. package/dist/tools/glasses-ui-limits.js +21 -0
  50. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  51. package/dist/tools/glasses-ui-recipes.js +581 -0
  52. package/dist/tools/glasses-ui-surfaces.js +278 -0
  53. package/dist/tools/glasses-ui-template.js +182 -0
  54. package/dist/tools/glasses-ui-tool.js +1111 -0
  55. package/dist/tools/session-title-tool.js +209 -0
  56. package/dist/version.js +2 -0
  57. package/openclaw.plugin.json +163 -15
  58. package/package.json +14 -5
  59. package/skills/glasses-ui/SKILL.md +156 -0
  60. package/dist/runtime/downstream-server.js +0 -1891
@@ -0,0 +1,1004 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { Worker } from "node:worker_threads";
3
+ import {
4
+ APP_PROTOCOL,
5
+ DEFAULT_NUDGE_THRESHOLDS,
6
+ DEFAULT_WORKER_HEALTH_THRESHOLDS,
7
+ DEFAULT_WORKER_QUEUE_CAPS,
8
+ DEFAULT_WORKER_RPC_LIMITS,
9
+ WORKER_FEATURES,
10
+ formatMainOperationReceived,
11
+ normalizeRequestId,
12
+ parseNonNegativeRevision,
13
+ } from "./relay-worker-protocol.js";
14
+
15
+ const DEFAULT_WORKER_TYPE = "module";
16
+ const AUTOMATION_STATE_FALLBACK_MS = 1000;
17
+
18
+ function normalizeLogger(logger) {
19
+ if (!logger || typeof logger !== "object") return console;
20
+ return {
21
+ info: typeof logger.info === "function" ? logger.info.bind(logger) : console.log,
22
+ warn: typeof logger.warn === "function" ? logger.warn.bind(logger) : console.warn,
23
+ error: typeof logger.error === "function" ? logger.error.bind(logger) : console.error,
24
+ debug: typeof logger.debug === "function" ? logger.debug.bind(logger) : console.debug,
25
+ };
26
+ }
27
+
28
+ function defaultWorkerFactory() {
29
+ return new Worker(new URL("./relay-worker-entry.js", import.meta.url), {
30
+ type: DEFAULT_WORKER_TYPE,
31
+ });
32
+ }
33
+
34
+ function normalizeFrameList(value) {
35
+ if (value === null || value === undefined) return [];
36
+ return Array.isArray(value) ? value : [value];
37
+ }
38
+
39
+ function frameMatchesOperationReceipt(frame, requestId) {
40
+ if (!requestId || typeof frame !== "string") return false;
41
+ try {
42
+ const parsed = JSON.parse(frame);
43
+ return parsed &&
44
+ parsed.type === "ocuclaw.operation.received" &&
45
+ parsed.requestId === requestId;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function addWorkerEpochToSendAck(frame, workerEpoch) {
52
+ if (typeof frame !== "string") return frame;
53
+ try {
54
+ const parsed = JSON.parse(frame);
55
+ if (parsed && parsed.type === "ocuclaw.message.send.ack") {
56
+ return JSON.stringify({
57
+ ...parsed,
58
+ workerEpoch,
59
+ });
60
+ }
61
+ } catch {
62
+ return frame;
63
+ }
64
+ return frame;
65
+ }
66
+
67
+ function parseFrame(frame) {
68
+ try {
69
+ return JSON.parse(frame);
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ function responseToWorkerMessage(requestId, result) {
76
+ const headers = result && result.headers && typeof result.headers === "object"
77
+ ? result.headers
78
+ : {};
79
+ const body = result && Buffer.isBuffer(result.body)
80
+ ? result.body
81
+ : Buffer.from(result && result.body !== undefined ? String(result.body) : "");
82
+ return {
83
+ kind: "http.response",
84
+ requestId,
85
+ statusCode: Number.isFinite(result && result.statusCode) ? result.statusCode : 200,
86
+ headers,
87
+ bodyBase64: body.toString("base64"),
88
+ };
89
+ }
90
+
91
+ function parseRequestIdFromRaw(raw) {
92
+ return normalizeRequestId((parseFrame(raw) || {}).requestId);
93
+ }
94
+
95
+ export function createRelayWorkerSupervisor(options = {}) {
96
+ const logger = normalizeLogger(options.logger);
97
+ const handler = options.handler || options.downstreamHandler || null;
98
+ const operationRegistry = options.operationRegistry || null;
99
+ const workerFactory =
100
+ typeof options.workerFactory === "function" ? options.workerFactory : defaultWorkerFactory;
101
+ const wssEvents = new EventEmitter();
102
+ let worker = null;
103
+ let workerEpoch = 0;
104
+ let addressValue = null;
105
+ let startPromise = null;
106
+ let readyPromise = Promise.resolve();
107
+ let resolveReady = null;
108
+ let rejectReady = null;
109
+ let closing = false;
110
+ let activeOperationBarrier = null;
111
+ let mainHeartbeatTimer = null;
112
+ const clients = new Map();
113
+ const pendingReadinessProbeRequests = new Map();
114
+ const pendingAutomationStateRequests = new Map();
115
+
116
+ function clearPendingAutomationStateRequest(requestId) {
117
+ const pending = pendingAutomationStateRequests.get(requestId);
118
+ if (pending && pending.fallbackTimer) {
119
+ clearTimeout(pending.fallbackTimer);
120
+ }
121
+ pendingAutomationStateRequests.delete(requestId);
122
+ return pending || null;
123
+ }
124
+
125
+ function clearPendingAutomationStateRequests() {
126
+ for (const requestId of pendingAutomationStateRequests.keys()) {
127
+ clearPendingAutomationStateRequest(requestId);
128
+ }
129
+ }
130
+
131
+ function resetReadyPromise() {
132
+ readyPromise = new Promise((resolve, reject) => {
133
+ resolveReady = resolve;
134
+ rejectReady = reject;
135
+ });
136
+ startPromise = readyPromise;
137
+ }
138
+
139
+ function postToWorker(message) {
140
+ if (worker && typeof worker.postMessage === "function") {
141
+ worker.postMessage(message);
142
+ }
143
+ }
144
+
145
+ function mainHeartbeatIntervalMs() {
146
+ if (Number.isFinite(options.mainHeartbeatIntervalMs)) {
147
+ return Math.max(10, Math.floor(options.mainHeartbeatIntervalMs));
148
+ }
149
+ return DEFAULT_WORKER_HEALTH_THRESHOLDS.heartbeatIntervalMs;
150
+ }
151
+
152
+ function buildMainHeartbeat(workerEpochValue) {
153
+ const resumeState =
154
+ typeof options.getCurrentResumeState === "function"
155
+ ? options.getCurrentResumeState() || {}
156
+ : {};
157
+ const heartbeat = {
158
+ kind: "main.heartbeat",
159
+ emittedAtMs: Date.now(),
160
+ workerEpoch: workerEpochValue,
161
+ };
162
+ const pagesRevision = parseNonNegativeRevision(resumeState.pagesRevision);
163
+ const statusRevision = parseNonNegativeRevision(resumeState.statusRevision);
164
+ if (pagesRevision !== null) heartbeat.cachedPagesRevision = pagesRevision;
165
+ if (statusRevision !== null) heartbeat.cachedStatusRevision = statusRevision;
166
+ return heartbeat;
167
+ }
168
+
169
+ function stopMainHeartbeat() {
170
+ if (!mainHeartbeatTimer) return;
171
+ clearInterval(mainHeartbeatTimer);
172
+ mainHeartbeatTimer = null;
173
+ }
174
+
175
+ function startMainHeartbeat(workerEpochValue) {
176
+ stopMainHeartbeat();
177
+ postToWorker(buildMainHeartbeat(workerEpochValue));
178
+ mainHeartbeatTimer = setInterval(() => {
179
+ postToWorker(buildMainHeartbeat(workerEpochValue));
180
+ }, mainHeartbeatIntervalMs());
181
+ if (typeof mainHeartbeatTimer.unref === "function") mainHeartbeatTimer.unref();
182
+ }
183
+
184
+ function buildManifest() {
185
+ workerEpoch += 1;
186
+ return {
187
+ kind: "manifest",
188
+ manifestId: `worker-${workerEpoch}-${Date.now()}`,
189
+ workerEpoch,
190
+ host: options.host || "127.0.0.1",
191
+ port: Number.isFinite(options.port) ? options.port : 0,
192
+ relayToken: options.token || "",
193
+ pluginId: options.pluginId || "ocuclaw",
194
+ pluginVersion:
195
+ typeof options.getPluginVersion === "function"
196
+ ? options.getPluginVersion()
197
+ : "",
198
+ requiresClientVersion:
199
+ typeof options.getRequiresClientVersion === "function"
200
+ ? options.getRequiresClientVersion()
201
+ : "",
202
+ supportedProtocolVersions: ["v2"],
203
+ featureFlags: WORKER_FEATURES,
204
+ routes: {
205
+ webSocketPaths: ["/"],
206
+ mainForwardedHttpPaths: ["/v1/chat/completions"],
207
+ },
208
+ externalDebugToolsEnabled: options.externalDebugToolsEnabled === true,
209
+ nudge: {
210
+ ...DEFAULT_NUDGE_THRESHOLDS,
211
+ ...(options.nudge || {}),
212
+ },
213
+ queue: {
214
+ ...DEFAULT_WORKER_QUEUE_CAPS,
215
+ },
216
+ health: {
217
+ ...DEFAULT_WORKER_HEALTH_THRESHOLDS,
218
+ },
219
+ rpc: {
220
+ ...DEFAULT_WORKER_RPC_LIMITS,
221
+ httpRequestTimeoutMs:
222
+ Number.isFinite(options.evenAiRequestTimeoutMs)
223
+ ? Math.max(
224
+ DEFAULT_WORKER_RPC_LIMITS.httpRequestTimeoutMs,
225
+ Math.floor(options.evenAiRequestTimeoutMs),
226
+ )
227
+ : DEFAULT_WORKER_RPC_LIMITS.httpRequestTimeoutMs,
228
+ httpMaxBodyBytes:
229
+ Number.isFinite(options.evenAiMaxBodyBytes)
230
+ ? Math.max(
231
+ DEFAULT_WORKER_RPC_LIMITS.httpMaxBodyBytes,
232
+ Math.floor(options.evenAiMaxBodyBytes),
233
+ )
234
+ : DEFAULT_WORKER_RPC_LIMITS.httpMaxBodyBytes,
235
+ httpMaxResponseBytes:
236
+ Number.isFinite(options.evenAiMaxResponseBytes)
237
+ ? Math.max(1, Math.floor(options.evenAiMaxResponseBytes))
238
+ : DEFAULT_WORKER_RPC_LIMITS.httpMaxResponseBytes,
239
+ },
240
+ initialCache: buildInitialCache(),
241
+ };
242
+ }
243
+
244
+ function buildInitialCache() {
245
+ const initialCache = {};
246
+ if (typeof options.getCurrentPages === "function") {
247
+ const pages = options.getCurrentPages();
248
+ if (typeof pages === "string") initialCache.pages = pages;
249
+ }
250
+ if (typeof options.getCurrentStatus === "function") {
251
+ const status = options.getCurrentStatus();
252
+ if (typeof status === "string") initialCache.status = status;
253
+ }
254
+ if (typeof options.getCurrentDebugConfig === "function") {
255
+ const debugConfig = options.getCurrentDebugConfig();
256
+ if (typeof debugConfig === "string") initialCache.debugConfig = debugConfig;
257
+ }
258
+
259
+ const resumeState =
260
+ typeof options.getCurrentResumeState === "function"
261
+ ? options.getCurrentResumeState() || {}
262
+ : {};
263
+ let pagesRevision = parseNonNegativeRevision(resumeState.pagesRevision);
264
+ let statusRevision = parseNonNegativeRevision(resumeState.statusRevision);
265
+ if (pagesRevision === null && initialCache.pages) {
266
+ pagesRevision = parseNonNegativeRevision((parseFrame(initialCache.pages) || {}).revision);
267
+ }
268
+ if (statusRevision === null && initialCache.status) {
269
+ statusRevision = parseNonNegativeRevision((parseFrame(initialCache.status) || {}).revision);
270
+ }
271
+ if (pagesRevision !== null) initialCache.pagesRevision = pagesRevision;
272
+ if (statusRevision !== null) initialCache.statusRevision = statusRevision;
273
+ if (initialCache.pages || initialCache.status || initialCache.debugConfig) {
274
+ const now = Date.now();
275
+ initialCache.lastMainFrameAtMs = now;
276
+ if (initialCache.status) initialCache.lastMainStatusAtMs = now;
277
+ }
278
+ if (
279
+ typeof options.getAgentAvatarHash === "function" &&
280
+ typeof options.getAgentAvatarDataUriByHash === "function"
281
+ ) {
282
+ const hash = options.getAgentAvatarHash();
283
+ if (typeof hash === "string" && hash) {
284
+ const dataUri = options.getAgentAvatarDataUriByHash(hash);
285
+ if (typeof dataUri === "string" && dataUri) {
286
+ initialCache.agentAvatar = { hash, dataUri };
287
+ }
288
+ }
289
+ }
290
+ return initialCache;
291
+ }
292
+
293
+ let lastPushedAgentAvatarHash = null;
294
+ function notifyAgentAvatarChanged(hash, dataUri) {
295
+ const nextHash =
296
+ typeof hash === "string" && hash && typeof dataUri === "string" && dataUri
297
+ ? hash
298
+ : null;
299
+ if (nextHash === lastPushedAgentAvatarHash) return;
300
+ lastPushedAgentAvatarHash = nextHash;
301
+ postToWorker({
302
+ kind: "main.avatar",
303
+ agentAvatar: nextHash ? { hash: nextHash, dataUri } : null,
304
+ emittedAtMs: Date.now(),
305
+ });
306
+ }
307
+
308
+ function postMainFrame(target, frame, clientId) {
309
+ if (typeof frame !== "string") return;
310
+ // audit #19: parse the frame once. parseMessageType is itself a full JSON.parse,
311
+ // so deriving type from a single parseFrame avoids a second parse on the hot
312
+ // pages/status path below (and is neutral — still one parse — on other frames).
313
+ const parsed = parseFrame(frame);
314
+ const type = parsed && typeof parsed.type === "string" ? parsed.type : null;
315
+ const message = {
316
+ kind: "main.frame",
317
+ target,
318
+ clientId,
319
+ frame,
320
+ emittedAtMs: Date.now(),
321
+ type,
322
+ };
323
+ if (type === APP_PROTOCOL.pages || type === APP_PROTOCOL.status) {
324
+ const revision = parseNonNegativeRevision((parsed || {}).revision);
325
+ if (revision !== null) {
326
+ message.revisions =
327
+ type === APP_PROTOCOL.pages
328
+ ? { pagesRevision: revision }
329
+ : { statusRevision: revision };
330
+ }
331
+ }
332
+ // F17: requestId-scope the operation barrier. The barrier exists to
333
+ // preserve send-ordering in the worker path, but it used to divert EVERY
334
+ // broadcast/broadcastApp frame for the whole send→ack window — stalling
335
+ // unrelated live broadcasts (streaming deltas / activity / typing / status
336
+ // from a prior, still-streaming turn) until flush. Only hold a broadcast
337
+ // that is causally tied to the in-flight barrier.requestId; let everything
338
+ // else pass straight through. The send's own operation-received and ack are
339
+ // unicast (never diverted) and the op-received is posted synchronously
340
+ // ahead of the async ack, so "operation-received precedes that op's ack"
341
+ // holds independent of this barrier.
342
+ if (
343
+ activeOperationBarrier &&
344
+ (target === "broadcast" || target === "broadcastApp") &&
345
+ parsed &&
346
+ normalizeRequestId(parsed.requestId) === activeOperationBarrier.requestId
347
+ ) {
348
+ activeOperationBarrier.frames.push(message);
349
+ return;
350
+ }
351
+ postToWorker(message);
352
+ }
353
+
354
+ function flushOperationBarrier(barrier) {
355
+ if (!barrier) return;
356
+ if (activeOperationBarrier === barrier) activeOperationBarrier = null;
357
+ for (const frame of barrier.frames) {
358
+ postToWorker(frame);
359
+ }
360
+ }
361
+
362
+ async function processResult(clientId, result, processOptions = {}) {
363
+ const resolved = await Promise.resolve(result);
364
+ if (!resolved) return;
365
+ for (const frame of normalizeFrameList(resolved.unicast)) {
366
+ const nextFrame = addWorkerEpochToSendAck(frame, workerEpoch);
367
+ if (
368
+ processOptions.suppressMainReceiptForRequestId &&
369
+ frameMatchesOperationReceipt(nextFrame, processOptions.suppressMainReceiptForRequestId)
370
+ ) {
371
+ continue;
372
+ }
373
+ postMainFrame("unicast", nextFrame, clientId);
374
+ }
375
+ if (resolved.readinessProbe) {
376
+ const requestId = normalizeRequestId(resolved.readinessProbe.requestId);
377
+ const targetClientId = normalizeRequestId(resolved.readinessProbe.targetClientId);
378
+ const message =
379
+ typeof resolved.readinessProbe.message === "string"
380
+ ? resolved.readinessProbe.message
381
+ : null;
382
+ if (!requestId || !targetClientId || !message || !isAppClient(targetClientId)) {
383
+ postMainFrame(
384
+ "unicast",
385
+ formatReadinessProbeFailure(
386
+ requestId,
387
+ "no_downstream_client",
388
+ "No downstream app client connected",
389
+ ),
390
+ clientId,
391
+ );
392
+ } else {
393
+ pendingReadinessProbeRequests.set(requestId, {
394
+ requesterClientId: clientId,
395
+ targetClientId,
396
+ createdAtMs: Date.now(),
397
+ });
398
+ postMainFrame("unicast", message, targetClientId);
399
+ }
400
+ }
401
+ if (resolved.automationStateRequest) {
402
+ const requestId = normalizeRequestId(resolved.automationStateRequest.requestId);
403
+ const targetClientId = normalizeRequestId(resolved.automationStateRequest.targetClientId);
404
+ const message =
405
+ typeof resolved.automationStateRequest.message === "string"
406
+ ? resolved.automationStateRequest.message
407
+ : null;
408
+ if (!requestId || !targetClientId || !message || !isAppClient(targetClientId)) {
409
+ postMainFrame(
410
+ "unicast",
411
+ formatAutomationStateFailure(
412
+ requestId,
413
+ "snapshot_unavailable",
414
+ "Automation state snapshot is unavailable",
415
+ ),
416
+ clientId,
417
+ );
418
+ } else {
419
+ const fallbackTimer = setTimeout(() => {
420
+ const pending = pendingAutomationStateRequests.get(requestId);
421
+ if (!pending || pending.targetClientId !== targetClientId) return;
422
+ pendingAutomationStateRequests.delete(requestId);
423
+ postMainFrame(
424
+ "unicast",
425
+ formatAutomationStateFailure(
426
+ requestId,
427
+ "snapshot_unavailable",
428
+ "Automation state snapshot is unavailable",
429
+ ),
430
+ clientId,
431
+ );
432
+ }, AUTOMATION_STATE_FALLBACK_MS);
433
+ if (fallbackTimer && typeof fallbackTimer.unref === "function") {
434
+ fallbackTimer.unref();
435
+ }
436
+ pendingAutomationStateRequests.set(requestId, {
437
+ requesterClientId: clientId,
438
+ targetClientId,
439
+ createdAtMs: Date.now(),
440
+ fallbackTimer,
441
+ });
442
+ postMainFrame("unicast", message, targetClientId);
443
+ }
444
+ }
445
+ for (const frame of normalizeFrameList(resolved.broadcast)) {
446
+ postMainFrame("broadcast", frame);
447
+ }
448
+ for (const frame of normalizeFrameList(resolved.broadcastApp)) {
449
+ postMainFrame("broadcastApp", frame);
450
+ }
451
+ if (resolved.followup) {
452
+ await processResult(clientId, resolved.followup, processOptions);
453
+ }
454
+ }
455
+
456
+ async function handleHttpRequest(message) {
457
+ if (typeof options.handleBufferedEvenAiHttpRequest !== "function") {
458
+ postToWorker(responseToWorkerMessage(message.requestId, {
459
+ statusCode: 404,
460
+ headers: { "content-type": "text/plain; charset=utf-8" },
461
+ body: Buffer.from("not found"),
462
+ }));
463
+ return;
464
+ }
465
+ try {
466
+ const result = await Promise.resolve(options.handleBufferedEvenAiHttpRequest(message));
467
+ postToWorker(responseToWorkerMessage(message.requestId, result));
468
+ } catch (err) {
469
+ logger.warn(`[relay-worker] buffered HTTP request failed: ${err && err.message ? err.message : err}`);
470
+ postToWorker(responseToWorkerMessage(message.requestId, {
471
+ statusCode: 503,
472
+ headers: { "content-type": "text/plain; charset=utf-8" },
473
+ body: Buffer.from("relay worker HTTP bridge failed"),
474
+ }));
475
+ }
476
+ }
477
+
478
+ function handleHttpCancel(message) {
479
+ if (typeof options.cancelBufferedEvenAiHttpRequest !== "function") {
480
+ return;
481
+ }
482
+ try {
483
+ options.cancelBufferedEvenAiHttpRequest(message);
484
+ } catch (err) {
485
+ logger.warn(`[relay-worker] buffered HTTP cancel failed: ${err && err.message ? err.message : err}`);
486
+ }
487
+ }
488
+
489
+ function getActiveSessionKey() {
490
+ return typeof options.getActiveSessionKey === "function"
491
+ ? options.getActiveSessionKey() || null
492
+ : null;
493
+ }
494
+
495
+ function emitRelaySession(event, sessionKey, payloadFactory) {
496
+ if (typeof options.emitDebug !== "function") return;
497
+ options.emitDebug(
498
+ "relay.session",
499
+ event,
500
+ "info",
501
+ { sessionKey: sessionKey || undefined },
502
+ payloadFactory,
503
+ );
504
+ }
505
+
506
+ function handleMessage(message) {
507
+ if (!message || typeof message !== "object") return;
508
+ if (message.kind === "worker.ready") {
509
+ addressValue = message.address || null;
510
+ wssEvents.emit("listening");
511
+ if (resolveReady) {
512
+ resolveReady(message);
513
+ resolveReady = null;
514
+ rejectReady = null;
515
+ }
516
+ return;
517
+ }
518
+ if (message.kind === "worker.error") {
519
+ logger.warn(`[relay-worker] ${message.message || "worker error"}`);
520
+ return;
521
+ }
522
+ if (message.kind === "app.message") {
523
+ if (!handler || typeof handler.handleMessage !== "function") return;
524
+ const processOptions = {};
525
+ if (message.operation === "message.send" && message.requestId) {
526
+ processOptions.suppressMainReceiptForRequestId = message.requestId;
527
+ activeOperationBarrier = {
528
+ requestId: message.requestId,
529
+ frames: [],
530
+ };
531
+ postMainFrame(
532
+ "unicast",
533
+ formatMainOperationReceived({
534
+ requestId: message.requestId,
535
+ operation: "message.send",
536
+ }),
537
+ message.clientId,
538
+ );
539
+ }
540
+ const barrier = activeOperationBarrier;
541
+ (async () => {
542
+ try {
543
+ await processResult(
544
+ message.clientId,
545
+ handler.handleMessage(message.clientId, message.raw),
546
+ processOptions,
547
+ );
548
+ } catch (err) {
549
+ logger.warn(`[relay-worker] app message handling failed: ${err && err.message ? err.message : err}`);
550
+ } finally {
551
+ flushOperationBarrier(barrier);
552
+ }
553
+ })();
554
+ return;
555
+ }
556
+ if (message.kind === "operation.reconcile") {
557
+ const results =
558
+ operationRegistry && typeof operationRegistry.reconcileRequestIds === "function"
559
+ ? operationRegistry.reconcileRequestIds(message.requestIds)
560
+ : [];
561
+ postToWorker({
562
+ kind: "operation.reconcile.result",
563
+ clientId: message.clientId,
564
+ requestIds: message.requestIds,
565
+ results,
566
+ });
567
+ return;
568
+ }
569
+ if (message.kind === "http.request") {
570
+ handleHttpRequest(message);
571
+ return;
572
+ }
573
+ if (message.kind === "http.cancel") {
574
+ handleHttpCancel(message);
575
+ return;
576
+ }
577
+ if (message.kind === "client.identified") {
578
+ clients.set(message.clientId, {
579
+ clientId: message.clientId,
580
+ clientKind: message.clientKind || "unknown",
581
+ clientName: message.clientName || null,
582
+ clientVersion: message.clientVersion || null,
583
+ sessionKey: message.sessionKey || null,
584
+ readinessSnapshot: message.readinessSnapshot || null,
585
+ connectedAtMs: Number.isFinite(message.connectedAtMs)
586
+ ? message.connectedAtMs
587
+ : Date.now(),
588
+ updatedAtMs: Date.now(),
589
+ });
590
+ const connectedEntry = clients.get(message.clientId) || null;
591
+ emitRelaySession("downstream_client_connected", getActiveSessionKey(), () => ({
592
+ clientId: message.clientId,
593
+ connectedCount: clients.size,
594
+ connectedAtMs: connectedEntry ? connectedEntry.connectedAtMs : null,
595
+ remoteAddress: null,
596
+ userAgentTail: null,
597
+ }));
598
+ return;
599
+ }
600
+ if (message.kind === "client.disconnected") {
601
+ const disconnectedEntry = clients.get(message.clientId) || null;
602
+ for (const [requestId, pending] of pendingReadinessProbeRequests) {
603
+ if (
604
+ pending.requesterClientId === message.clientId ||
605
+ pending.targetClientId === message.clientId
606
+ ) {
607
+ pendingReadinessProbeRequests.delete(requestId);
608
+ }
609
+ }
610
+ for (const [requestId, pending] of pendingAutomationStateRequests) {
611
+ if (
612
+ pending.requesterClientId === message.clientId ||
613
+ pending.targetClientId === message.clientId
614
+ ) {
615
+ clearPendingAutomationStateRequest(requestId);
616
+ }
617
+ }
618
+ clients.delete(message.clientId);
619
+ const disconnectedConnectedAtMs = disconnectedEntry ? disconnectedEntry.connectedAtMs : null;
620
+ const disconnectedLifetimeMs = Number.isFinite(disconnectedConnectedAtMs)
621
+ ? Math.max(0, Date.now() - disconnectedConnectedAtMs)
622
+ : null;
623
+ emitRelaySession("downstream_client_disconnected", getActiveSessionKey(), () => ({
624
+ clientId: message.clientId,
625
+ connectedCount: clients.size,
626
+ connectedAtMs: disconnectedConnectedAtMs,
627
+ lifetimeMs: disconnectedLifetimeMs,
628
+ closeCode: Number.isFinite(message.closeCode) ? message.closeCode : null,
629
+ closeReasonTail: message.closeReasonTail || null,
630
+ role: disconnectedEntry ? disconnectedEntry.clientKind : null,
631
+ clientKind: disconnectedEntry ? disconnectedEntry.clientKind : null,
632
+ protocolVersion: null,
633
+ protocolReason: null,
634
+ clientName: disconnectedEntry ? disconnectedEntry.clientName : null,
635
+ clientVersion: disconnectedEntry ? disconnectedEntry.clientVersion : null,
636
+ }));
637
+ if (
638
+ disconnectedEntry &&
639
+ disconnectedEntry.clientKind === "app" &&
640
+ typeof options.onAppClientDisconnect === "function"
641
+ ) {
642
+ // Drain a session's live-refresh crons only when THAT session's last
643
+ // app client disconnects (round-2: a same-session duplicate keeps it
644
+ // live; round-6: per-session so A drains independently of B). The
645
+ // disconnecting client is already removed from `clients`, so the
646
+ // excludeClientId is belt-and-suspenders.
647
+ const drainSessionKey =
648
+ typeof disconnectedEntry.sessionKey === "string" && disconnectedEntry.sessionKey
649
+ ? disconnectedEntry.sessionKey
650
+ : null;
651
+ if (drainSessionKey) {
652
+ if (getConnectedAppEntries(message.clientId, drainSessionKey).length === 0) {
653
+ options.onAppClientDisconnect(drainSessionKey);
654
+ }
655
+ } else if (getConnectedAppEntries(message.clientId).length === 0) {
656
+ options.onAppClientDisconnect(getActiveSessionKey());
657
+ }
658
+ }
659
+ return;
660
+ }
661
+ if (message.kind === "client.visibility") {
662
+ const visibilityEntry = clients.get(message.clientId) || null;
663
+ emitRelaySession(
664
+ "downstream_transport_visibility",
665
+ (visibilityEntry && visibilityEntry.sessionKey) || getActiveSessionKey(),
666
+ () => ({
667
+ clientId: message.clientId,
668
+ state: message.state || null,
669
+ connectedCount: clients.size,
670
+ role: visibilityEntry ? visibilityEntry.clientKind : null,
671
+ clientKind: visibilityEntry ? visibilityEntry.clientKind : message.clientKind || null,
672
+ clientName: visibilityEntry ? visibilityEntry.clientName : message.clientName || null,
673
+ clientVersion: visibilityEntry
674
+ ? visibilityEntry.clientVersion
675
+ : message.clientVersion || null,
676
+ protocolVersion: message.protocolVersion || null,
677
+ }),
678
+ );
679
+ return;
680
+ }
681
+ if (message.kind === "client.readinessSnapshot") {
682
+ const entry = clients.get(message.clientId);
683
+ if (entry) {
684
+ entry.readinessSnapshot = message.readinessSnapshot || null;
685
+ entry.updatedAtMs = Number.isFinite(message.updatedAtMs)
686
+ ? message.updatedAtMs
687
+ : Date.now();
688
+ }
689
+ return;
690
+ }
691
+
692
+ if (message.kind === "client.readinessProbeAck") {
693
+ const ack = message.ack && typeof message.ack === "object" ? message.ack : null;
694
+ const requestId = normalizeRequestId(ack && ack.requestId);
695
+ const pending = requestId ? pendingReadinessProbeRequests.get(requestId) : null;
696
+ if (!pending || pending.targetClientId !== message.clientId) return;
697
+ pendingReadinessProbeRequests.delete(requestId);
698
+ const protocol = clients.get(message.clientId) || {};
699
+ const frame =
700
+ handler && typeof handler.formatReadinessProbeAck === "function"
701
+ ? handler.formatReadinessProbeAck({
702
+ ok: ack.ok !== false,
703
+ requestId,
704
+ reasonCode: ack.reasonCode || null,
705
+ message: ack.message || null,
706
+ activeSessionKey: ack.activeSessionKey || null,
707
+ emittedAtMs: ack.emittedAtMs,
708
+ clientId: message.clientId,
709
+ clientName: protocol.clientName || null,
710
+ clientVersion: protocol.clientVersion || null,
711
+ })
712
+ : JSON.stringify({
713
+ type: APP_PROTOCOL.readinessProbeAck,
714
+ ok: ack.ok !== false,
715
+ requestId,
716
+ reasonCode: ack.reasonCode || null,
717
+ message: ack.message || null,
718
+ activeSessionKey: ack.activeSessionKey || null,
719
+ emittedAtMs: ack.emittedAtMs,
720
+ clientId: message.clientId,
721
+ clientName: protocol.clientName || null,
722
+ clientVersion: protocol.clientVersion || null,
723
+ });
724
+ postMainFrame("unicast", frame, pending.requesterClientId);
725
+ return;
726
+ }
727
+
728
+ if (message.kind === "client.automationStateSnapshot") {
729
+ const snapshot =
730
+ message.snapshot && typeof message.snapshot === "object" ? message.snapshot : null;
731
+ const requestId = normalizeRequestId(snapshot && snapshot.requestId);
732
+ const pending = requestId ? pendingAutomationStateRequests.get(requestId) : null;
733
+ if (!pending || pending.targetClientId !== message.clientId) return;
734
+ clearPendingAutomationStateRequest(requestId);
735
+ const frame =
736
+ handler && typeof handler.formatAutomationStateSnapshot === "function"
737
+ ? handler.formatAutomationStateSnapshot({
738
+ ok: snapshot.ok !== false,
739
+ requestId,
740
+ state: snapshot.state || null,
741
+ reasonCode: snapshot.reasonCode || null,
742
+ message: snapshot.message || null,
743
+ })
744
+ : JSON.stringify({
745
+ type: APP_PROTOCOL.automationStateSnapshot,
746
+ ok: snapshot.ok !== false,
747
+ requestId,
748
+ state: snapshot.state || null,
749
+ reasonCode: snapshot.reasonCode || null,
750
+ message: snapshot.message || null,
751
+ });
752
+ postMainFrame("unicast", frame, pending.requesterClientId);
753
+ return;
754
+ }
755
+ if (message.kind === "debug" && typeof options.emitDebug === "function") {
756
+ options.emitDebug(
757
+ message.category || "relay.worker.health",
758
+ message.event || "worker_event",
759
+ message.severity || "debug",
760
+ null,
761
+ () => message.data || {},
762
+ );
763
+ }
764
+ }
765
+
766
+ function start() {
767
+ if (startPromise) return startPromise;
768
+ closing = false;
769
+ resetReadyPromise();
770
+ startWorker();
771
+ return startPromise;
772
+ }
773
+
774
+ function startWorker() {
775
+ const nextWorker = workerFactory();
776
+ worker = nextWorker;
777
+ nextWorker.on("message", handleMessage);
778
+ nextWorker.on("error", (err) => {
779
+ logger.error(`[relay-worker] worker error: ${err && err.message ? err.message : err}`);
780
+ if (rejectReady) rejectReady(err);
781
+ wssEvents.emit("error", err);
782
+ });
783
+ const startedWorker = nextWorker;
784
+ nextWorker.on("exit", (code) => {
785
+ const unexpected = !closing;
786
+ if (worker === startedWorker) worker = null;
787
+ stopMainHeartbeat();
788
+ if (unexpected) {
789
+ const err = new Error(`relay worker exited with code ${code}`);
790
+ logger.warn(`[relay-worker] ${err.message}`);
791
+ if (rejectReady) rejectReady(err);
792
+ resolveReady = null;
793
+ rejectReady = null;
794
+ addressValue = null;
795
+ clients.clear();
796
+ pendingReadinessProbeRequests.clear();
797
+ clearPendingAutomationStateRequests();
798
+ if (wssEvents.listenerCount("error") > 0) {
799
+ wssEvents.emit("error", err);
800
+ }
801
+ resetReadyPromise();
802
+ startWorker();
803
+ }
804
+ });
805
+ const manifest = buildManifest();
806
+ postToWorker(manifest);
807
+ startMainHeartbeat(manifest.workerEpoch);
808
+ return nextWorker;
809
+ }
810
+
811
+ function close() {
812
+ closing = true;
813
+ stopMainHeartbeat();
814
+ if (!worker) {
815
+ startPromise = null;
816
+ return Promise.resolve();
817
+ }
818
+ const activeWorker = worker;
819
+ return new Promise((resolve) => {
820
+ let resolved = false;
821
+ function finish() {
822
+ if (resolved) return;
823
+ resolved = true;
824
+ if (worker === activeWorker) worker = null;
825
+ startPromise = null;
826
+ addressValue = null;
827
+ clients.clear();
828
+ pendingReadinessProbeRequests.clear();
829
+ clearPendingAutomationStateRequests();
830
+ resolve();
831
+ }
832
+ const timer = setTimeout(() => {
833
+ activeWorker.terminate().finally(finish);
834
+ }, 500);
835
+ if (typeof timer.unref === "function") timer.unref();
836
+ activeWorker.once("exit", () => {
837
+ clearTimeout(timer);
838
+ finish();
839
+ });
840
+ activeWorker.once("message", (message) => {
841
+ if (message && message.kind === "worker.closed") {
842
+ clearTimeout(timer);
843
+ activeWorker.terminate().finally(finish);
844
+ }
845
+ });
846
+ activeWorker.postMessage({ kind: "shutdown" });
847
+ });
848
+ }
849
+
850
+ function broadcast(frame) {
851
+ postMainFrame("broadcast", frame);
852
+ }
853
+
854
+ function broadcastApp(frame) {
855
+ postMainFrame("broadcastApp", frame);
856
+ }
857
+
858
+ function unicast(clientId, frame) {
859
+ postMainFrame("unicast", frame, clientId);
860
+ }
861
+
862
+ function getConnectedAppEntries(excludeClientId = null, sessionKey = null) {
863
+ const entries = [];
864
+ for (const entry of clients.values()) {
865
+ if (entry.clientKind !== "app") continue;
866
+ if (excludeClientId && entry.clientId === excludeClientId) continue;
867
+ if (sessionKey != null && entry.sessionKey !== sessionKey) continue;
868
+ entries.push(entry);
869
+ }
870
+ return entries;
871
+ }
872
+
873
+ function isAppClient(clientId) {
874
+ const entry = clients.get(clientId);
875
+ return entry && entry.clientKind === "app";
876
+ }
877
+
878
+ function formatReadinessProbeFailure(requestId, reasonCode, message) {
879
+ if (handler && typeof handler.formatReadinessProbeAck === "function") {
880
+ return handler.formatReadinessProbeAck({
881
+ ok: false,
882
+ requestId,
883
+ reasonCode,
884
+ message,
885
+ });
886
+ }
887
+ return JSON.stringify({
888
+ type: APP_PROTOCOL.readinessProbeAck,
889
+ ok: false,
890
+ requestId,
891
+ reasonCode,
892
+ message,
893
+ });
894
+ }
895
+
896
+ function formatAutomationStateFailure(requestId, reasonCode, message) {
897
+ if (handler && typeof handler.formatAutomationStateSnapshot === "function") {
898
+ return handler.formatAutomationStateSnapshot({
899
+ ok: false,
900
+ requestId,
901
+ reasonCode,
902
+ message,
903
+ });
904
+ }
905
+ return JSON.stringify({
906
+ type: APP_PROTOCOL.automationStateSnapshot,
907
+ ok: false,
908
+ requestId,
909
+ reasonCode,
910
+ message,
911
+ });
912
+ }
913
+
914
+ function getReadinessSnapshot() {
915
+ const appClients = getConnectedAppEntries();
916
+ const updatedAtMs = appClients.reduce((latest, entry) => {
917
+ const candidate = Number.isFinite(entry.updatedAtMs) ? entry.updatedAtMs : null;
918
+ return candidate === null ? latest : Math.max(latest || 0, candidate);
919
+ }, null);
920
+ return {
921
+ connectedClientCount: appClients.length,
922
+ fanoutRecipientCount: appClients.length,
923
+ updatedAtMs,
924
+ clients: appClients.map((entry) => ({
925
+ clientId: entry.clientId,
926
+ clientKind: entry.clientKind,
927
+ clientName: entry.clientName,
928
+ clientVersion: entry.clientVersion,
929
+ protocolVersion: "v2",
930
+ protocolSessionKey: entry.sessionKey,
931
+ readinessSnapshot: entry.readinessSnapshot,
932
+ connectedAtMs: entry.connectedAtMs,
933
+ })),
934
+ };
935
+ }
936
+
937
+ return {
938
+ start,
939
+ close,
940
+ broadcast,
941
+ broadcastApp,
942
+ unicast,
943
+ notifyAgentAvatarChanged,
944
+ getClientIds() {
945
+ return Array.from(clients.keys());
946
+ },
947
+ getConnectedAppCount(excludeClientId = null, sessionKey = null) {
948
+ return getConnectedAppEntries(excludeClientId, sessionKey).length;
949
+ },
950
+ getReadinessSnapshot,
951
+ closeConnectedAppClients(opts = {}) {
952
+ const excludeClientId =
953
+ typeof opts.excludeClientId === "string" && opts.excludeClientId.trim()
954
+ ? opts.excludeClientId.trim()
955
+ : null;
956
+ const reason =
957
+ typeof opts.reason === "string" && opts.reason.trim()
958
+ ? opts.reason.trim()
959
+ : "server_close";
960
+ const closedClientIds = getConnectedAppEntries(excludeClientId).map((entry) => entry.clientId);
961
+ if (closedClientIds.length > 0) {
962
+ postToWorker({
963
+ kind: "worker.closeClients",
964
+ clientIds: closedClientIds,
965
+ reason,
966
+ workerEpoch,
967
+ });
968
+ }
969
+ return {
970
+ closedCount: closedClientIds.length,
971
+ closedClientIds,
972
+ reason,
973
+ };
974
+ },
975
+ get readyPromise() {
976
+ return readyPromise;
977
+ },
978
+ get httpServer() {
979
+ return null;
980
+ },
981
+ get wss() {
982
+ return {
983
+ address() {
984
+ return addressValue;
985
+ },
986
+ on(eventName, listener) {
987
+ wssEvents.on(eventName, listener);
988
+ return this;
989
+ },
990
+ once(eventName, listener) {
991
+ wssEvents.once(eventName, listener);
992
+ return this;
993
+ },
994
+ off(eventName, listener) {
995
+ wssEvents.off(eventName, listener);
996
+ return this;
997
+ },
998
+ emit(eventName, ...args) {
999
+ return wssEvents.emit(eventName, ...args);
1000
+ },
1001
+ };
1002
+ },
1003
+ };
1004
+ }