ocuclaw 0.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +63 -8
  2. package/dist/config/runtime-config.js +81 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +41 -184
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +909 -68
  27. package/dist/runtime/downstream-server.js +1004 -512
  28. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  29. package/dist/runtime/plugin-update-service.js +216 -0
  30. package/dist/runtime/protocol-adapter.js +9 -0
  31. package/dist/runtime/provider-usage-select.js +168 -0
  32. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  33. package/dist/runtime/relay-core.js +1357 -210
  34. package/dist/runtime/relay-health-monitor.js +172 -0
  35. package/dist/runtime/relay-operation-registry.js +263 -0
  36. package/dist/runtime/relay-service.js +201 -1
  37. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  38. package/dist/runtime/relay-worker-entry.js +32 -0
  39. package/dist/runtime/relay-worker-health.js +272 -0
  40. package/dist/runtime/relay-worker-protocol.js +285 -0
  41. package/dist/runtime/relay-worker-queue.js +202 -0
  42. package/dist/runtime/relay-worker-supervisor.js +1081 -0
  43. package/dist/runtime/relay-worker-transport.js +1051 -0
  44. package/dist/runtime/session-context-service.js +189 -0
  45. package/dist/runtime/session-service.js +656 -38
  46. package/dist/runtime/upstream-runtime.js +1167 -60
  47. package/dist/tools/device-info-tool.js +242 -0
  48. package/dist/tools/glasses-ui-cron.js +427 -0
  49. package/dist/tools/glasses-ui-descriptors.js +261 -0
  50. package/dist/tools/glasses-ui-limits.js +21 -0
  51. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  52. package/dist/tools/glasses-ui-recipes.js +746 -0
  53. package/dist/tools/glasses-ui-surfaces.js +278 -0
  54. package/dist/tools/glasses-ui-template.js +182 -0
  55. package/dist/tools/glasses-ui-tool.js +1147 -0
  56. package/dist/tools/session-title-tool.js +209 -0
  57. package/dist/version.js +2 -0
  58. package/openclaw.plugin.json +163 -15
  59. package/package.json +12 -4
@@ -1,9 +1,15 @@
1
1
  import * as fs from "node:fs";
2
- import * as http from "node:http";
3
2
  import * as path from "node:path";
3
+ import * as childProcess from "node:child_process";
4
+ import { EventEmitter } from "node:events";
5
+ import { createPluginUpdateService } from "./plugin-update-service.js";
4
6
  import * as conversationStateModule from "../domain/conversation-state.js";
5
7
  import { createDebugStore } from "../domain/debug-store.js";
8
+ import { summarizeGlassesUiContent } from "../domain/glasses-ui-content-summary.js";
6
9
  import { composeReadabilitySystemPrompt } from "../domain/readability-system-prompt.js";
10
+ import { composeNeuralEmojiReactorSystemPrompt } from "../domain/neural-emoji-reactor-system-prompt.js";
11
+ import { composeNeuralPaceModulatorSystemPrompt } from "../domain/neural-pace-modulator-system-prompt.js";
12
+ import { composeGlassesUiNudgeSystemPrompt } from "../domain/glasses-ui-system-prompt.js";
7
13
  import { createActivityStatusAdapter } from "../domain/activity-status-adapter.js";
8
14
  import { createEvenAiEndpoint } from "../even-ai/even-ai-endpoint.js";
9
15
  import { createEvenAiRouter } from "../even-ai/even-ai-router.js";
@@ -12,15 +18,20 @@ import { createEvenAiSettingsStore } from "../even-ai/even-ai-settings-store.js"
12
18
  import { createPluginOpenclawClient } from "../gateway/openclaw-client.js";
13
19
  import { createPluginRpcGatewayBridge } from "../gateway/gateway-bridge.js";
14
20
  import { createDownstreamHandler } from "./downstream-handler.js";
15
- import { createDownstreamServer } from "./downstream-server.js";
16
21
  import { createOcuClawSettingsStore } from "./ocuclaw-settings-store.js";
22
+ import { createRelayHealthMonitor } from "./relay-health-monitor.js";
23
+ import { createRelayOperationRegistry } from "./relay-operation-registry.js";
24
+ import { createRelayWorkerSupervisor } from "./relay-worker-supervisor.js";
17
25
  import { createSessionService } from "./session-service.js";
18
26
  import { createUpstreamRuntime } from "./upstream-runtime.js";
19
27
 
20
28
  const SONIOX_TEMP_KEY_URL = "https://api.soniox.com/v1/auth/temporary-api-key";
21
29
  const SONIOX_MODELS_URL = "https://api.soniox.com/v1/models";
22
30
  const DEFAULT_SONIOX_TEMP_KEY_EXPIRES_IN_SECONDS = 3600;
23
- const EVEN_AI_HANDLED = Symbol.for("ocuclaw.evenai.handled");
31
+ // Maximum time (ms) to wait for a Soniox temp-key mint before aborting the
32
+ // fetch. 8 s is a conservative cold-path ceiling; tests inject a tiny value
33
+ // via opts.sonioxTemporaryKeyMintTimeoutMs to make assertions fast.
34
+ const DEFAULT_SONIOX_TEMP_KEY_MINT_TIMEOUT_MS = 8000;
24
35
  const EVEN_AI_NAMESPACE_PREFIX = "ocuclaw:even-ai";
25
36
  const EVEN_AI_NAMESPACE_PREFIX_WITH_DELIMITER = "ocuclaw:even-ai:";
26
37
  const LISTEN_INTERCEPT_RECOVERY_ERROR = "Voice interrupted; retry";
@@ -159,6 +170,10 @@ function normalizeSonioxTemporaryKeyErrorCode(err) {
159
170
  : "";
160
171
  const lowered = message.toLowerCase();
161
172
  if (!message) return "soniox_temp_key_request_failed";
173
+ // AbortError from the per-fetch timeout AbortController.
174
+ if (err && err.name === "AbortError") {
175
+ return "soniox_temp_key_mint_timeout";
176
+ }
162
177
  if (lowered.includes("api key is not configured")) {
163
178
  return "soniox_temp_key_not_configured";
164
179
  }
@@ -210,31 +225,71 @@ function normalizeSonioxModelEntryRows(result) {
210
225
  return models;
211
226
  }
212
227
 
213
- function createOwnedRelayHttpServer() {
214
- return http.createServer((_req, res) => {
215
- if (res.writableEnded || res[EVEN_AI_HANDLED]) {
216
- return;
228
+ function createBufferedHttpRequest(envelope) {
229
+ const req = new EventEmitter();
230
+ req.method = envelope && envelope.method ? envelope.method : "GET";
231
+ req.url = envelope && envelope.url ? envelope.url : "/";
232
+ req.headers = envelope && envelope.headers && typeof envelope.headers === "object"
233
+ ? envelope.headers
234
+ : {};
235
+ req.socket = {
236
+ remoteAddress: "127.0.0.1",
237
+ };
238
+ const body = Buffer.from((envelope && envelope.bodyBase64) || "", "base64");
239
+ process.nextTick(() => {
240
+ if (body.length > 0) {
241
+ req.emit("data", body);
217
242
  }
218
- res.statusCode = 404;
219
- res.setHeader("content-type", "text/plain; charset=utf-8");
220
- res.end("not found");
243
+ req.emit("end");
221
244
  });
245
+ return req;
222
246
  }
223
247
 
224
- function listenOwnedRelayHttpServer(httpServer, host, port) {
225
- if (!httpServer || httpServer.listening) {
226
- return;
227
- }
228
- httpServer.listen(port, host);
229
- }
230
-
231
- function closeOwnedRelayHttpServer(httpServer) {
232
- if (!httpServer || !httpServer.listening) {
233
- return Promise.resolve();
234
- }
235
- return new Promise((resolve) => {
236
- httpServer.close(() => resolve());
237
- });
248
+ function createBufferedHttpResponse(maxResponseBytes) {
249
+ const headers = {};
250
+ const chunks = [];
251
+ let totalBytes = 0;
252
+ const limit = Number.isFinite(maxResponseBytes) && maxResponseBytes > 0
253
+ ? Math.floor(maxResponseBytes)
254
+ : 262_144;
255
+ // EventEmitter shape: handlers like the Even-AI endpoint subscribe to
256
+ // res.once('close', ...) for client-disconnect detection. Worker-mode
257
+ // relays actual client closes through an http.cancel worker message.
258
+ const res = new EventEmitter();
259
+ res.statusCode = 200;
260
+ res.writableEnded = false;
261
+ res.setHeader = function (name, value) {
262
+ if (typeof name === "string" && name) {
263
+ headers[name.toLowerCase()] = value;
264
+ }
265
+ };
266
+ res.getHeader = function (name) {
267
+ return typeof name === "string" ? headers[name.toLowerCase()] : undefined;
268
+ };
269
+ res.write = function (chunk) {
270
+ if (this.writableEnded) return false;
271
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
272
+ totalBytes += buffer.length;
273
+ if (totalBytes > limit) {
274
+ throw new Error("Buffered HTTP response exceeded relay worker limit");
275
+ }
276
+ chunks.push(buffer);
277
+ return true;
278
+ };
279
+ res.end = function (chunk) {
280
+ if (chunk !== undefined && chunk !== null) {
281
+ this.write(chunk);
282
+ }
283
+ this.writableEnded = true;
284
+ };
285
+ res.toResult = function () {
286
+ return {
287
+ statusCode: this.statusCode,
288
+ headers: { ...headers },
289
+ body: Buffer.concat(chunks),
290
+ };
291
+ };
292
+ return res;
238
293
  }
239
294
 
240
295
  // --- Factory ---
@@ -283,9 +338,7 @@ function createRelay(opts) {
283
338
  const activityStatusAdapter = createActivityStatusAdapter(
284
339
  opts.activityStatusAdapter,
285
340
  );
286
- const ownedHttpServer =
287
- !opts.httpServer && opts.evenAiEnabled === true ? createOwnedRelayHttpServer() : null;
288
- const sharedHttpServer = opts.httpServer || ownedHttpServer || null;
341
+ const sharedHttpServer = opts.httpServer || null;
289
342
 
290
343
  // --- Cached state ---
291
344
 
@@ -298,6 +351,8 @@ function createRelay(opts) {
298
351
  let cachedStatus = null;
299
352
  /** Monotonic status snapshot revision used for resume handshake. */
300
353
  let statusRevision = 0;
354
+ /** @type {{sessionKey: string, modelProvider: string|null, model: string|null, thinkingLevel: string, reasoningLevel: string, verboseLevel: string}|null} */
355
+ let currentSessionModelConfigSnapshot = null;
301
356
 
302
357
  /** Relay-local deterministic simulate-stream run sequence counter. */
303
358
  let simulateStreamRunSeq = 0;
@@ -306,18 +361,48 @@ function createRelay(opts) {
306
361
 
307
362
  // --- Structured debug state ---
308
363
 
364
+ const debugCategories = Array.isArray(opts.debugCategories)
365
+ ? opts.debugCategories
366
+ : opts.debugCategories && typeof opts.debugCategories === "object"
367
+ ? Object.entries(opts.debugCategories)
368
+ .filter(([, enabled]) => enabled)
369
+ .map(([category]) => category)
370
+ : opts.debugCategories;
371
+ // Single relay-side clock: the debug store, the emitDebug ts stamp, and the
372
+ // liveui log tee all read from this one source so store records and `[liveui]`
373
+ // log lines share an identical ts (downstream reconcilers dedupe on it).
374
+ const debugNow =
375
+ typeof opts.debugNow === "function" ? opts.debugNow : () => Date.now();
309
376
  const debugStore = createDebugStore({
310
- categories: opts.debugCategories,
377
+ categories: debugCategories,
311
378
  capacity: opts.debugCapacity,
312
379
  payloadMaxBytes: opts.debugPayloadMaxBytes,
313
380
  defaultTtlMs: opts.debugDefaultTtlMs,
314
381
  maxTtlMs: opts.debugMaxTtlMs,
315
382
  dumpDefaultLimit: opts.debugDumpDefaultLimit,
316
383
  dumpMaxLimit: opts.debugDumpMaxLimit,
317
- now: opts.debugNow,
384
+ now: debugNow,
318
385
  noisyPolicies: opts.debugNoisyPolicies,
319
386
  });
320
387
 
388
+ // --- Live-interface trace-log flag (durable across restarts) ---
389
+ // Gates the glasses.lifecycle → gateway-log tee. Read once at construction;
390
+ // toggled live by applyTraceLogSet, which also rewrites this file so the
391
+ // value survives a relay/gateway restart (the store's enable-state does not).
392
+ const liveUiTraceFlagPath =
393
+ typeof opts.stateDir === "string" && opts.stateDir
394
+ ? path.join(opts.stateDir, "liveui-trace.json")
395
+ : null;
396
+ let liveUiTraceLogEnabled = false;
397
+ if (liveUiTraceFlagPath) {
398
+ try {
399
+ liveUiTraceLogEnabled =
400
+ JSON.parse(fs.readFileSync(liveUiTraceFlagPath, "utf8")).enabled === true;
401
+ } catch {
402
+ liveUiTraceLogEnabled = false;
403
+ }
404
+ }
405
+
321
406
  // --- Console log file ---
322
407
 
323
408
  const consoleLogPath =
@@ -371,8 +456,11 @@ function createRelay(opts) {
371
456
  * @param {object} context
372
457
  * @param {() => object} buildData
373
458
  */
374
- function emitDebug(cat, event, severity, context, buildData) {
375
- if (!debugStore.isEnabled(cat)) return;
459
+ function emitDebug(cat, event, severity, context, buildData, options) {
460
+ const force = !!(options && options.force === true);
461
+ if (!force && !debugStore.isEnabled(cat) && !(liveUiTraceLogEnabled && (cat === "glasses.lifecycle" || cat === "openclaw.message"))) {
462
+ return;
463
+ }
376
464
 
377
465
  let data = {};
378
466
  if (typeof buildData === "function") {
@@ -383,18 +471,55 @@ function createRelay(opts) {
383
471
  }
384
472
  }
385
473
 
386
- const payload = {
387
- cat,
388
- event,
389
- severity,
390
- data,
391
- };
474
+ const ts = debugNow();
475
+ const payload = { ts, cat, event, severity, data };
392
476
 
393
477
  if (context && context.sessionKey) payload.sessionKey = context.sessionKey;
394
478
  if (context && context.runId) payload.runId = context.runId;
395
479
  if (context && context.screen) payload.screen = context.screen;
396
480
 
397
- debugStore.emit(payload);
481
+ debugStore.emit(payload, { force });
482
+
483
+ // Durable openclaw-side trace tee (gated by the persistent flag, NOT the
484
+ // store category enable). Must never throw into the emit path.
485
+ if (liveUiTraceLogEnabled && (cat === "glasses.lifecycle" || cat === "openclaw.message")) {
486
+ try {
487
+ const surfaceId =
488
+ data && typeof data.surfaceId === "string" ? data.surfaceId : null;
489
+ const sessionKey =
490
+ payload.sessionKey ||
491
+ (data && typeof data.sessionKey === "string" ? data.sessionKey : null) ||
492
+ null;
493
+ const side =
494
+ cat === "openclaw.message"
495
+ ? (event === "user_message" ? "user" : "agent")
496
+ : "openclaw";
497
+ logger.info(
498
+ "[liveui] " +
499
+ JSON.stringify({
500
+ trace: "liveui",
501
+ side,
502
+ ts,
503
+ cat,
504
+ event,
505
+ severity,
506
+ surfaceId,
507
+ sessionKey,
508
+ data,
509
+ }),
510
+ );
511
+ } catch {
512
+ // observability must never break the emit path
513
+ }
514
+ }
515
+ }
516
+
517
+ function isForcedReadinessProofEvent(payload) {
518
+ return !!(
519
+ payload &&
520
+ payload.cat === "app.lifecycle" &&
521
+ payload.event === "readiness_probe_received"
522
+ );
398
523
  }
399
524
 
400
525
  function scheduleSimulateStreamTimer(delayMs, callback) {
@@ -430,6 +555,11 @@ function createRelay(opts) {
430
555
  )
431
556
  ? Math.max(30, Math.floor(opts.sonioxTemporaryKeyExpiresInSeconds))
432
557
  : DEFAULT_SONIOX_TEMP_KEY_EXPIRES_IN_SECONDS;
558
+ const sonioxTemporaryKeyMintTimeoutMs = Number.isFinite(
559
+ opts.sonioxTemporaryKeyMintTimeoutMs,
560
+ )
561
+ ? Math.max(1, Math.floor(opts.sonioxTemporaryKeyMintTimeoutMs))
562
+ : DEFAULT_SONIOX_TEMP_KEY_MINT_TIMEOUT_MS;
433
563
  /** @type {Array<{id: string, name: string, supportsMaxEndpointDelay: boolean}>|null} */
434
564
  let cachedSonioxModels = null;
435
565
  let cachedSonioxModelsFetchedAt = 0;
@@ -658,18 +788,29 @@ function createRelay(opts) {
658
788
  throw new Error("fetch is not available for Soniox temporary-key minting");
659
789
  }
660
790
 
661
- const response = await fetchImpl(SONIOX_TEMP_KEY_URL, {
662
- method: "POST",
663
- headers: {
664
- Authorization: `Bearer ${configuredSonioxApiKey}`,
665
- "Content-Type": "application/json",
666
- },
667
- body: JSON.stringify({
668
- usage_type: "transcribe_websocket",
669
- expires_in_seconds: sonioxTemporaryKeyExpiresInSeconds,
670
- client_reference_id: voiceSessionId,
671
- }),
672
- });
791
+ const mintAbortController = new AbortController();
792
+ const mintTimeoutTimer = setTimeout(
793
+ () => mintAbortController.abort(),
794
+ sonioxTemporaryKeyMintTimeoutMs,
795
+ );
796
+ let response;
797
+ try {
798
+ response = await fetchImpl(SONIOX_TEMP_KEY_URL, {
799
+ method: "POST",
800
+ headers: {
801
+ Authorization: `Bearer ${configuredSonioxApiKey}`,
802
+ "Content-Type": "application/json",
803
+ },
804
+ body: JSON.stringify({
805
+ usage_type: "transcribe_websocket",
806
+ expires_in_seconds: sonioxTemporaryKeyExpiresInSeconds,
807
+ client_reference_id: voiceSessionId,
808
+ }),
809
+ signal: mintAbortController.signal,
810
+ });
811
+ } finally {
812
+ clearTimeout(mintTimeoutTimer);
813
+ }
673
814
 
674
815
  const rawText =
675
816
  response && typeof response.text === "function"
@@ -755,11 +896,63 @@ function createRelay(opts) {
755
896
  },
756
897
  });
757
898
 
758
- function currentOcuClawSendOptions() {
899
+ function currentOcuClawSendOptions(perTurnSignals) {
900
+ const signals = perTurnSignals || {};
901
+ const baseReadability = composeReadabilitySystemPrompt(
902
+ ocuClawSettingsStore.getSnapshot().systemPrompt,
903
+ );
904
+ const validState = (raw) =>
905
+ raw === "active" || raw === "recently-disabled" || raw === "inactive"
906
+ ? raw
907
+ : "inactive";
908
+ const reactorState = validState(signals.neuralEmojiReactorState);
909
+ const paceState = validState(signals.neuralPaceModulatorState);
910
+ const reactor = composeNeuralEmojiReactorSystemPrompt({ state: reactorState });
911
+ const pace = composeNeuralPaceModulatorSystemPrompt({ state: paceState });
912
+ // Only include the glasses-UI nudge when a downstream app client is
913
+ // connected. Keeps the prompt clean when the agent has nowhere to render
914
+ // the tool's output, and keeps existing prompt-assembly tests stable
915
+ // (they exercise the prompt without spinning up an app client).
916
+ const hasAppClient =
917
+ server &&
918
+ typeof server.getConnectedAppCount === "function" &&
919
+ server.getConnectedAppCount() > 0;
920
+ const glassesUiNudge = hasAppClient ? composeGlassesUiNudgeSystemPrompt() : "";
921
+ const parts = [];
922
+ if (baseReadability) parts.push(baseReadability);
923
+ if (reactor) parts.push(reactor);
924
+ if (pace) parts.push(pace);
925
+ if (glassesUiNudge) parts.push(glassesUiNudge);
759
926
  return {
760
- extraSystemPrompt: composeReadabilitySystemPrompt(
761
- ocuClawSettingsStore.getSnapshot().systemPrompt,
762
- ),
927
+ extraSystemPrompt: parts.join("\n\n"),
928
+ };
929
+ }
930
+
931
+ function buildOcuClawSendDiagnostic(params = {}) {
932
+ const attachment = params.attachment || null;
933
+ const messageId =
934
+ typeof params.id === "string" && params.id.trim()
935
+ ? params.id.trim()
936
+ : null;
937
+ const sessionKey =
938
+ typeof params.sessionKey === "string" && params.sessionKey.trim()
939
+ ? params.sessionKey.trim()
940
+ : sessionService.ensureSessionKey();
941
+ const source =
942
+ typeof params.source === "string" && params.source.trim()
943
+ ? params.source.trim()
944
+ : "relay_send";
945
+
946
+ return {
947
+ messageId,
948
+ sessionKey,
949
+ source,
950
+ textChars: typeof params.text === "string" ? params.text.length : 0,
951
+ hasAttachment: !!attachment,
952
+ attachmentBytes:
953
+ attachment && Number.isFinite(attachment.sizeBytes)
954
+ ? Math.floor(attachment.sizeBytes)
955
+ : null,
763
956
  };
764
957
  }
765
958
 
@@ -806,6 +999,9 @@ function createRelay(opts) {
806
999
  ) {
807
1000
  patch.thinkingLevel = settings.defaultThinking.trim().toLowerCase();
808
1001
  }
1002
+ if (settings && settings.defaultFastMode === true) {
1003
+ patch.fastMode = true;
1004
+ }
809
1005
  return Object.keys(patch).length > 0 ? patch : null;
810
1006
  }
811
1007
 
@@ -879,10 +1075,108 @@ function createRelay(opts) {
879
1075
  getAgentName() {
880
1076
  return upstreamRuntime ? upstreamRuntime.getAgentName() : null;
881
1077
  },
1078
+ isPinnedFirstUserMessageKey(sessionKey) {
1079
+ const normalizedSessionKey = normalizeEvenAiSessionKeyForLookup(sessionKey);
1080
+ if (!normalizedSessionKey) {
1081
+ return false;
1082
+ }
1083
+ const trackedThrowawayKeys =
1084
+ typeof evenAiSettingsStore.getTrackedThrowawayKeys === "function"
1085
+ ? evenAiSettingsStore.getTrackedThrowawayKeys()
1086
+ : [];
1087
+ return dedupeNormalizedSessionKeys(trackedThrowawayKeys).some(
1088
+ (trackedKey) =>
1089
+ trackedKey.toLowerCase() === normalizedSessionKey.toLowerCase(),
1090
+ );
1091
+ },
882
1092
  onSessionStateReset: resetActivityStatusAdapter,
883
1093
  onPagesChanged: cachePages,
884
1094
  onStatusChanged: broadcastStatus,
1095
+ onSessionModelConfig(config) {
1096
+ applyCurrentSessionModelConfigSnapshot(config);
1097
+ },
1098
+ broadcastSessions: () => broadcastSessions(),
1099
+ broadcastEvenAiSessions: () => broadcastEvenAiSessions(),
1100
+ });
1101
+
1102
+ const relayHealth = createRelayHealthMonitor({
1103
+ emitDebug(event, severity, data) {
1104
+ emitDebug(
1105
+ "relay.health",
1106
+ event,
1107
+ severity,
1108
+ { sessionKey: sessionService.peekSessionKey() || undefined },
1109
+ () => data,
1110
+ { force: event === "relay_queue_depth" },
1111
+ );
1112
+ },
885
1113
  });
1114
+ relayHealth.start();
1115
+
1116
+ const relayOperationRegistry = createRelayOperationRegistry({
1117
+ emitDebug(event, severity, data, context = {}) {
1118
+ emitDebug(
1119
+ "relay.operation",
1120
+ event,
1121
+ severity,
1122
+ {
1123
+ sessionKey: context.sessionKey || sessionService.peekSessionKey() || undefined,
1124
+ runId: context.runId || undefined,
1125
+ },
1126
+ () => data,
1127
+ );
1128
+ },
1129
+ });
1130
+
1131
+ function isActiveSessionModelConfig(config) {
1132
+ return !!(
1133
+ config &&
1134
+ typeof config.sessionKey === "string" &&
1135
+ (
1136
+ typeof sessionService.isCurrentSession === "function"
1137
+ ? sessionService.isCurrentSession(config.sessionKey)
1138
+ : config.sessionKey === sessionService.ensureSessionKey()
1139
+ )
1140
+ );
1141
+ }
1142
+
1143
+ function applyCurrentSessionModelConfigSnapshot(config) {
1144
+ if (!isActiveSessionModelConfig(config)) {
1145
+ return false;
1146
+ }
1147
+ currentSessionModelConfigSnapshot = config;
1148
+ if (
1149
+ upstreamRuntime &&
1150
+ typeof upstreamRuntime.handleCurrentSessionModelConfigChanged === "function"
1151
+ ) {
1152
+ upstreamRuntime.handleCurrentSessionModelConfigChanged().catch((err) => {
1153
+ logger.warn(`[relay] Provider usage rebroadcast failed after session config update: ${err.message}`);
1154
+ });
1155
+ }
1156
+ return true;
1157
+ }
1158
+
1159
+ function clearCurrentSessionModelConfigSnapshot(trigger) {
1160
+ currentSessionModelConfigSnapshot = null;
1161
+ if (
1162
+ upstreamRuntime &&
1163
+ typeof upstreamRuntime.handleCurrentSessionModelConfigCleared === "function"
1164
+ ) {
1165
+ upstreamRuntime.handleCurrentSessionModelConfigCleared().catch((err) => {
1166
+ logger.warn(`[relay] Provider usage clear broadcast failed after ${trigger}: ${err.message}`);
1167
+ });
1168
+ }
1169
+ }
1170
+
1171
+ // TTL fallback for set_session_title activity label. The tool itself
1172
+ // completes in <50ms but its label can linger if no follow-up activity
1173
+ // arrives (e.g. agent streams a response directly after, with no
1174
+ // intervening activity event). After 1s, synthesize a thinking-status
1175
+ // activity with no tool/label so the renderer falls back to the bare
1176
+ // animated spinner. Any real activity arriving in the meantime cancels
1177
+ // the timer.
1178
+ const SESSION_TITLE_STATUS_FALLBACK_MS = 1500;
1179
+ let sessionTitleStatusFallbackTimer = null;
886
1180
 
887
1181
  function broadcastActivity(rawActivity) {
888
1182
  const activity = activityStatusAdapter.augmentActivity(rawActivity || {});
@@ -905,6 +1199,8 @@ function createRelay(opts) {
905
1199
  intent: (activity && activity.intent) || null,
906
1200
  thinkingSummarySource: (activity && activity.thinkingSummarySource) || null,
907
1201
  category: (activity && activity.category) || null,
1202
+ isError: typeof activity.isError === "boolean" ? activity.isError : null,
1203
+ code: (activity && activity.code) || null,
908
1204
  activityId: (activity && activity.activityId) || null,
909
1205
  seq: Number.isFinite(activity && activity.seq) ? activity.seq : null,
910
1206
  origin,
@@ -913,9 +1209,194 @@ function createRelay(opts) {
913
1209
  );
914
1210
 
915
1211
  server.broadcast(handler.formatActivity(activity));
1212
+
1213
+ if (sessionTitleStatusFallbackTimer) {
1214
+ clearTimeout(sessionTitleStatusFallbackTimer);
1215
+ sessionTitleStatusFallbackTimer = null;
1216
+ }
1217
+ if (
1218
+ activity &&
1219
+ activity.tool === "set_session_title" &&
1220
+ phase !== "end" &&
1221
+ origin !== "synthetic_session_title_fallback"
1222
+ ) {
1223
+ const fallbackSessionKey = activity.sessionKey || null;
1224
+ const fallbackRunId = runId;
1225
+ sessionTitleStatusFallbackTimer = setTimeout(() => {
1226
+ sessionTitleStatusFallbackTimer = null;
1227
+ broadcastActivity({
1228
+ state: "thinking",
1229
+ sessionKey: fallbackSessionKey,
1230
+ runId: fallbackRunId,
1231
+ origin: "synthetic_session_title_fallback",
1232
+ phase: "update",
1233
+ });
1234
+ }, SESSION_TITLE_STATUS_FALLBACK_MS);
1235
+ }
1236
+
916
1237
  return activity;
917
1238
  }
918
1239
 
1240
+ function broadcastProviderUsageSnapshot(snapshot) {
1241
+ if (!server || !handler || typeof handler.formatProviderUsageSnapshot !== "function") {
1242
+ return snapshot;
1243
+ }
1244
+ server.broadcast(handler.formatProviderUsageSnapshot(snapshot || {}));
1245
+ return snapshot;
1246
+ }
1247
+
1248
+ const appClientDisconnectHandlers = new Set();
1249
+ function onAppClientDisconnect(handler) {
1250
+ if (typeof handler !== "function") return () => {};
1251
+ appClientDisconnectHandlers.add(handler);
1252
+ return () => appClientDisconnectHandlers.delete(handler);
1253
+ }
1254
+ function dispatchAppClientDisconnect(sessionKey) {
1255
+ for (const handler of appClientDisconnectHandlers) {
1256
+ try { handler({ sessionKey }); } catch (err) {
1257
+ logger.warn(`[relay] app_client_disconnect handler threw: ${err && err.message ? err.message : err}`);
1258
+ }
1259
+ }
1260
+ }
1261
+
1262
+ const glassesUiResultHandlers = new Set();
1263
+
1264
+ function sendGlassesUiRender(params) {
1265
+ if (!server) return;
1266
+ const payload = {
1267
+ type: "glasses_ui_render",
1268
+ sessionKey: params && typeof params.sessionKey === "string" ? params.sessionKey : null,
1269
+ surfaceId: params && typeof params.surfaceId === "string" ? params.surfaceId : "",
1270
+ depth: Number.isFinite(params && params.depth) ? Math.floor(params.depth) : 1,
1271
+ spec: params && params.spec ? params.spec : null,
1272
+ };
1273
+ server.broadcast(JSON.stringify(payload));
1274
+ emitDebug(
1275
+ "glasses.lifecycle",
1276
+ "surface_send",
1277
+ "debug",
1278
+ { sessionKey: payload.sessionKey || undefined },
1279
+ () => ({ surfaceId: payload.surfaceId, mode: "render", depth: payload.depth, ...summarizeGlassesUiContent(payload.spec) }),
1280
+ );
1281
+ }
1282
+
1283
+ function sendGlassesUiSurfaceUpdate(params) {
1284
+ if (!server) return;
1285
+ const patch = params && params.patch ? params.patch : null;
1286
+ if (!patch) return;
1287
+ const cleanPatch = {};
1288
+ if (typeof patch.title === "string") cleanPatch.title = patch.title;
1289
+ if (typeof patch.body === "string") cleanPatch.body = patch.body;
1290
+ if (Array.isArray(patch.items)) {
1291
+ // Items may be plain-string labels (list_surface / label-only) OR
1292
+ // {label, body} objects (list_with_details detail-body ticks). Keep both
1293
+ // shapes; drop anything malformed (no string, no string label).
1294
+ cleanPatch.items = patch.items
1295
+ .map((i) => {
1296
+ if (typeof i === "string") return i;
1297
+ if (i && typeof i === "object" && typeof i.label === "string") {
1298
+ const o = { label: i.label };
1299
+ if (typeof i.body === "string") o.body = i.body;
1300
+ return o;
1301
+ }
1302
+ return null;
1303
+ })
1304
+ .filter((i) => i !== null);
1305
+ }
1306
+ const payload = {
1307
+ type: "glasses_ui_surface_update",
1308
+ sessionKey: params && typeof params.sessionKey === "string" ? params.sessionKey : null,
1309
+ surfaceId: params && typeof params.surfaceId === "string" ? params.surfaceId : "",
1310
+ patch: cleanPatch,
1311
+ };
1312
+ server.broadcast(JSON.stringify(payload));
1313
+ emitDebug(
1314
+ "glasses.lifecycle",
1315
+ "surface_send",
1316
+ "debug",
1317
+ { sessionKey: payload.sessionKey || undefined },
1318
+ () => ({ surfaceId: payload.surfaceId, mode: "update", ...summarizeGlassesUiContent(cleanPatch) }),
1319
+ );
1320
+ }
1321
+
1322
+ function onGlassesUiResult(handler) {
1323
+ if (typeof handler !== "function") return () => {};
1324
+ glassesUiResultHandlers.add(handler);
1325
+ return () => glassesUiResultHandlers.delete(handler);
1326
+ }
1327
+
1328
+ function dispatchGlassesUiResult(frame) {
1329
+ if (!frame || typeof frame !== "object") return;
1330
+ for (const handler of glassesUiResultHandlers) {
1331
+ try {
1332
+ handler({
1333
+ surfaceId: typeof frame.surfaceId === "string" ? frame.surfaceId : "",
1334
+ outcome: frame.outcome,
1335
+ });
1336
+ } catch (err) {
1337
+ logger.warn(`[relay] glasses_ui_result handler threw: ${err.message}`);
1338
+ }
1339
+ }
1340
+ }
1341
+
1342
+ const glassesUiNavEventHandlers = new Set();
1343
+
1344
+ function onGlassesUiNavEvent(handler) {
1345
+ if (typeof handler !== "function") return () => {};
1346
+ glassesUiNavEventHandlers.add(handler);
1347
+ return () => glassesUiNavEventHandlers.delete(handler);
1348
+ }
1349
+
1350
+ function dispatchGlassesUiNavEvent(frame) {
1351
+ if (!frame || typeof frame !== "object") return;
1352
+ for (const handler of glassesUiNavEventHandlers) {
1353
+ try {
1354
+ handler({
1355
+ surfaceId: typeof frame.surfaceId === "string" ? frame.surfaceId : "",
1356
+ depth: Number.isFinite(frame.depth) ? Math.max(1, Math.floor(frame.depth)) : 1,
1357
+ });
1358
+ } catch (err) {
1359
+ logger.warn(`[relay] glasses_ui_nav_event handler threw: ${err.message}`);
1360
+ }
1361
+ }
1362
+ }
1363
+
1364
+ const deviceInfoResponseHandlers = new Set();
1365
+
1366
+ function sendDeviceInfoRequest(params) {
1367
+ if (!server) return;
1368
+ const payload = {
1369
+ type: "device_info_request",
1370
+ sessionKey: params && typeof params.sessionKey === "string" ? params.sessionKey : null,
1371
+ requestId: params && typeof params.requestId === "string" ? params.requestId : "",
1372
+ };
1373
+ server.broadcast(JSON.stringify(payload));
1374
+ }
1375
+
1376
+ function onDeviceInfoResponse(handler) {
1377
+ if (typeof handler !== "function") return () => {};
1378
+ deviceInfoResponseHandlers.add(handler);
1379
+ return () => deviceInfoResponseHandlers.delete(handler);
1380
+ }
1381
+
1382
+ function dispatchDeviceInfoResponse(frame) {
1383
+ if (!frame || typeof frame !== "object") return;
1384
+ for (const handler of deviceInfoResponseHandlers) {
1385
+ try {
1386
+ handler({
1387
+ requestId: typeof frame.requestId === "string" ? frame.requestId : "",
1388
+ ok: frame.ok === true,
1389
+ code: typeof frame.code === "string" ? frame.code : undefined,
1390
+ data: frame.data && typeof frame.data === "object" ? frame.data : undefined,
1391
+ });
1392
+ } catch (err) {
1393
+ logger.warn(
1394
+ `[relay] device_info_response handler threw: ${err && err.message ? err.message : err}`,
1395
+ );
1396
+ }
1397
+ }
1398
+ }
1399
+
919
1400
  function normalizeAttachmentErrorCode(err) {
920
1401
  if (!err) return "attachment_upstream_rejected";
921
1402
  const code = typeof err.code === "string" ? err.code.trim() : "";
@@ -948,10 +1429,18 @@ function createRelay(opts) {
948
1429
  const text = params.text;
949
1430
  const sessionKey = params.sessionKey;
950
1431
  const attachment = params.attachment || null;
1432
+ const clientDisplaySignals = params.clientDisplaySignals || null;
951
1433
  const resolvedSessionKey = sessionKey || sessionService.ensureSessionKey();
952
1434
  sessionService.recordFirstSentUserMessage(resolvedSessionKey, text);
1435
+ if (clientDisplaySignals && resolvedSessionKey) {
1436
+ sessionService.recordNeuralSessionNamesEnabled(
1437
+ resolvedSessionKey,
1438
+ clientDisplaySignals.neuralSessionNamesEnabled !== false,
1439
+ );
1440
+ }
953
1441
  const hasAttachment = !!attachment;
954
1442
  const sendStartedAt = Date.now();
1443
+ relayOperationRegistry.markStarted(id);
955
1444
  sessionService.invalidateSessionsCache();
956
1445
  emitDebug(
957
1446
  "relay.protocol",
@@ -976,13 +1465,24 @@ function createRelay(opts) {
976
1465
  text,
977
1466
  resolvedSessionKey,
978
1467
  attachment,
979
- currentOcuClawSendOptions(),
1468
+ {
1469
+ ...currentOcuClawSendOptions(clientDisplaySignals),
1470
+ diagnostic: buildOcuClawSendDiagnostic({
1471
+ ...params,
1472
+ sessionKey: resolvedSessionKey,
1473
+ }),
1474
+ },
980
1475
  );
981
1476
  const upstreamDispatchedAt = Date.now();
982
1477
 
983
- conversationState.addMessage(
984
- "user",
985
- buildLocalUserMessageContent(text, attachment),
1478
+ const userContent = buildLocalUserMessageContent(text, attachment);
1479
+ conversationState.addMessage("user", userContent);
1480
+ emitDebug(
1481
+ "openclaw.message",
1482
+ "user_message",
1483
+ "info",
1484
+ { sessionKey: resolvedSessionKey },
1485
+ () => ({ text: typeof text === "string" ? text : "" }),
986
1486
  );
987
1487
  broadcastPages();
988
1488
  const localPublishDoneAt = Date.now();
@@ -1005,6 +1505,10 @@ function createRelay(opts) {
1005
1505
  (result) => {
1006
1506
  const ackAt = Date.now();
1007
1507
  const runId = result && result.runId ? result.runId : null;
1508
+ relayOperationRegistry.markUpstreamAck(id, {
1509
+ runId,
1510
+ status: result && result.status ? result.status : null,
1511
+ });
1008
1512
  if (runId && upstreamRuntime) {
1009
1513
  upstreamRuntime.trackAcceptedRun({
1010
1514
  runId,
@@ -1030,8 +1534,16 @@ function createRelay(opts) {
1030
1534
  return result;
1031
1535
  },
1032
1536
  (err) => {
1033
- if (attachment && !err.errorCode) {
1034
- err.errorCode = normalizeAttachmentErrorCode(err);
1537
+ const mirroredErrorCode =
1538
+ err && typeof err.errorCode === "string" && err.errorCode.trim()
1539
+ ? err.errorCode.trim()
1540
+ : err && typeof err.code === "string" && err.code.trim()
1541
+ ? err.code.trim()
1542
+ : attachment
1543
+ ? normalizeAttachmentErrorCode(err)
1544
+ : null;
1545
+ if (mirroredErrorCode && err && typeof err === "object") {
1546
+ err.errorCode = mirroredErrorCode;
1035
1547
  }
1036
1548
  emitDebug(
1037
1549
  "relay.protocol",
@@ -1042,7 +1554,8 @@ function createRelay(opts) {
1042
1554
  messageId: id,
1043
1555
  elapsedMs: Date.now() - sendStartedAt,
1044
1556
  hasAttachment,
1045
- errorCode: err.errorCode || null,
1557
+ errorCode:
1558
+ err && typeof err.errorCode === "string" ? err.errorCode : null,
1046
1559
  message: err && err.message ? err.message : String(err),
1047
1560
  }),
1048
1561
  );
@@ -1074,13 +1587,39 @@ function createRelay(opts) {
1074
1587
  };
1075
1588
  }
1076
1589
 
1590
+ function emitListenInterceptBroadcast(params = {}) {
1591
+ if (!server || !handler) {
1592
+ return;
1593
+ }
1594
+ const sessionKey = params && typeof params.sessionKey === "string" ? params.sessionKey : null;
1595
+ server.broadcast(handler.formatEvenAiListenIntercepted(sessionKey));
1596
+ }
1597
+
1077
1598
  // --- Downstream handler ---
1078
1599
 
1079
- /** @type {ReturnType<typeof createDownstreamServer>|null} */
1600
+ /** @type {ReturnType<typeof createRelayWorkerSupervisor>|null} */
1080
1601
  let server = null;
1081
1602
  let evenAiEndpoint = null;
1082
1603
  let evenAiRouter = null;
1083
1604
  let evenAiRunWaiter = null;
1605
+ const pendingBufferedEvenAiResponses = new Map();
1606
+ let relayApi = null;
1607
+
1608
+ function applyTraceLogSet(clientId, request) {
1609
+ const enabled = !!(request && request.enabled === true);
1610
+ liveUiTraceLogEnabled = enabled;
1611
+ let persisted = false;
1612
+ if (liveUiTraceFlagPath) {
1613
+ try {
1614
+ fs.writeFileSync(liveUiTraceFlagPath, JSON.stringify({ enabled }) + "\n");
1615
+ persisted = true;
1616
+ } catch (err) {
1617
+ logger.warn(`[relay] liveui trace-log flag persist failed: ${err && err.message ? err.message : err}`);
1618
+ }
1619
+ }
1620
+ emitDebug("relay.protocol", "trace_log_set", "info", { sessionKey: sessionService.ensureSessionKey() }, () => ({ clientId, enabled, persisted }));
1621
+ return { ok: true, enabled, persisted, persistedPath: liveUiTraceFlagPath };
1622
+ }
1084
1623
 
1085
1624
  const handler = createDownstreamHandler({
1086
1625
  logger,
@@ -1099,14 +1638,99 @@ function createRelay(opts) {
1099
1638
  * @param {object|null} attachment - Optional image attachment payload
1100
1639
  * @returns {Promise}
1101
1640
  */
1102
- onSend(id, text, sessionKey, attachment) {
1641
+ onSend(id, text, sessionKey, attachment, clientDisplaySignals) {
1103
1642
  return dispatchOcuClawUserSend({
1104
1643
  id,
1105
1644
  text,
1106
1645
  sessionKey,
1107
1646
  attachment,
1647
+ clientDisplaySignals: clientDisplaySignals || null,
1648
+ source: "phone_ui",
1108
1649
  });
1109
1650
  },
1651
+ onGlassesUiResult(frame) {
1652
+ emitDebug(
1653
+ "glasses.lifecycle",
1654
+ "surface_outcome",
1655
+ "debug",
1656
+ {},
1657
+ () => ({ surfaceId: frame && frame.surfaceId, outcome: frame && frame.outcome }),
1658
+ );
1659
+ dispatchGlassesUiResult(frame);
1660
+ },
1661
+ onGlassesUiNavEvent(frame) {
1662
+ emitDebug(
1663
+ "glasses.lifecycle",
1664
+ "nav_event_recv",
1665
+ "debug",
1666
+ {},
1667
+ () => ({ surfaceId: frame && frame.surfaceId, depth: frame && frame.depth }),
1668
+ );
1669
+ dispatchGlassesUiNavEvent(frame);
1670
+ },
1671
+ onDeviceInfoResponse(frame) {
1672
+ dispatchDeviceInfoResponse(frame);
1673
+ },
1674
+ onGlassesUiRenderInject(params) {
1675
+ sendGlassesUiRender(params);
1676
+ },
1677
+ onSetUserSessionTitle(sessionKey, title) {
1678
+ const result = sessionService.setSessionTitle(sessionKey, title, { userSet: true });
1679
+ if (result && result.ok) {
1680
+ broadcastSessions();
1681
+ }
1682
+ },
1683
+ onSetSessionPinned(sessionKey, pinned, kind) {
1684
+ const result = sessionService.setSessionPinned(kind, sessionKey, pinned);
1685
+ if (result && result.ok) {
1686
+ broadcastSessions();
1687
+ }
1688
+ return result;
1689
+ },
1690
+ onCompactSession({ sessionKey }) {
1691
+ if (!upstreamRuntime || typeof upstreamRuntime.compactActiveSession !== "function") {
1692
+ return Promise.resolve({
1693
+ status: "rejected",
1694
+ error: "upstream runtime not ready",
1695
+ });
1696
+ }
1697
+ return upstreamRuntime.compactActiveSession(sessionKey);
1698
+ },
1699
+ onDeleteSessions(sessionKeys, kind, switchBeforeDelete) {
1700
+ const action = switchBeforeDelete
1701
+ ? sessionService.switchAndDeleteSessions(kind, sessionKeys)
1702
+ : sessionService.deleteSessions(kind, sessionKeys);
1703
+ Promise.resolve(action)
1704
+ .then(() => broadcastSessions())
1705
+ .catch((err) => {
1706
+ logger.error(`[relay] deleteSessions failed: ${err && err.message ? err.message : err}`);
1707
+ });
1708
+ },
1709
+ onSearchTranscripts(clientId, query, kind) {
1710
+ Promise.resolve(sessionService.searchTranscripts(kind, query))
1711
+ .then((result) => {
1712
+ if (!server) return;
1713
+ const payload = {
1714
+ type: "ocuclaw.session.transcripts.search.result",
1715
+ query,
1716
+ kind,
1717
+ snippets: result.snippets,
1718
+ truncated: result.truncated,
1719
+ };
1720
+ server.unicast(clientId, JSON.stringify(payload));
1721
+ })
1722
+ .catch((err) => {
1723
+ logger.error(`[relay] searchTranscripts failed: ${err && err.message ? err.message : err}`);
1724
+ if (server) {
1725
+ const payload = {
1726
+ type: "ocuclaw.session.transcripts.search.result",
1727
+ query, kind, snippets: [], truncated: false,
1728
+ };
1729
+ server.unicast(clientId, JSON.stringify(payload));
1730
+ }
1731
+ });
1732
+ },
1733
+ operationRegistry: relayOperationRegistry,
1110
1734
 
1111
1735
  /**
1112
1736
  * Inject a fake assistant message into conversation state.
@@ -1262,6 +1886,9 @@ function createRelay(opts) {
1262
1886
  { sessionKey: sessionService.ensureSessionKey() },
1263
1887
  () => ({}),
1264
1888
  );
1889
+ if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
1890
+ upstreamRuntime.clearTyping("new_chat");
1891
+ }
1265
1892
  sessionService.invalidateSessionsCache();
1266
1893
  resetActivityStatusAdapter();
1267
1894
  conversationState.clear();
@@ -1284,6 +1911,10 @@ function createRelay(opts) {
1284
1911
 
1285
1912
  onSwitchSession(sessionKey) {
1286
1913
  return sessionService.switchToSession(sessionKey).then((pages) => {
1914
+ clearCurrentSessionModelConfigSnapshot("switch_session");
1915
+ if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
1916
+ upstreamRuntime.clearTyping("switch_session");
1917
+ }
1287
1918
  if (upstreamRuntime && typeof upstreamRuntime.handleSessionChanged === "function") {
1288
1919
  upstreamRuntime.handleSessionChanged("switch_session");
1289
1920
  }
@@ -1293,6 +1924,10 @@ function createRelay(opts) {
1293
1924
 
1294
1925
  async onNewSession() {
1295
1926
  const result = await sessionService.newSession();
1927
+ clearCurrentSessionModelConfigSnapshot("new_session");
1928
+ if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
1929
+ upstreamRuntime.clearTyping("new_session");
1930
+ }
1296
1931
  if (upstreamRuntime && typeof upstreamRuntime.handleSessionChanged === "function") {
1297
1932
  upstreamRuntime.handleSessionChanged("new_session");
1298
1933
  }
@@ -1319,12 +1954,26 @@ function createRelay(opts) {
1319
1954
  : Promise.resolve({ skills: [], fetchedAtMs: Date.now(), stale: true });
1320
1955
  },
1321
1956
 
1957
+ onGetProviderUsageSnapshot() {
1958
+ return upstreamRuntime
1959
+ ? upstreamRuntime.getProviderUsageSnapshot()
1960
+ : Promise.resolve({
1961
+ sessionKey: null,
1962
+ provider: null,
1963
+ displayName: null,
1964
+ limitingWindowKey: null,
1965
+ windows: [],
1966
+ fetchedAtMs: Date.now(),
1967
+ stale: true,
1968
+ });
1969
+ },
1970
+
1322
1971
  onGetSonioxModels() {
1323
1972
  return getSonioxModelsSnapshot();
1324
1973
  },
1325
1974
 
1326
1975
  onGetStatus() {
1327
- return buildStatusObject();
1976
+ return buildStatusObject({ includeDownstreamReadiness: true });
1328
1977
  },
1329
1978
 
1330
1979
  onGetSessionModelConfig() {
@@ -1333,7 +1982,13 @@ function createRelay(opts) {
1333
1982
 
1334
1983
  async onSetSessionModelConfig(patch) {
1335
1984
  const result = await sessionService.setCurrentSessionModelConfig(patch || {});
1336
- if (result && result.status === "accepted" && result.config) {
1985
+ if (
1986
+ result &&
1987
+ result.status === "accepted" &&
1988
+ result.config &&
1989
+ isActiveSessionModelConfig(result.config)
1990
+ ) {
1991
+ currentSessionModelConfigSnapshot = result.config;
1337
1992
  server.broadcast(handler.formatSessionModelConfig(result.config));
1338
1993
  }
1339
1994
  return result;
@@ -1348,50 +2003,7 @@ function createRelay(opts) {
1348
2003
  },
1349
2004
 
1350
2005
  async onGetEvenAiSessions() {
1351
- const dedicatedKey =
1352
- evenAiRouter && typeof evenAiRouter.getDedicatedSessionKey === "function"
1353
- ? evenAiRouter.getDedicatedSessionKey()
1354
- : opts.evenAiDedicatedSessionKey;
1355
- const dedicatedEvenAiKey = normalizeEvenAiSessionKeyForLookup(dedicatedKey);
1356
- const trackedThrowawayKeys =
1357
- typeof evenAiSettingsStore.getTrackedThrowawayKeys === "function"
1358
- ? evenAiSettingsStore.getTrackedThrowawayKeys()
1359
- : [];
1360
- const normalizedTrackedThrowawayKeys = dedupeNormalizedSessionKeys(
1361
- trackedThrowawayKeys,
1362
- );
1363
- const resolvedSessions = await sessionService.getSessionsByExactKeys([
1364
- ...normalizedTrackedThrowawayKeys,
1365
- ...(dedicatedEvenAiKey ? [dedicatedEvenAiKey] : []),
1366
- ]);
1367
- const normalizedDedicatedKey = dedicatedEvenAiKey.toLowerCase();
1368
- const sessions = [];
1369
- let dedicatedIncluded = false;
1370
- for (const session of resolvedSessions) {
1371
- if (
1372
- !dedicatedIncluded &&
1373
- session &&
1374
- typeof session.key === "string" &&
1375
- session.key.trim().toLowerCase() === normalizedDedicatedKey
1376
- ) {
1377
- sessions.push(session);
1378
- dedicatedIncluded = true;
1379
- continue;
1380
- }
1381
- sessions.push(session);
1382
- }
1383
- if (!dedicatedIncluded && dedicatedEvenAiKey) {
1384
- sessions.unshift({
1385
- key: dedicatedEvenAiKey,
1386
- updatedAt: 0,
1387
- preview: "",
1388
- firstUserMessage: "",
1389
- });
1390
- }
1391
- return {
1392
- sessions,
1393
- dedicatedKey,
1394
- };
2006
+ return buildEvenAiSessionsSnapshot();
1395
2007
  },
1396
2008
 
1397
2009
  async onSetEvenAiSettings(patch) {
@@ -1422,6 +2034,9 @@ function createRelay(opts) {
1422
2034
  sessionService.invalidateSessionsCache();
1423
2035
  resetActivityStatusAdapter();
1424
2036
  conversationState.clear();
2037
+ if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
2038
+ upstreamRuntime.clearTyping("slash_reset");
2039
+ }
1425
2040
  conversationState.setAgentName(
1426
2041
  (upstreamRuntime ? upstreamRuntime.getAgentName() : null) || "Agent",
1427
2042
  );
@@ -1437,7 +2052,7 @@ function createRelay(opts) {
1437
2052
  * @returns {boolean} Whether upstream is connected.
1438
2053
  */
1439
2054
  isUpstreamConnected() {
1440
- return upstreamRuntime ? upstreamRuntime.isConnected() : false;
2055
+ return true;
1441
2056
  },
1442
2057
 
1443
2058
  onConsoleLog(level, message) {
@@ -1459,7 +2074,8 @@ function createRelay(opts) {
1459
2074
  onEventDebug(clientId, payload) {
1460
2075
  if (!payload || typeof payload !== "object") return;
1461
2076
  const cat = payload.cat;
1462
- if (!debugStore.isEnabled(cat)) return;
2077
+ const forceStore = isForcedReadinessProofEvent(payload);
2078
+ if (!forceStore && !debugStore.isEnabled(cat)) return;
1463
2079
  emitDebug(
1464
2080
  cat,
1465
2081
  payload.event,
@@ -1473,6 +2089,7 @@ function createRelay(opts) {
1473
2089
  clientId,
1474
2090
  ...(payload.data || {}),
1475
2091
  }),
2092
+ { force: forceStore },
1476
2093
  );
1477
2094
  },
1478
2095
 
@@ -1507,6 +2124,13 @@ function createRelay(opts) {
1507
2124
  return result;
1508
2125
  },
1509
2126
 
2127
+ onTraceLogSet(clientId, request) {
2128
+ return applyTraceLogSet(clientId, request);
2129
+ },
2130
+ onTraceLogGet() {
2131
+ return { ok: true, enabled: liveUiTraceLogEnabled, persistedPath: liveUiTraceFlagPath };
2132
+ },
2133
+
1510
2134
  onDebugDump(clientId, request) {
1511
2135
  const result = debugStore.dump(request);
1512
2136
  if (!result.ok) {
@@ -1637,14 +2261,265 @@ function createRelay(opts) {
1637
2261
  control,
1638
2262
  };
1639
2263
  },
2264
+
2265
+ onReadinessProbe(clientId, request) {
2266
+ const now = Date.now();
2267
+ const requestId =
2268
+ (typeof request.requestId === "string" && request.requestId.trim()) ||
2269
+ `readiness-${now}-${Math.random().toString(16).slice(2, 8)}`;
2270
+ const sinceMs = Number.isFinite(Number(request && request.sinceMs))
2271
+ ? Math.max(0, Math.floor(Number(request.sinceMs)))
2272
+ : now;
2273
+ const snapshot =
2274
+ server && typeof server.getReadinessSnapshot === "function"
2275
+ ? server.getReadinessSnapshot()
2276
+ : {
2277
+ connectedClientCount: 0,
2278
+ fanoutRecipientCount: 0,
2279
+ clients: [],
2280
+ };
2281
+ const targetClientId =
2282
+ snapshot &&
2283
+ snapshot.connectedClientCount === 1 &&
2284
+ snapshot.fanoutRecipientCount === 1 &&
2285
+ Array.isArray(snapshot.clients) &&
2286
+ snapshot.clients.length === 1 &&
2287
+ typeof snapshot.clients[0].clientId === "string"
2288
+ ? snapshot.clients[0].clientId
2289
+ : null;
2290
+
2291
+ emitDebug(
2292
+ "relay.protocol",
2293
+ "readiness_probe_requested",
2294
+ "info",
2295
+ { sessionKey: sessionService.ensureSessionKey() },
2296
+ () => ({
2297
+ clientId,
2298
+ requestId,
2299
+ sinceMs,
2300
+ requestedSessionKey:
2301
+ typeof request.sessionKey === "string" && request.sessionKey.trim()
2302
+ ? request.sessionKey.trim()
2303
+ : null,
2304
+ connectedClientCount:
2305
+ snapshot && Number.isFinite(snapshot.connectedClientCount)
2306
+ ? snapshot.connectedClientCount
2307
+ : 0,
2308
+ fanoutRecipientCount:
2309
+ snapshot && Number.isFinite(snapshot.fanoutRecipientCount)
2310
+ ? snapshot.fanoutRecipientCount
2311
+ : 0,
2312
+ }),
2313
+ );
2314
+
2315
+ if (
2316
+ !snapshot ||
2317
+ snapshot.connectedClientCount <= 0 ||
2318
+ snapshot.fanoutRecipientCount <= 0
2319
+ ) {
2320
+ return {
2321
+ ok: false,
2322
+ requestId,
2323
+ reasonCode: "no_downstream_client",
2324
+ message: "No downstream app clients connected",
2325
+ };
2326
+ }
2327
+
2328
+ if (
2329
+ snapshot.connectedClientCount > 1 ||
2330
+ snapshot.fanoutRecipientCount > 1 ||
2331
+ !targetClientId
2332
+ ) {
2333
+ return {
2334
+ ok: false,
2335
+ requestId,
2336
+ reasonCode: "multi_recipient_fanout",
2337
+ message: "Multiple downstream app clients connected",
2338
+ };
2339
+ }
2340
+
2341
+ emitDebug(
2342
+ "relay.protocol",
2343
+ "readiness_probe_dispatched",
2344
+ "info",
2345
+ { sessionKey: sessionService.ensureSessionKey() },
2346
+ () => ({
2347
+ clientId,
2348
+ requestId,
2349
+ targetClientId,
2350
+ sinceMs,
2351
+ }),
2352
+ );
2353
+
2354
+ return {
2355
+ ok: true,
2356
+ requestId,
2357
+ targetClientId,
2358
+ probe: {
2359
+ requestId,
2360
+ sinceMs,
2361
+ sessionKey:
2362
+ typeof request.sessionKey === "string" && request.sessionKey.trim()
2363
+ ? request.sessionKey.trim()
2364
+ : null,
2365
+ },
2366
+ };
2367
+ },
2368
+
2369
+ onAutomationState(clientId, request) {
2370
+ // Mirrors onReadinessProbe (above): identify the single connected app
2371
+ // client via the readiness snapshot, then return a dispatch envelope
2372
+ // that downstream-handler.handleAutomationState wraps into
2373
+ // `automationStateRequest`. Without this callback wired, the handler
2374
+ // returns null and the request is silently dropped at the relay —
2375
+ // simctl/debugctl times out with no failure response, no trace event,
2376
+ // no outbox drop. The lack of wiring was found 2026-05-28 while
2377
+ // validating the streaming-thinking-emoji-demotion fix on the sim.
2378
+ const now = Date.now();
2379
+ const requestId =
2380
+ (typeof request.requestId === "string" && request.requestId.trim()) ||
2381
+ `automation-${now}-${Math.random().toString(16).slice(2, 8)}`;
2382
+ const requestedSessionKey =
2383
+ typeof request.sessionKey === "string" && request.sessionKey.trim()
2384
+ ? request.sessionKey.trim()
2385
+ : null;
2386
+ const snapshot =
2387
+ server && typeof server.getReadinessSnapshot === "function"
2388
+ ? server.getReadinessSnapshot()
2389
+ : {
2390
+ connectedClientCount: 0,
2391
+ fanoutRecipientCount: 0,
2392
+ clients: [],
2393
+ };
2394
+ const targetEntry =
2395
+ snapshot &&
2396
+ snapshot.connectedClientCount === 1 &&
2397
+ snapshot.fanoutRecipientCount === 1 &&
2398
+ Array.isArray(snapshot.clients) &&
2399
+ snapshot.clients.length === 1
2400
+ ? snapshot.clients[0]
2401
+ : null;
2402
+ const targetClientId =
2403
+ targetEntry && typeof targetEntry.clientId === "string"
2404
+ ? targetEntry.clientId
2405
+ : null;
2406
+ // A connected app client that has never published a readiness snapshot
2407
+ // cannot answer an automation state request; forwarding anyway would
2408
+ // park the request in pendingAutomationStateRequests with no reply.
2409
+ // Same predicate as the downstream readiness gate; this wired callback
2410
+ // bypasses the normal dispatch path.
2411
+ const readinessPublished =
2412
+ !!(
2413
+ targetEntry &&
2414
+ targetEntry.readinessSnapshot &&
2415
+ Number.isFinite(targetEntry.readinessSnapshot.emittedAtMs)
2416
+ );
2417
+
2418
+ emitDebug(
2419
+ "relay.protocol",
2420
+ "automation_state_requested",
2421
+ "info",
2422
+ { sessionKey: sessionService.ensureSessionKey() },
2423
+ () => ({
2424
+ clientId,
2425
+ requestId,
2426
+ requestedSessionKey,
2427
+ connectedClientCount:
2428
+ snapshot && Number.isFinite(snapshot.connectedClientCount)
2429
+ ? snapshot.connectedClientCount
2430
+ : 0,
2431
+ fanoutRecipientCount:
2432
+ snapshot && Number.isFinite(snapshot.fanoutRecipientCount)
2433
+ ? snapshot.fanoutRecipientCount
2434
+ : 0,
2435
+ }),
2436
+ );
2437
+
2438
+ if (
2439
+ !snapshot ||
2440
+ snapshot.connectedClientCount <= 0 ||
2441
+ snapshot.fanoutRecipientCount <= 0
2442
+ ) {
2443
+ return {
2444
+ ok: false,
2445
+ requestId,
2446
+ reasonCode: "no_downstream_client",
2447
+ message: "No downstream app clients connected",
2448
+ };
2449
+ }
2450
+
2451
+ if (
2452
+ snapshot.connectedClientCount > 1 ||
2453
+ snapshot.fanoutRecipientCount > 1 ||
2454
+ !targetClientId
2455
+ ) {
2456
+ return {
2457
+ ok: false,
2458
+ requestId,
2459
+ reasonCode: "multi_recipient_fanout",
2460
+ message: "Multiple downstream app clients connected",
2461
+ };
2462
+ }
2463
+
2464
+ if (!readinessPublished) {
2465
+ return {
2466
+ ok: false,
2467
+ requestId,
2468
+ reasonCode: "snapshot_unavailable",
2469
+ message: "Automation state snapshot is unavailable",
2470
+ };
2471
+ }
2472
+
2473
+ emitDebug(
2474
+ "relay.protocol",
2475
+ "automation_state_dispatched",
2476
+ "info",
2477
+ { sessionKey: sessionService.ensureSessionKey() },
2478
+ () => ({
2479
+ clientId,
2480
+ requestId,
2481
+ targetClientId,
2482
+ }),
2483
+ );
2484
+
2485
+ return {
2486
+ ok: true,
2487
+ requestId,
2488
+ targetClientId,
2489
+ request: {
2490
+ requestId,
2491
+ sessionKey: requestedSessionKey,
2492
+ },
2493
+ };
2494
+ },
1640
2495
  });
1641
2496
 
1642
- // --- Downstream server ---
2497
+ // --- Worker supervisor ---
1643
2498
 
1644
- server = createDownstreamServer({
2499
+ const pluginUpdateService = createPluginUpdateService({
2500
+ spawn: childProcess.spawn,
2501
+ logger,
2502
+ nowMs: () => Date.now(),
2503
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
2504
+ clearTimeout: (handle) => clearTimeout(handle),
2505
+ });
2506
+
2507
+ server = createRelayWorkerSupervisor({
2508
+ pluginId: "ocuclaw",
2509
+ getPluginVersion: () => pluginUpdateService.getPluginVersion(),
2510
+ getRequiresClientVersion: () => pluginUpdateService.getRequiresClientVersion(),
1645
2511
  logger,
1646
- externalDebugToolsEnabled,
1647
2512
  handler,
2513
+ operationRegistry: relayOperationRegistry,
2514
+ host: opts.host,
2515
+ port: opts.port,
2516
+ token: opts.token,
2517
+ externalDebugToolsEnabled,
2518
+ runPluginUpdate: () => pluginUpdateService.runPluginUpdate(),
2519
+ runGatewayRestart: () => pluginUpdateService.runGatewayRestart(),
2520
+ evenAiRequestTimeoutMs: opts.evenAiRequestTimeoutMs,
2521
+ evenAiMaxBodyBytes: opts.evenAiMaxBodyBytes,
2522
+ evenAiMaxResponseBytes: opts.evenAiMaxResponseBytes,
1648
2523
  getCurrentPages() {
1649
2524
  return cachedPages;
1650
2525
  },
@@ -1660,95 +2535,58 @@ function createRelay(opts) {
1660
2535
  statusRevision: statusRevision || 0,
1661
2536
  };
1662
2537
  },
1663
- onClientConnected(meta) {
1664
- emitDebug(
1665
- "relay.session",
1666
- "downstream_client_connected",
1667
- "info",
1668
- { sessionKey: sessionService.peekSessionKey() || undefined },
1669
- () => ({
1670
- clientId: meta && meta.clientId ? meta.clientId : null,
1671
- connectedCount: meta && Number.isFinite(meta.connectedCount) ? meta.connectedCount : null,
1672
- connectedAtMs: meta && Number.isFinite(meta.connectedAtMs) ? meta.connectedAtMs : null,
1673
- remoteAddress: meta && meta.remoteAddress ? meta.remoteAddress : null,
1674
- userAgentTail: meta && meta.userAgent ? meta.userAgent : null,
1675
- }),
1676
- );
2538
+ getAgentAvatarHash: () =>
2539
+ upstreamRuntime && typeof upstreamRuntime.getAgentAvatarHash === "function"
2540
+ ? upstreamRuntime.getAgentAvatarHash()
2541
+ : null,
2542
+ getAgentAvatarDataUriByHash: (hash) =>
2543
+ upstreamRuntime && typeof upstreamRuntime.getAgentAvatarDataUriByHash === "function"
2544
+ ? upstreamRuntime.getAgentAvatarDataUriByHash(hash)
2545
+ : null,
2546
+ handleBufferedEvenAiHttpRequest(envelope) {
2547
+ return handleBufferedEvenAiHttpRequest(envelope);
1677
2548
  },
1678
- onClientDisconnected(meta) {
1679
- emitDebug(
1680
- "relay.session",
1681
- "downstream_client_disconnected",
1682
- "info",
1683
- { sessionKey: sessionService.peekSessionKey() || undefined },
1684
- () => ({
1685
- clientId: meta && meta.clientId ? meta.clientId : null,
1686
- connectedCount: meta && Number.isFinite(meta.connectedCount) ? meta.connectedCount : null,
1687
- connectedAtMs: meta && Number.isFinite(meta.connectedAtMs) ? meta.connectedAtMs : null,
1688
- lifetimeMs: meta && Number.isFinite(meta.lifetimeMs) ? meta.lifetimeMs : null,
1689
- closeCode: meta && Number.isFinite(meta.closeCode) ? meta.closeCode : null,
1690
- closeReasonTail: meta && meta.closeReason ? meta.closeReason : null,
1691
- role: meta && meta.role ? meta.role : null,
1692
- clientKind: meta && meta.clientKind ? meta.clientKind : null,
1693
- protocolVersion: meta && meta.protocolVersion ? meta.protocolVersion : null,
1694
- protocolReason: meta && meta.protocolReason ? meta.protocolReason : null,
1695
- clientName: meta && meta.clientName ? meta.clientName : null,
1696
- clientVersion: meta && meta.clientVersion ? meta.clientVersion : null,
1697
- firstMessageType: meta && meta.firstMessageType ? meta.firstMessageType : null,
1698
- textMessageCount: meta && Number.isFinite(meta.textMessageCount) ? meta.textMessageCount : null,
1699
- binaryMessageCount: meta && Number.isFinite(meta.binaryMessageCount) ? meta.binaryMessageCount : null,
1700
- remoteControlCount: meta && Number.isFinite(meta.remoteControlCount) ? meta.remoteControlCount : null,
1701
- }),
1702
- );
2549
+ cancelBufferedEvenAiHttpRequest(envelope) {
2550
+ return cancelBufferedEvenAiHttpRequest(envelope);
1703
2551
  },
1704
- onTransportControl(meta) {
1705
- if (!meta || meta.controlType !== "visibility") {
1706
- return;
1707
- }
1708
- emitDebug(
1709
- "relay.session",
1710
- "downstream_transport_visibility",
1711
- "info",
1712
- { sessionKey: meta.sessionKey || sessionService.peekSessionKey() || undefined },
1713
- () => ({
1714
- clientId: meta && meta.clientId ? meta.clientId : null,
1715
- state: meta && meta.state ? meta.state : null,
1716
- connectedCount: meta && Number.isFinite(meta.connectedCount) ? meta.connectedCount : null,
1717
- role: meta && meta.role ? meta.role : null,
1718
- clientKind: meta && meta.clientKind ? meta.clientKind : null,
1719
- clientName: meta && meta.clientName ? meta.clientName : null,
1720
- clientVersion: meta && meta.clientVersion ? meta.clientVersion : null,
1721
- protocolVersion: meta && meta.protocolVersion ? meta.protocolVersion : null,
1722
- }),
1723
- );
2552
+ getActiveSessionKey() {
2553
+ return sessionService.peekSessionKey() || null;
2554
+ },
2555
+ onAppClientDisconnect(sessionKey) {
2556
+ dispatchAppClientDisconnect(sessionKey);
2557
+ },
2558
+ emitDebug(category, event, severity, context, payloadFactory, options) {
2559
+ emitDebug(category, event, severity, context, payloadFactory, options);
1724
2560
  },
1725
- httpServer: sharedHttpServer,
1726
- port: opts.port,
1727
- host: opts.host,
1728
- token: opts.token,
1729
2561
  });
1730
- if (ownedHttpServer) {
1731
- ownedHttpServer.on("listening", () => {
1732
- server.wss.emit("listening");
1733
- });
1734
- ownedHttpServer.on("error", (err) => {
1735
- server.wss.emit("error", err);
1736
- });
1737
- listenOwnedRelayHttpServer(ownedHttpServer, opts.host, opts.port);
1738
- }
1739
2562
 
1740
2563
  // --- Helpers ---
1741
2564
 
1742
- function buildStatusObject() {
1743
- return {
2565
+ function buildStatusObject(options = {}) {
2566
+ const includeDownstreamReadiness = options.includeDownstreamReadiness === true;
2567
+ const status = {
1744
2568
  openclaw:
1745
2569
  upstreamRuntime && upstreamRuntime.isConnected()
1746
2570
  ? "connected"
1747
2571
  : "disconnected",
1748
2572
  agent: upstreamRuntime ? upstreamRuntime.getAgentName() : null,
2573
+ agentEmoji: upstreamRuntime ? upstreamRuntime.getAgentEmoji() : null,
2574
+ agentAvatarHash: upstreamRuntime ? upstreamRuntime.getAgentAvatarHash() : null,
1749
2575
  session: sessionService.ensureSessionKey(),
1750
2576
  evenAiEnabled: opts.evenAiEnabled === true,
1751
2577
  };
2578
+ if (includeDownstreamReadiness) {
2579
+ status.downstreamReadiness =
2580
+ server && typeof server.getReadinessSnapshot === "function"
2581
+ ? server.getReadinessSnapshot()
2582
+ : {
2583
+ connectedClientCount: 0,
2584
+ fanoutRecipientCount: 0,
2585
+ updatedAtMs: null,
2586
+ clients: [],
2587
+ };
2588
+ }
2589
+ return status;
1752
2590
  }
1753
2591
 
1754
2592
  function cachePages(pages) {
@@ -1782,6 +2620,97 @@ function createRelay(opts) {
1782
2620
  }
1783
2621
  }
1784
2622
 
2623
+ /**
2624
+ * Fetch the latest sessions snapshot and broadcast it. Used after a session
2625
+ * title changes so connected clients refresh the title in the main webui
2626
+ * status row and Session Settings tab without waiting for a manual
2627
+ * session-list open.
2628
+ */
2629
+ function broadcastSessions() {
2630
+ sessionService
2631
+ .getSessions()
2632
+ .then((sessions) => {
2633
+ server.broadcast(handler.formatSessions(sessions));
2634
+ })
2635
+ .catch((err) => {
2636
+ emitDebug(
2637
+ "relay.session",
2638
+ "session_broadcast_failed",
2639
+ "debug",
2640
+ { sessionKey: sessionService.peekSessionKey() || undefined },
2641
+ () => ({ message: err && err.message ? err.message : String(err) }),
2642
+ );
2643
+ });
2644
+ }
2645
+
2646
+ /**
2647
+ * Resolve the current Even AI sessions snapshot for unicast/broadcast.
2648
+ * Mirrors the shape that `formatEvenAiSessions` expects.
2649
+ */
2650
+ async function buildEvenAiSessionsSnapshot() {
2651
+ const dedicatedKey =
2652
+ evenAiRouter && typeof evenAiRouter.getDedicatedSessionKey === "function"
2653
+ ? evenAiRouter.getDedicatedSessionKey()
2654
+ : opts.evenAiDedicatedSessionKey;
2655
+ const dedicatedEvenAiKey = normalizeEvenAiSessionKeyForLookup(dedicatedKey);
2656
+ const trackedThrowawayKeys =
2657
+ typeof evenAiSettingsStore.getTrackedThrowawayKeys === "function"
2658
+ ? evenAiSettingsStore.getTrackedThrowawayKeys()
2659
+ : [];
2660
+ const normalizedTrackedThrowawayKeys = dedupeNormalizedSessionKeys(
2661
+ trackedThrowawayKeys,
2662
+ );
2663
+ const resolvedSessions = await sessionService.getSessionsByExactKeys([
2664
+ ...normalizedTrackedThrowawayKeys,
2665
+ ...(dedicatedEvenAiKey ? [dedicatedEvenAiKey] : []),
2666
+ ]);
2667
+ const normalizedDedicatedKey = dedicatedEvenAiKey.toLowerCase();
2668
+ const sessions = [];
2669
+ let dedicatedIncluded = false;
2670
+ for (const session of resolvedSessions) {
2671
+ if (
2672
+ !dedicatedIncluded &&
2673
+ session &&
2674
+ typeof session.key === "string" &&
2675
+ session.key.trim().toLowerCase() === normalizedDedicatedKey
2676
+ ) {
2677
+ sessions.push(session);
2678
+ dedicatedIncluded = true;
2679
+ continue;
2680
+ }
2681
+ sessions.push(session);
2682
+ }
2683
+ if (!dedicatedIncluded && dedicatedEvenAiKey) {
2684
+ sessions.unshift({
2685
+ key: dedicatedEvenAiKey,
2686
+ updatedAt: 0,
2687
+ preview: "",
2688
+ firstUserMessage: "",
2689
+ });
2690
+ }
2691
+ return { sessions, dedicatedKey };
2692
+ }
2693
+
2694
+ function broadcastEvenAiSessions() {
2695
+ if (!server) return;
2696
+ buildEvenAiSessionsSnapshot()
2697
+ .then((payload) => {
2698
+ server.broadcast(handler.formatEvenAiSessions(payload));
2699
+ })
2700
+ .catch((err) => {
2701
+ emitDebug(
2702
+ "relay.session",
2703
+ "session_broadcast_failed",
2704
+ "debug",
2705
+ { sessionKey: sessionService.peekSessionKey() || undefined },
2706
+ () => ({
2707
+ kind: "evenai",
2708
+ message: err && err.message ? err.message : String(err),
2709
+ }),
2710
+ );
2711
+ });
2712
+ }
2713
+
1785
2714
  /**
1786
2715
  * Build, cache, and broadcast the current status.
1787
2716
  */
@@ -1790,10 +2719,24 @@ function createRelay(opts) {
1790
2719
  if (next !== null) {
1791
2720
  server.broadcast(next);
1792
2721
  }
2722
+ if (server && typeof server.notifyAgentAvatarChanged === "function") {
2723
+ const hash =
2724
+ upstreamRuntime && typeof upstreamRuntime.getAgentAvatarHash === "function"
2725
+ ? upstreamRuntime.getAgentAvatarHash()
2726
+ : null;
2727
+ const dataUri =
2728
+ hash &&
2729
+ upstreamRuntime &&
2730
+ typeof upstreamRuntime.getAgentAvatarDataUriByHash === "function"
2731
+ ? upstreamRuntime.getAgentAvatarDataUriByHash(hash)
2732
+ : null;
2733
+ server.notifyAgentAvatarChanged(hash, dataUri);
2734
+ }
1793
2735
  }
1794
2736
 
1795
2737
  upstreamRuntime = createUpstreamRuntime({
1796
2738
  logger,
2739
+ stateDir: opts.stateDir,
1797
2740
  gatewayBridge,
1798
2741
  conversationState,
1799
2742
  sessionService,
@@ -1802,6 +2745,11 @@ function createRelay(opts) {
1802
2745
  broadcastPages,
1803
2746
  broadcastStatus,
1804
2747
  broadcastActivity,
2748
+ broadcastProviderUsageSnapshot,
2749
+ operationRegistry: relayOperationRegistry,
2750
+ getCurrentSessionModelConfigSnapshot() {
2751
+ return currentSessionModelConfigSnapshot;
2752
+ },
1805
2753
  resetActivityStatusAdapter,
1806
2754
  modelsCacheTtlMs: opts.modelsCacheTtlMs,
1807
2755
  getServer() {
@@ -1810,8 +2758,39 @@ function createRelay(opts) {
1810
2758
  getVoiceRuntime() {
1811
2759
  return null;
1812
2760
  },
2761
+ gatewayUrl: opts.gatewayUrl,
2762
+ gatewayToken: opts.gatewayToken,
2763
+ fetchAgentAvatar: opts.fetchAgentAvatar,
1813
2764
  });
1814
2765
 
2766
+ // Shared routing gate for session-scoped Even AI defaults (thinking seed,
2767
+ // fast-mode patch): never touch active-routed sessions; always seed fresh
2768
+ // background_new sessions; seed persistent background sessions only before
2769
+ // their first turn exists.
2770
+ async function shouldSeedSessionScopedDefaultForRoute(route) {
2771
+ const routingMode =
2772
+ route && typeof route.routingMode === "string"
2773
+ ? route.routingMode.trim().toLowerCase()
2774
+ : "active";
2775
+ const sessionKey =
2776
+ route && typeof route.sessionKey === "string" ? route.sessionKey.trim() : "";
2777
+ if (!sessionKey || routingMode === "active") {
2778
+ return false;
2779
+ }
2780
+ if (routingMode === "background_new") {
2781
+ return true;
2782
+ }
2783
+ if (routingMode !== "background") {
2784
+ return false;
2785
+ }
2786
+ try {
2787
+ const existingSessions = await sessionService.getSessionsByExactKeys([sessionKey]);
2788
+ return existingSessions.length === 0;
2789
+ } catch {
2790
+ return false;
2791
+ }
2792
+ }
2793
+
1815
2794
  if (opts.evenAiEnabled === true) {
1816
2795
  evenAiRouter = createEvenAiRouter({
1817
2796
  sessionService,
@@ -1829,7 +2808,7 @@ function createRelay(opts) {
1829
2808
  logger,
1830
2809
  httpServer: sharedHttpServer,
1831
2810
  enabled: true,
1832
- externallyRouted: opts.evenAiExternalHttpRouting === true,
2811
+ externallyRouted: true,
1833
2812
  token: opts.evenAiToken,
1834
2813
  getSettingsSnapshot() {
1835
2814
  return evenAiSettingsStore.getSnapshot();
@@ -1850,6 +2829,9 @@ function createRelay(opts) {
1850
2829
  emitListenInterceptRecovery(params) {
1851
2830
  return emitListenInterceptRecovery(params);
1852
2831
  },
2832
+ emitListenInterceptBroadcast(params) {
2833
+ return emitListenInterceptBroadcast(params);
2834
+ },
1853
2835
  hasConnectedAppClient() {
1854
2836
  return server ? server.getConnectedAppCount() > 0 : false;
1855
2837
  },
@@ -1869,31 +2851,29 @@ function createRelay(opts) {
1869
2851
  },
1870
2852
  async shouldSeedThinkingForRoute(params) {
1871
2853
  const route = params && params.route ? params.route : params;
1872
- const routingMode =
1873
- route && typeof route.routingMode === "string"
1874
- ? route.routingMode.trim().toLowerCase()
1875
- : "active";
1876
2854
  const thinkingLevel =
1877
2855
  params && typeof params.thinkingLevel === "string"
1878
2856
  ? params.thinkingLevel.trim().toLowerCase()
1879
2857
  : "";
1880
- const sessionKey =
1881
- route && typeof route.sessionKey === "string" ? route.sessionKey.trim() : "";
1882
- if (!thinkingLevel || !sessionKey || routingMode === "active") {
2858
+ if (!thinkingLevel) {
1883
2859
  return false;
1884
2860
  }
1885
- if (routingMode === "background_new") {
1886
- return true;
1887
- }
1888
- if (routingMode !== "background") {
2861
+ return shouldSeedSessionScopedDefaultForRoute(route);
2862
+ },
2863
+ async seedFastModeForRoute(params) {
2864
+ const route = params && params.route ? params.route : params;
2865
+ const settings = evenAiSettingsStore.getSnapshot();
2866
+ if (!settings || settings.defaultFastMode !== true) {
1889
2867
  return false;
1890
2868
  }
1891
- try {
1892
- const existingSessions = await sessionService.getSessionsByExactKeys([sessionKey]);
1893
- return existingSessions.length === 0;
1894
- } catch {
2869
+ if (!(await shouldSeedSessionScopedDefaultForRoute(route))) {
1895
2870
  return false;
1896
2871
  }
2872
+ const result = await sessionService.setSessionModelConfig(
2873
+ route.sessionKey.trim(),
2874
+ { fastMode: true },
2875
+ );
2876
+ return !!(result && result.status === "accepted");
1897
2877
  },
1898
2878
  onSessionActivated(route) {
1899
2879
  if (!route || !route.sessionChanged) {
@@ -1910,15 +2890,70 @@ function createRelay(opts) {
1910
2890
  });
1911
2891
  }
1912
2892
 
2893
+ async function handleBufferedEvenAiHttpRequest(envelope) {
2894
+ if (!evenAiEndpoint || typeof evenAiEndpoint.handleRequest !== "function") {
2895
+ return {
2896
+ statusCode: 404,
2897
+ headers: { "content-type": "text/plain; charset=utf-8" },
2898
+ body: Buffer.from("not found"),
2899
+ };
2900
+ }
2901
+ const req = createBufferedHttpRequest(envelope);
2902
+ const res = createBufferedHttpResponse(opts.evenAiMaxResponseBytes || 262_144);
2903
+ const requestId =
2904
+ envelope && typeof envelope.requestId === "string" ? envelope.requestId : null;
2905
+ if (requestId) {
2906
+ pendingBufferedEvenAiResponses.set(requestId, { req, res });
2907
+ }
2908
+ try {
2909
+ await Promise.resolve(evenAiEndpoint.handleRequest(req, res));
2910
+ if (!res.writableEnded) {
2911
+ res.statusCode = 404;
2912
+ res.setHeader("content-type", "text/plain; charset=utf-8");
2913
+ res.end("not found");
2914
+ }
2915
+ return res.toResult();
2916
+ } finally {
2917
+ if (requestId) {
2918
+ pendingBufferedEvenAiResponses.delete(requestId);
2919
+ }
2920
+ }
2921
+ }
2922
+
2923
+ function cancelBufferedEvenAiHttpRequest(envelope) {
2924
+ const requestId =
2925
+ envelope && typeof envelope.requestId === "string" ? envelope.requestId : null;
2926
+ if (!requestId) {
2927
+ return false;
2928
+ }
2929
+ const pending = pendingBufferedEvenAiResponses.get(requestId);
2930
+ if (!pending) {
2931
+ return false;
2932
+ }
2933
+ pending.res.emit("close");
2934
+ pending.req.emit("close");
2935
+ return true;
2936
+ }
2937
+
1913
2938
  // --- Public API ---
1914
2939
 
1915
- return {
2940
+ relayApi = {
2941
+ /**
2942
+ * Emit a glasses-UI surface-lifecycle event on the permanent
2943
+ * `glasses.lifecycle` debug category (nav reconcile + cron pause/resume/
2944
+ * tick). Recorded only when the category is enabled via debug-set. Wired
2945
+ * through the relay-service facade into the glasses-ui tool handler + cron
2946
+ * engine. See docs/superpowers/findings/2026-05-30-glasses-ui-phase4-hardware.md.
2947
+ */
2948
+ emitGlassesUiLifecycle(event, severity, data) {
2949
+ emitDebug("glasses.lifecycle", event, severity, {}, () => data || {});
2950
+ },
1916
2951
  /**
1917
2952
  * Start the upstream OpenClaw connection.
1918
2953
  * The downstream server is already listening from construction.
1919
- */
2954
+ */
1920
2955
  start() {
1921
- return Promise.resolve(gatewayBridge.start()).then(() => {
2956
+ const startGateway = () => Promise.resolve(gatewayBridge.start()).then(() => {
1922
2957
  prefetchSonioxModels("relay_start").catch((err) => {
1923
2958
  logger.warn(`[relay] Soniox models prefetch failed: ${err.message}`);
1924
2959
  });
@@ -1926,6 +2961,10 @@ function createRelay(opts) {
1926
2961
  return upstreamRuntime.start();
1927
2962
  }
1928
2963
  });
2964
+ if (server && typeof server.start === "function") {
2965
+ return Promise.resolve(server.start()).then(startGateway);
2966
+ }
2967
+ return startGateway();
1929
2968
  },
1930
2969
 
1931
2970
  /**
@@ -1944,10 +2983,12 @@ function createRelay(opts) {
1944
2983
  if (upstreamRuntime) {
1945
2984
  upstreamRuntime.stop();
1946
2985
  }
2986
+ relayHealth.stop();
1947
2987
  gatewayBridge.stop();
1948
- return Promise.resolve(server.close()).then(() =>
1949
- closeOwnedRelayHttpServer(ownedHttpServer),
1950
- );
2988
+ return Promise.all([
2989
+ sessionService.flushFirstSentUserMessageCache(),
2990
+ Promise.resolve(server.close()),
2991
+ ]).then(() => undefined);
1951
2992
  },
1952
2993
 
1953
2994
  handleEvenAiHttpRequest(req, res) {
@@ -1957,11 +2998,34 @@ function createRelay(opts) {
1957
2998
  return Promise.resolve(evenAiEndpoint.handleRequest(req, res));
1958
2999
  },
1959
3000
 
3001
+ handleBufferedEvenAiHttpRequest,
3002
+
1960
3003
  /** The downstream server instance. */
1961
3004
  get server() {
1962
3005
  return server;
1963
3006
  },
1964
3007
 
3008
+ get workerReadyForTest() {
3009
+ return server && server.readyPromise ? server.readyPromise : Promise.resolve();
3010
+ },
3011
+
3012
+ get debugStoreForTest() {
3013
+ return debugStore;
3014
+ },
3015
+
3016
+ get liveUiTraceLogEnabledForTest() {
3017
+ return liveUiTraceLogEnabled;
3018
+ },
3019
+ __onTraceLogSetForTest(clientId, request) {
3020
+ return applyTraceLogSet(clientId, request);
3021
+ },
3022
+
3023
+ get operationRegistryForTest() {
3024
+ return relayOperationRegistry;
3025
+ },
3026
+
3027
+ relayHealth,
3028
+
1965
3029
  get httpServer() {
1966
3030
  return sharedHttpServer;
1967
3031
  },
@@ -1969,7 +3033,90 @@ function createRelay(opts) {
1969
3033
  getEvenAiSettingsSnapshot() {
1970
3034
  return evenAiSettingsStore.getSnapshot();
1971
3035
  },
3036
+
3037
+ getSessionTitle(sessionKey) {
3038
+ return sessionService.getSessionTitle(sessionKey);
3039
+ },
3040
+
3041
+ hasRecordedUserMessage(sessionKey) {
3042
+ return sessionService.hasRecordedFirstUserMessage(sessionKey);
3043
+ },
3044
+
3045
+ isNeuralSessionNamesEnabled(sessionKey) {
3046
+ return sessionService.isNeuralSessionNamesEnabled(sessionKey);
3047
+ },
3048
+
3049
+ isSessionUserLocked(sessionKey) {
3050
+ return sessionService.isSessionUserLocked(sessionKey);
3051
+ },
3052
+
3053
+ peekSessionKey() {
3054
+ return sessionService.peekSessionKey();
3055
+ },
3056
+
3057
+ /**
3058
+ * Test/shutdown hook: resolves once the async first-user-message cache
3059
+ * write has fully drained (no write in flight, no dirty mark pending) so
3060
+ * the on-disk file reflects the latest in-memory map.
3061
+ */
3062
+ flushFirstSentUserMessageCache() {
3063
+ return sessionService.flushFirstSentUserMessageCache();
3064
+ },
3065
+
3066
+ recordNeuralSessionNamesEnabled(sessionKey, enabled) {
3067
+ sessionService.recordNeuralSessionNamesEnabled(sessionKey, enabled);
3068
+ },
3069
+
3070
+ setSessionTitle(sessionKey, title, opts) {
3071
+ const result = sessionService.setSessionTitle(sessionKey, title, opts);
3072
+ if (result && result.ok) {
3073
+ broadcastSessions();
3074
+ }
3075
+ return result;
3076
+ },
3077
+
3078
+ /**
3079
+ * Test-only: direct access to dispatchOcuClawUserSend so integration
3080
+ * tests can drive per-turn signal plumbing without a live downstream
3081
+ * WebSocket connection.
3082
+ */
3083
+ _dispatchOcuClawUserSend(params) {
3084
+ return dispatchOcuClawUserSend(params || {});
3085
+ },
3086
+
3087
+ sendGlassesUiRender(params) {
3088
+ sendGlassesUiRender(params);
3089
+ },
3090
+
3091
+ sendGlassesUiSurfaceUpdate(params) {
3092
+ sendGlassesUiSurfaceUpdate(params);
3093
+ },
3094
+
3095
+ onGlassesUiResult(handler) {
3096
+ return onGlassesUiResult(handler);
3097
+ },
3098
+
3099
+ onGlassesUiNavEvent(handler) {
3100
+ return onGlassesUiNavEvent(handler);
3101
+ },
3102
+
3103
+ sendDeviceInfoRequest(params) {
3104
+ sendDeviceInfoRequest(params);
3105
+ },
3106
+
3107
+ onDeviceInfoResponse(handler) {
3108
+ return onDeviceInfoResponse(handler);
3109
+ },
3110
+
3111
+ hasConnectedAppClient() {
3112
+ return server ? server.getConnectedAppCount() > 0 : false;
3113
+ },
3114
+
3115
+ onAppClientDisconnect(handler) {
3116
+ return onAppClientDisconnect(handler);
3117
+ },
1972
3118
  };
3119
+ return relayApi;
1973
3120
  }
1974
3121
 
1975
3122
  const createRelayCore = createRelay;