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,172 @@
1
+ import { PerformanceObserver, monitorEventLoopDelay } from "node:perf_hooks";
2
+
3
+ const LOW_LAG_SAMPLE_THRESHOLD_MS = 50;
4
+ const LOW_LAG_HEARTBEAT_MS = 60_000;
5
+ const SPIKE_BUCKETS_MS = [250, 1_000, 5_000, 10_000];
6
+ const DEFAULT_SEND_BUFFER_HIGH_WATER_BYTES = 262_144;
7
+
8
+ export function classifyFrameForRelayHealth(messageType) {
9
+ if (
10
+ messageType === "ping" ||
11
+ messageType === "pong" ||
12
+ messageType === "protocolHelloAck" ||
13
+ messageType === "ocuclaw.sync.resume.ack" ||
14
+ messageType === "ocuclaw.worker.health"
15
+ ) {
16
+ return "transport-control";
17
+ }
18
+ if (
19
+ messageType === "ocuclaw.operation.received" ||
20
+ messageType === "ocuclaw.worker.operation.received" ||
21
+ messageType === "ocuclaw.relay.busy"
22
+ ) {
23
+ return "operation-control";
24
+ }
25
+ if (
26
+ messageType === "ocuclaw.message.send.ack" ||
27
+ messageType === "ocuclaw.approval.resolve.ack"
28
+ ) {
29
+ return "transactional";
30
+ }
31
+ if (
32
+ messageType === "ocuclaw.session.switch.applied" ||
33
+ messageType === "ocuclaw.session.config.set.ack" ||
34
+ messageType === "ocuclaw.evenai.settings.set.ack" ||
35
+ messageType === "ocuclaw.settings.set.ack"
36
+ ) {
37
+ return "latest-mutation";
38
+ }
39
+ if (
40
+ messageType === "ocuclaw.runtime.status" ||
41
+ messageType === "ocuclaw.session.list.result" ||
42
+ messageType === "ocuclaw.provider.usage.snapshot" ||
43
+ messageType === "ocuclaw.model.catalog.snapshot" ||
44
+ messageType === "ocuclaw.skills.catalog.snapshot"
45
+ ) {
46
+ return "coalescable-read";
47
+ }
48
+ return "best-effort";
49
+ }
50
+
51
+ export function createRelayHealthMonitor(options) {
52
+ const now = options.now || Date.now;
53
+ const setIntervalFn = options.setIntervalFn || setInterval;
54
+ const clearIntervalFn = options.clearIntervalFn || clearInterval;
55
+ const sampleIntervalMs = options.sampleIntervalMs || 1_000;
56
+ const sendBufferHighWaterBytes =
57
+ options.sendBufferHighWaterBytes || DEFAULT_SEND_BUFFER_HIGH_WATER_BYTES;
58
+ let intervalId = null;
59
+ let delayMonitor = null;
60
+ let gcObserver = null;
61
+ let lastLowLagEmitAtMs = null;
62
+ const emittedSpikeBuckets = new Set();
63
+
64
+ function defaultSampleEventLoopDelay() {
65
+ if (!delayMonitor) {
66
+ return { p50Ms: 0, p95Ms: 0, maxMs: 0, sampleCount: 0 };
67
+ }
68
+ const sample = {
69
+ p50Ms: delayMonitor.percentile(50) / 1_000_000,
70
+ p95Ms: delayMonitor.percentile(95) / 1_000_000,
71
+ maxMs: delayMonitor.max / 1_000_000,
72
+ sampleCount: Number(delayMonitor.count || 0),
73
+ };
74
+ delayMonitor.reset();
75
+ return sample;
76
+ }
77
+
78
+ function emitLagSample() {
79
+ const sample = options.sampleEventLoopDelay
80
+ ? options.sampleEventLoopDelay()
81
+ : defaultSampleEventLoopDelay();
82
+ const nowMs = now();
83
+ const shouldEmitLowLagHeartbeat =
84
+ lastLowLagEmitAtMs === null || nowMs - lastLowLagEmitAtMs >= LOW_LAG_HEARTBEAT_MS;
85
+ const shouldEmitSample =
86
+ sample.maxMs >= LOW_LAG_SAMPLE_THRESHOLD_MS || shouldEmitLowLagHeartbeat;
87
+ if (shouldEmitSample) {
88
+ if (sample.maxMs < LOW_LAG_SAMPLE_THRESHOLD_MS) {
89
+ lastLowLagEmitAtMs = nowMs;
90
+ }
91
+ options.emitDebug("event_loop_lag_sample", "debug", {
92
+ p50Ms: Math.round(sample.p50Ms),
93
+ p95Ms: Math.round(sample.p95Ms),
94
+ maxMs: Math.round(sample.maxMs),
95
+ sampleCount: sample.sampleCount,
96
+ sampleIntervalMs,
97
+ });
98
+ }
99
+ for (const bucketMs of SPIKE_BUCKETS_MS) {
100
+ if (sample.maxMs >= bucketMs && !emittedSpikeBuckets.has(bucketMs)) {
101
+ emittedSpikeBuckets.add(bucketMs);
102
+ options.emitDebug("event_loop_lag_spike", "warn", {
103
+ bucketMs,
104
+ maxMs: Math.round(sample.maxMs),
105
+ p95Ms: Math.round(sample.p95Ms),
106
+ sampleCount: sample.sampleCount,
107
+ });
108
+ }
109
+ }
110
+ if (sample.maxMs < SPIKE_BUCKETS_MS[0]) {
111
+ emittedSpikeBuckets.clear();
112
+ }
113
+ }
114
+
115
+ function start() {
116
+ if (intervalId !== null) return;
117
+ lastLowLagEmitAtMs = now();
118
+ if (!options.sampleEventLoopDelay) {
119
+ delayMonitor = monitorEventLoopDelay({ resolution: 50 });
120
+ delayMonitor.enable();
121
+ }
122
+ if (options.observeGc !== false) {
123
+ gcObserver = new PerformanceObserver((list) => {
124
+ for (const entry of list.getEntries()) {
125
+ options.emitDebug("gc_pause", "warn", {
126
+ durationMs: Math.round(entry.duration),
127
+ kind: Number(entry.kind || 0),
128
+ });
129
+ }
130
+ });
131
+ gcObserver.observe({ entryTypes: ["gc"] });
132
+ }
133
+ intervalId = setIntervalFn(emitLagSample, sampleIntervalMs);
134
+ }
135
+
136
+ function stop() {
137
+ if (intervalId !== null) {
138
+ clearIntervalFn(intervalId);
139
+ intervalId = null;
140
+ }
141
+ if (delayMonitor) {
142
+ delayMonitor.disable();
143
+ delayMonitor = null;
144
+ }
145
+ if (gcObserver) {
146
+ gcObserver.disconnect();
147
+ gcObserver = null;
148
+ }
149
+ }
150
+
151
+ function observeSendBuffer(params) {
152
+ if (
153
+ params.bufferedAmountBytes !== null &&
154
+ Number.isFinite(params.bufferedAmountBytes) &&
155
+ params.bufferedAmountBytes >= sendBufferHighWaterBytes
156
+ ) {
157
+ options.emitDebug("ws_send_buffer_high_water", "warn", {
158
+ clientId: params.clientId,
159
+ messageType: params.messageType,
160
+ frameClass: classifyFrameForRelayHealth(params.messageType),
161
+ bufferedAmountBytes: params.bufferedAmountBytes,
162
+ thresholdBytes: sendBufferHighWaterBytes,
163
+ });
164
+ }
165
+ }
166
+
167
+ function emitQueueDepth(snapshot) {
168
+ options.emitDebug("relay_queue_depth", "debug", snapshot);
169
+ }
170
+
171
+ return { start, stop, observeSendBuffer, emitQueueDepth };
172
+ }
@@ -0,0 +1,263 @@
1
+ const SLOW_BUCKETS_MS = [5_000, 10_000, 30_000, 60_000];
2
+
3
+ export function createRelayOperationRegistry(options) {
4
+ const now = options.now || Date.now;
5
+ const retentionMs = options.retentionMs || 90_000;
6
+ const entriesByRequestId = new Map();
7
+ const requestIdByRunId = new Map();
8
+
9
+ function prune(nowMs = now()) {
10
+ for (const [requestId, entry] of entriesByRequestId) {
11
+ if (nowMs - entry.receivedAtMs > retentionMs) {
12
+ entriesByRequestId.delete(requestId);
13
+ for (const [runId, mappedRequestId] of requestIdByRunId) {
14
+ if (mappedRequestId === requestId) {
15
+ requestIdByRunId.delete(runId);
16
+ }
17
+ }
18
+ }
19
+ }
20
+ }
21
+
22
+ function receiptFrame(entry) {
23
+ return JSON.stringify({
24
+ type: "ocuclaw.operation.received",
25
+ requestId: entry.requestId,
26
+ operation: entry.operation,
27
+ status: "upstream_pending",
28
+ phase: "relay_received",
29
+ receivedAtMs: entry.receivedAtMs,
30
+ });
31
+ }
32
+
33
+ function beginMessageSend(params) {
34
+ prune();
35
+ const existing = entriesByRequestId.get(params.requestId);
36
+ if (existing) {
37
+ options.emitDebug(
38
+ "operation_received",
39
+ "info",
40
+ {
41
+ requestId: existing.requestId,
42
+ operation: existing.operation,
43
+ class: existing.class,
44
+ clientId: existing.clientId,
45
+ sessionKey: existing.sessionKey,
46
+ duplicate: true,
47
+ retainedFinal: existing.finalFrame !== null,
48
+ },
49
+ { sessionKey: existing.sessionKey },
50
+ );
51
+ return {
52
+ duplicate: true,
53
+ receipt: receiptFrame(existing),
54
+ finalFrame: existing.finalFrame,
55
+ complete: (finalFrame, result = {}) =>
56
+ complete(existing.requestId, finalFrame, result),
57
+ fail: (finalFrame, result = {}) =>
58
+ fail(existing.requestId, finalFrame, result),
59
+ };
60
+ }
61
+
62
+ const receivedAtMs = now();
63
+ const entry = {
64
+ requestId: params.requestId,
65
+ operation: "message.send",
66
+ class: "transactional",
67
+ clientId: params.clientId,
68
+ sessionKey: params.sessionKey || null,
69
+ receivedAtMs,
70
+ startedAtMs: null,
71
+ upstreamAckAtMs: null,
72
+ lifecycleStartAtMs: null,
73
+ firstStreamAtMs: null,
74
+ completedAtMs: null,
75
+ finalFrame: null,
76
+ slowBuckets: new Set(),
77
+ };
78
+ entriesByRequestId.set(entry.requestId, entry);
79
+ options.emitDebug(
80
+ "operation_received",
81
+ "info",
82
+ {
83
+ requestId: entry.requestId,
84
+ operation: entry.operation,
85
+ class: entry.class,
86
+ clientId: entry.clientId,
87
+ sessionKey: entry.sessionKey,
88
+ duplicate: false,
89
+ },
90
+ { sessionKey: entry.sessionKey },
91
+ );
92
+ return {
93
+ duplicate: false,
94
+ receipt: receiptFrame(entry),
95
+ finalFrame: null,
96
+ complete: (finalFrame, result = {}) => complete(entry.requestId, finalFrame, result),
97
+ fail: (finalFrame, result = {}) => fail(entry.requestId, finalFrame, result),
98
+ };
99
+ }
100
+
101
+ function markStarted(requestId) {
102
+ const entry = entriesByRequestId.get(requestId);
103
+ if (!entry || entry.startedAtMs !== null) return;
104
+ entry.startedAtMs = now();
105
+ options.emitDebug(
106
+ "operation_started",
107
+ "debug",
108
+ {
109
+ requestId,
110
+ operation: entry.operation,
111
+ class: entry.class,
112
+ elapsedMs: entry.startedAtMs - entry.receivedAtMs,
113
+ },
114
+ { sessionKey: entry.sessionKey },
115
+ );
116
+ }
117
+
118
+ function markUpstreamAck(requestId, params = {}) {
119
+ const entry = entriesByRequestId.get(requestId);
120
+ if (!entry) return;
121
+ entry.upstreamAckAtMs = now();
122
+ if (params.runId) requestIdByRunId.set(params.runId, requestId);
123
+ options.emitDebug(
124
+ "operation_phase",
125
+ "debug",
126
+ {
127
+ requestId,
128
+ operation: entry.operation,
129
+ class: entry.class,
130
+ phase: "upstream_ack",
131
+ status: params.status || null,
132
+ elapsedMs: entry.upstreamAckAtMs - entry.receivedAtMs,
133
+ upstreamAckMs: entry.upstreamAckAtMs - entry.receivedAtMs,
134
+ },
135
+ { sessionKey: entry.sessionKey, runId: params.runId || null },
136
+ );
137
+ }
138
+
139
+ function markRunPhase(runId, phase) {
140
+ const requestId = requestIdByRunId.get(runId);
141
+ if (!requestId) return;
142
+ const entry = entriesByRequestId.get(requestId);
143
+ if (!entry) return;
144
+ const atMs = now();
145
+ if (phase === "lifecycle_start" && entry.lifecycleStartAtMs === null) {
146
+ entry.lifecycleStartAtMs = atMs;
147
+ }
148
+ if (phase === "first_stream" && entry.firstStreamAtMs === null) {
149
+ entry.firstStreamAtMs = atMs;
150
+ }
151
+ if (phase === "complete") {
152
+ entry.completedAtMs = atMs;
153
+ }
154
+ options.emitDebug(
155
+ "operation_phase",
156
+ "debug",
157
+ {
158
+ requestId,
159
+ operation: entry.operation,
160
+ class: entry.class,
161
+ phase,
162
+ elapsedMs: atMs - entry.receivedAtMs,
163
+ ackToPhaseMs: entry.upstreamAckAtMs ? atMs - entry.upstreamAckAtMs : null,
164
+ lifecycleStartMs: entry.lifecycleStartAtMs
165
+ ? entry.lifecycleStartAtMs - entry.receivedAtMs
166
+ : null,
167
+ firstStreamMs: entry.firstStreamAtMs
168
+ ? entry.firstStreamAtMs - entry.receivedAtMs
169
+ : null,
170
+ },
171
+ { sessionKey: entry.sessionKey, runId },
172
+ );
173
+ }
174
+
175
+ function complete(requestId, finalFrame, result = {}) {
176
+ const entry = entriesByRequestId.get(requestId);
177
+ if (!entry) return;
178
+ const completedAtMs = now();
179
+ entry.completedAtMs = completedAtMs;
180
+ entry.finalFrame = finalFrame;
181
+ options.emitDebug(
182
+ "operation_completed",
183
+ "info",
184
+ {
185
+ requestId,
186
+ operation: entry.operation,
187
+ class: entry.class,
188
+ elapsedMs: completedAtMs - entry.receivedAtMs,
189
+ resultSource: "typed_final_frame",
190
+ ...result,
191
+ },
192
+ { sessionKey: entry.sessionKey },
193
+ );
194
+ }
195
+
196
+ function fail(requestId, finalFrame, result = {}) {
197
+ const entry = entriesByRequestId.get(requestId);
198
+ if (!entry) return;
199
+ const failedAtMs = now();
200
+ entry.completedAtMs = failedAtMs;
201
+ entry.finalFrame = finalFrame;
202
+ options.emitDebug(
203
+ "operation_failed",
204
+ "warn",
205
+ {
206
+ requestId,
207
+ operation: entry.operation,
208
+ class: entry.class,
209
+ elapsedMs: failedAtMs - entry.receivedAtMs,
210
+ ...result,
211
+ },
212
+ { sessionKey: entry.sessionKey },
213
+ );
214
+ }
215
+
216
+ function queueDepthSnapshot() {
217
+ prune();
218
+ let transactional = 0;
219
+ for (const entry of entriesByRequestId.values()) {
220
+ if (!entry.completedAtMs && entry.class === "transactional") transactional += 1;
221
+ }
222
+ return {
223
+ transactional,
224
+ latestMutation: 0,
225
+ coalescableRead: 0,
226
+ bestEffort: 0,
227
+ };
228
+ }
229
+
230
+ function reconcileRequestIds(requestIds) {
231
+ prune();
232
+ const ids = Array.isArray(requestIds) ? requestIds : [];
233
+ return ids
234
+ .map((value) => (typeof value === "string" ? value.trim() : ""))
235
+ .filter(Boolean)
236
+ .map((requestId) => {
237
+ const entry = entriesByRequestId.get(requestId);
238
+ if (!entry) {
239
+ return {
240
+ requestId,
241
+ known: false,
242
+ receiptFrame: null,
243
+ finalFrame: null,
244
+ };
245
+ }
246
+ return {
247
+ requestId,
248
+ known: true,
249
+ receiptFrame: receiptFrame(entry),
250
+ finalFrame: entry.finalFrame || null,
251
+ };
252
+ });
253
+ }
254
+
255
+ return {
256
+ beginMessageSend,
257
+ markStarted,
258
+ markUpstreamAck,
259
+ markRunPhase,
260
+ queueDepthSnapshot,
261
+ reconcileRequestIds,
262
+ };
263
+ }
@@ -33,10 +33,37 @@ function resolveOpenclawClient(openclawClientOverride, runtimeConfig, logger, st
33
33
  });
34
34
  }
35
35
 
36
+ // Shared cross-context relay singleton. OpenClaw can load the plugin in
37
+ // multiple isolated registration contexts (gateway startup, agent runs,
38
+ // tool discovery) and each call to createOcuClawRelayService produces a
39
+ // fresh service instance. Only one of those instances actually starts the
40
+ // WebSocket-bearing relay; the others need to reach the same relay so that
41
+ // tools registered in those contexts (e.g. render_glasses_ui) can call
42
+ // sendGlassesUiRender / onGlassesUiResult against the real running relay.
43
+ const SHARED_RELAY_SYMBOL = Symbol.for("ocuclaw.shared.relay");
44
+
45
+ function getSharedRelay() {
46
+ return globalThis[SHARED_RELAY_SYMBOL] || null;
47
+ }
48
+
49
+ function setSharedRelay(relay) {
50
+ globalThis[SHARED_RELAY_SYMBOL] = relay;
51
+ }
52
+
53
+ function clearSharedRelay(relay) {
54
+ if (globalThis[SHARED_RELAY_SYMBOL] === relay) {
55
+ globalThis[SHARED_RELAY_SYMBOL] = null;
56
+ }
57
+ }
58
+
36
59
  export function createOcuClawRelayService(opts = {}) {
37
60
  const baseLogger = normalizeLogger(opts.logger);
38
61
  let relay = null;
39
62
  let runtimeConfig = null;
63
+ const pendingGlassesUiResultHandlers = [];
64
+ const pendingGlassesUiNavEventHandlers = [];
65
+ const pendingDeviceInfoResponseHandlers = [];
66
+ const pendingAppClientDisconnectHandlers = [];
40
67
 
41
68
  function getRuntimeConfig() {
42
69
  if (!runtimeConfig) {
@@ -94,9 +121,38 @@ export function createOcuClawRelayService(opts = {}) {
94
121
  openclawClient,
95
122
  logger,
96
123
  consoleLogPath: opts.consoleLogPath,
124
+ activityStatusAdapter: {
125
+ freshnessWindowMs: config.freshnessWindowMs,
126
+ now: () => Date.now(),
127
+ },
97
128
  });
98
129
 
99
130
  relay = nextRelay;
131
+ setSharedRelay(nextRelay);
132
+ if (typeof nextRelay.onGlassesUiResult === "function" && pendingGlassesUiResultHandlers.length > 0) {
133
+ for (const handler of pendingGlassesUiResultHandlers) {
134
+ nextRelay.onGlassesUiResult(handler);
135
+ }
136
+ }
137
+ if (typeof nextRelay.onGlassesUiNavEvent === "function" && pendingGlassesUiNavEventHandlers.length > 0) {
138
+ for (const handler of pendingGlassesUiNavEventHandlers.splice(0)) {
139
+ nextRelay.onGlassesUiNavEvent(handler);
140
+ }
141
+ }
142
+ if (typeof nextRelay.onDeviceInfoResponse === "function" && pendingDeviceInfoResponseHandlers.length > 0) {
143
+ // splice(0) atomically drains the buffer so the unsubscribe returned to
144
+ // pre-start() callers becomes a safe no-op (it indexOf-s an empty list).
145
+ // After flush, handler lifetime is owned by the live relay's own
146
+ // unsubscribe; the pending buffer is no longer the source of truth.
147
+ for (const handler of pendingDeviceInfoResponseHandlers.splice(0)) {
148
+ nextRelay.onDeviceInfoResponse(handler);
149
+ }
150
+ }
151
+ if (typeof nextRelay.onAppClientDisconnect === "function" && pendingAppClientDisconnectHandlers.length > 0) {
152
+ for (const handler of pendingAppClientDisconnectHandlers.splice(0)) {
153
+ nextRelay.onAppClientDisconnect(handler);
154
+ }
155
+ }
100
156
  try {
101
157
  await Promise.resolve(nextRelay.start());
102
158
  logger.info(
@@ -104,6 +160,7 @@ export function createOcuClawRelayService(opts = {}) {
104
160
  );
105
161
  return nextRelay;
106
162
  } catch (err) {
163
+ clearSharedRelay(nextRelay);
107
164
  relay = null;
108
165
  throw err;
109
166
  }
@@ -117,14 +174,108 @@ export function createOcuClawRelayService(opts = {}) {
117
174
  const logger = normalizeLogger(stopOpts.logger || baseLogger);
118
175
  const activeRelay = relay;
119
176
  relay = null;
177
+ clearSharedRelay(activeRelay);
120
178
  await Promise.resolve(activeRelay.stop());
121
179
  logger.info("[ocuclaw] relay service stopped");
122
180
  }
123
181
 
182
+ function resolveLiveRelay() {
183
+ // Prefer the local-instance relay (started in this context); fall back to
184
+ // the cross-context shared relay (started in a sibling plugin-load context
185
+ // such as the gateway main process). This matters because OpenClaw can
186
+ // invoke a plugin's register() in multiple isolated contexts, but only
187
+ // one of them holds the running WebSocket relay.
188
+ return relay || getSharedRelay();
189
+ }
124
190
  return {
125
191
  getRuntimeConfig,
126
192
  getRelay() {
127
- return relay;
193
+ return resolveLiveRelay();
194
+ },
195
+ sendGlassesUiRender(params) {
196
+ const liveRelay = resolveLiveRelay();
197
+ if (!liveRelay || typeof liveRelay.sendGlassesUiRender !== "function") {
198
+ throw new Error("ocuclaw relay not started");
199
+ }
200
+ liveRelay.sendGlassesUiRender(params);
201
+ },
202
+ sendGlassesUiSurfaceUpdate(params) {
203
+ const liveRelay = resolveLiveRelay();
204
+ if (!liveRelay || typeof liveRelay.sendGlassesUiSurfaceUpdate !== "function") {
205
+ throw new Error("ocuclaw relay not started");
206
+ }
207
+ liveRelay.sendGlassesUiSurfaceUpdate(params);
208
+ },
209
+ // Permanent glasses.lifecycle debug category passthrough (nav reconcile +
210
+ // cron pause/resume/tick). No-op until the relay is live; events are only
211
+ // recorded when the category is enabled via debug-set.
212
+ emitGlassesUiLifecycle(event, severity, data) {
213
+ const liveRelay = resolveLiveRelay();
214
+ if (liveRelay && typeof liveRelay.emitGlassesUiLifecycle === "function") {
215
+ liveRelay.emitGlassesUiLifecycle(event, severity, data);
216
+ }
217
+ },
218
+ onGlassesUiResult(handler) {
219
+ if (typeof handler !== "function") {
220
+ return () => {};
221
+ }
222
+ const liveRelay = resolveLiveRelay();
223
+ if (liveRelay && typeof liveRelay.onGlassesUiResult === "function") {
224
+ return liveRelay.onGlassesUiResult(handler);
225
+ }
226
+ // Relay not started yet — buffer until start() can register the handler.
227
+ pendingGlassesUiResultHandlers.push(handler);
228
+ return () => {
229
+ const idx = pendingGlassesUiResultHandlers.indexOf(handler);
230
+ if (idx !== -1) pendingGlassesUiResultHandlers.splice(idx, 1);
231
+ };
232
+ },
233
+ onGlassesUiNavEvent(handler) {
234
+ if (typeof handler !== "function") return () => {};
235
+ const liveRelay = resolveLiveRelay();
236
+ if (liveRelay && typeof liveRelay.onGlassesUiNavEvent === "function") {
237
+ return liveRelay.onGlassesUiNavEvent(handler);
238
+ }
239
+ pendingGlassesUiNavEventHandlers.push(handler);
240
+ return () => {
241
+ const idx = pendingGlassesUiNavEventHandlers.indexOf(handler);
242
+ if (idx !== -1) pendingGlassesUiNavEventHandlers.splice(idx, 1);
243
+ };
244
+ },
245
+ onAppClientDisconnect(handler) {
246
+ if (typeof handler !== "function") return () => {};
247
+ const liveRelay = resolveLiveRelay();
248
+ if (liveRelay && typeof liveRelay.onAppClientDisconnect === "function") {
249
+ return liveRelay.onAppClientDisconnect(handler);
250
+ }
251
+ // Relay not started yet — buffer until start() can register the handler.
252
+ pendingAppClientDisconnectHandlers.push(handler);
253
+ return () => {
254
+ const idx = pendingAppClientDisconnectHandlers.indexOf(handler);
255
+ if (idx !== -1) pendingAppClientDisconnectHandlers.splice(idx, 1);
256
+ };
257
+ },
258
+ sendDeviceInfoRequest(params) {
259
+ const liveRelay = resolveLiveRelay();
260
+ if (!liveRelay || typeof liveRelay.sendDeviceInfoRequest !== "function") {
261
+ throw new Error("ocuclaw relay not started");
262
+ }
263
+ liveRelay.sendDeviceInfoRequest(params);
264
+ },
265
+ onDeviceInfoResponse(handler) {
266
+ if (typeof handler !== "function") {
267
+ return () => {};
268
+ }
269
+ const liveRelay = resolveLiveRelay();
270
+ if (liveRelay && typeof liveRelay.onDeviceInfoResponse === "function") {
271
+ return liveRelay.onDeviceInfoResponse(handler);
272
+ }
273
+ // Relay not started yet — buffer until start() can register the handler.
274
+ pendingDeviceInfoResponseHandlers.push(handler);
275
+ return () => {
276
+ const idx = pendingDeviceInfoResponseHandlers.indexOf(handler);
277
+ if (idx !== -1) pendingDeviceInfoResponseHandlers.splice(idx, 1);
278
+ };
128
279
  },
129
280
  getEvenAiSettingsSnapshot() {
130
281
  if (relay && typeof relay.getEvenAiSettingsSnapshot === "function") {
@@ -140,6 +291,55 @@ export function createOcuClawRelayService(opts = {}) {
140
291
  trackedThrowawayKeys: [],
141
292
  };
142
293
  },
294
+ getSessionTitle(sessionKey) {
295
+ if (relay && typeof relay.getSessionTitle === "function") {
296
+ return relay.getSessionTitle(sessionKey);
297
+ }
298
+ return null;
299
+ },
300
+ hasRecordedUserMessage(sessionKey) {
301
+ if (relay && typeof relay.hasRecordedUserMessage === "function") {
302
+ return relay.hasRecordedUserMessage(sessionKey);
303
+ }
304
+ // Fail-closed when the relay isn't running: block titling.
305
+ return false;
306
+ },
307
+ isNeuralSessionNamesEnabled(sessionKey) {
308
+ if (relay && typeof relay.isNeuralSessionNamesEnabled === "function") {
309
+ return relay.isNeuralSessionNamesEnabled(sessionKey);
310
+ }
311
+ return true;
312
+ },
313
+ isSessionUserLocked(sessionKey) {
314
+ if (relay && typeof relay.isSessionUserLocked === "function") {
315
+ return relay.isSessionUserLocked(sessionKey);
316
+ }
317
+ return false;
318
+ },
319
+ peekSessionKey() {
320
+ if (relay && typeof relay.peekSessionKey === "function") {
321
+ return relay.peekSessionKey();
322
+ }
323
+ return null;
324
+ },
325
+ recordNeuralSessionNamesEnabled(sessionKey, enabled) {
326
+ if (relay && typeof relay.recordNeuralSessionNamesEnabled === "function") {
327
+ relay.recordNeuralSessionNamesEnabled(sessionKey, enabled);
328
+ }
329
+ },
330
+ setSessionTitle(sessionKey, title, opts) {
331
+ if (relay && typeof relay.setSessionTitle === "function") {
332
+ return relay.setSessionTitle(sessionKey, title, opts);
333
+ }
334
+ return { ok: false, code: "relay_not_running" };
335
+ },
336
+ hasConnectedAppClient() {
337
+ const liveRelay = resolveLiveRelay();
338
+ if (liveRelay && typeof liveRelay.hasConnectedAppClient === "function") {
339
+ return liveRelay.hasConnectedAppClient();
340
+ }
341
+ return false;
342
+ },
143
343
  start,
144
344
  stop,
145
345
  };