ocuclaw 1.2.4 → 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 +18 -5
  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 +38 -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/downstream-server.js +700 -534
  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 +1209 -204
  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 +615 -24
  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 =
@@ -373,7 +458,9 @@ function createRelay(opts) {
373
458
  */
374
459
  function emitDebug(cat, event, severity, context, buildData, options) {
375
460
  const force = !!(options && options.force === true);
376
- if (!force && !debugStore.isEnabled(cat)) return;
461
+ if (!force && !debugStore.isEnabled(cat) && !(liveUiTraceLogEnabled && (cat === "glasses.lifecycle" || cat === "openclaw.message"))) {
462
+ return;
463
+ }
377
464
 
378
465
  let data = {};
379
466
  if (typeof buildData === "function") {
@@ -384,18 +471,47 @@ function createRelay(opts) {
384
471
  }
385
472
  }
386
473
 
387
- const payload = {
388
- cat,
389
- event,
390
- severity,
391
- data,
392
- };
474
+ const ts = debugNow();
475
+ const payload = { ts, cat, event, severity, data };
393
476
 
394
477
  if (context && context.sessionKey) payload.sessionKey = context.sessionKey;
395
478
  if (context && context.runId) payload.runId = context.runId;
396
479
  if (context && context.screen) payload.screen = context.screen;
397
480
 
398
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
+ }
399
515
  }
400
516
 
401
517
  function isForcedReadinessProofEvent(payload) {
@@ -439,6 +555,11 @@ function createRelay(opts) {
439
555
  )
440
556
  ? Math.max(30, Math.floor(opts.sonioxTemporaryKeyExpiresInSeconds))
441
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;
442
563
  /** @type {Array<{id: string, name: string, supportsMaxEndpointDelay: boolean}>|null} */
443
564
  let cachedSonioxModels = null;
444
565
  let cachedSonioxModelsFetchedAt = 0;
@@ -667,18 +788,29 @@ function createRelay(opts) {
667
788
  throw new Error("fetch is not available for Soniox temporary-key minting");
668
789
  }
669
790
 
670
- const response = await fetchImpl(SONIOX_TEMP_KEY_URL, {
671
- method: "POST",
672
- headers: {
673
- Authorization: `Bearer ${configuredSonioxApiKey}`,
674
- "Content-Type": "application/json",
675
- },
676
- body: JSON.stringify({
677
- usage_type: "transcribe_websocket",
678
- expires_in_seconds: sonioxTemporaryKeyExpiresInSeconds,
679
- client_reference_id: voiceSessionId,
680
- }),
681
- });
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
+ }
682
814
 
683
815
  const rawText =
684
816
  response && typeof response.text === "function"
@@ -764,11 +896,63 @@ function createRelay(opts) {
764
896
  },
765
897
  });
766
898
 
767
- 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);
768
926
  return {
769
- extraSystemPrompt: composeReadabilitySystemPrompt(
770
- ocuClawSettingsStore.getSnapshot().systemPrompt,
771
- ),
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,
772
956
  };
773
957
  }
774
958
 
@@ -815,6 +999,9 @@ function createRelay(opts) {
815
999
  ) {
816
1000
  patch.thinkingLevel = settings.defaultThinking.trim().toLowerCase();
817
1001
  }
1002
+ if (settings && settings.defaultFastMode === true) {
1003
+ patch.fastMode = true;
1004
+ }
818
1005
  return Object.keys(patch).length > 0 ? patch : null;
819
1006
  }
820
1007
 
@@ -905,7 +1092,91 @@ function createRelay(opts) {
905
1092
  onSessionStateReset: resetActivityStatusAdapter,
906
1093
  onPagesChanged: cachePages,
907
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
+ },
908
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;
909
1180
 
910
1181
  function broadcastActivity(rawActivity) {
911
1182
  const activity = activityStatusAdapter.augmentActivity(rawActivity || {});
@@ -928,6 +1199,8 @@ function createRelay(opts) {
928
1199
  intent: (activity && activity.intent) || null,
929
1200
  thinkingSummarySource: (activity && activity.thinkingSummarySource) || null,
930
1201
  category: (activity && activity.category) || null,
1202
+ isError: typeof activity.isError === "boolean" ? activity.isError : null,
1203
+ code: (activity && activity.code) || null,
931
1204
  activityId: (activity && activity.activityId) || null,
932
1205
  seq: Number.isFinite(activity && activity.seq) ? activity.seq : null,
933
1206
  origin,
@@ -936,9 +1209,194 @@ function createRelay(opts) {
936
1209
  );
937
1210
 
938
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
+
939
1237
  return activity;
940
1238
  }
941
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
+
942
1400
  function normalizeAttachmentErrorCode(err) {
943
1401
  if (!err) return "attachment_upstream_rejected";
944
1402
  const code = typeof err.code === "string" ? err.code.trim() : "";
@@ -971,10 +1429,18 @@ function createRelay(opts) {
971
1429
  const text = params.text;
972
1430
  const sessionKey = params.sessionKey;
973
1431
  const attachment = params.attachment || null;
1432
+ const clientDisplaySignals = params.clientDisplaySignals || null;
974
1433
  const resolvedSessionKey = sessionKey || sessionService.ensureSessionKey();
975
1434
  sessionService.recordFirstSentUserMessage(resolvedSessionKey, text);
1435
+ if (clientDisplaySignals && resolvedSessionKey) {
1436
+ sessionService.recordNeuralSessionNamesEnabled(
1437
+ resolvedSessionKey,
1438
+ clientDisplaySignals.neuralSessionNamesEnabled !== false,
1439
+ );
1440
+ }
976
1441
  const hasAttachment = !!attachment;
977
1442
  const sendStartedAt = Date.now();
1443
+ relayOperationRegistry.markStarted(id);
978
1444
  sessionService.invalidateSessionsCache();
979
1445
  emitDebug(
980
1446
  "relay.protocol",
@@ -999,13 +1465,24 @@ function createRelay(opts) {
999
1465
  text,
1000
1466
  resolvedSessionKey,
1001
1467
  attachment,
1002
- currentOcuClawSendOptions(),
1468
+ {
1469
+ ...currentOcuClawSendOptions(clientDisplaySignals),
1470
+ diagnostic: buildOcuClawSendDiagnostic({
1471
+ ...params,
1472
+ sessionKey: resolvedSessionKey,
1473
+ }),
1474
+ },
1003
1475
  );
1004
1476
  const upstreamDispatchedAt = Date.now();
1005
1477
 
1006
- conversationState.addMessage(
1007
- "user",
1008
- 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 : "" }),
1009
1486
  );
1010
1487
  broadcastPages();
1011
1488
  const localPublishDoneAt = Date.now();
@@ -1028,6 +1505,10 @@ function createRelay(opts) {
1028
1505
  (result) => {
1029
1506
  const ackAt = Date.now();
1030
1507
  const runId = result && result.runId ? result.runId : null;
1508
+ relayOperationRegistry.markUpstreamAck(id, {
1509
+ runId,
1510
+ status: result && result.status ? result.status : null,
1511
+ });
1031
1512
  if (runId && upstreamRuntime) {
1032
1513
  upstreamRuntime.trackAcceptedRun({
1033
1514
  runId,
@@ -1053,8 +1534,16 @@ function createRelay(opts) {
1053
1534
  return result;
1054
1535
  },
1055
1536
  (err) => {
1056
- if (attachment && !err.errorCode) {
1057
- 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;
1058
1547
  }
1059
1548
  emitDebug(
1060
1549
  "relay.protocol",
@@ -1065,7 +1554,8 @@ function createRelay(opts) {
1065
1554
  messageId: id,
1066
1555
  elapsedMs: Date.now() - sendStartedAt,
1067
1556
  hasAttachment,
1068
- errorCode: err.errorCode || null,
1557
+ errorCode:
1558
+ err && typeof err.errorCode === "string" ? err.errorCode : null,
1069
1559
  message: err && err.message ? err.message : String(err),
1070
1560
  }),
1071
1561
  );
@@ -1097,13 +1587,39 @@ function createRelay(opts) {
1097
1587
  };
1098
1588
  }
1099
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
+
1100
1598
  // --- Downstream handler ---
1101
1599
 
1102
- /** @type {ReturnType<typeof createDownstreamServer>|null} */
1600
+ /** @type {ReturnType<typeof createRelayWorkerSupervisor>|null} */
1103
1601
  let server = null;
1104
1602
  let evenAiEndpoint = null;
1105
1603
  let evenAiRouter = null;
1106
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
+ }
1107
1623
 
1108
1624
  const handler = createDownstreamHandler({
1109
1625
  logger,
@@ -1122,14 +1638,99 @@ function createRelay(opts) {
1122
1638
  * @param {object|null} attachment - Optional image attachment payload
1123
1639
  * @returns {Promise}
1124
1640
  */
1125
- onSend(id, text, sessionKey, attachment) {
1641
+ onSend(id, text, sessionKey, attachment, clientDisplaySignals) {
1126
1642
  return dispatchOcuClawUserSend({
1127
1643
  id,
1128
1644
  text,
1129
1645
  sessionKey,
1130
1646
  attachment,
1647
+ clientDisplaySignals: clientDisplaySignals || null,
1648
+ source: "phone_ui",
1131
1649
  });
1132
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,
1133
1734
 
1134
1735
  /**
1135
1736
  * Inject a fake assistant message into conversation state.
@@ -1285,6 +1886,9 @@ function createRelay(opts) {
1285
1886
  { sessionKey: sessionService.ensureSessionKey() },
1286
1887
  () => ({}),
1287
1888
  );
1889
+ if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
1890
+ upstreamRuntime.clearTyping("new_chat");
1891
+ }
1288
1892
  sessionService.invalidateSessionsCache();
1289
1893
  resetActivityStatusAdapter();
1290
1894
  conversationState.clear();
@@ -1307,6 +1911,10 @@ function createRelay(opts) {
1307
1911
 
1308
1912
  onSwitchSession(sessionKey) {
1309
1913
  return sessionService.switchToSession(sessionKey).then((pages) => {
1914
+ clearCurrentSessionModelConfigSnapshot("switch_session");
1915
+ if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
1916
+ upstreamRuntime.clearTyping("switch_session");
1917
+ }
1310
1918
  if (upstreamRuntime && typeof upstreamRuntime.handleSessionChanged === "function") {
1311
1919
  upstreamRuntime.handleSessionChanged("switch_session");
1312
1920
  }
@@ -1316,6 +1924,10 @@ function createRelay(opts) {
1316
1924
 
1317
1925
  async onNewSession() {
1318
1926
  const result = await sessionService.newSession();
1927
+ clearCurrentSessionModelConfigSnapshot("new_session");
1928
+ if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
1929
+ upstreamRuntime.clearTyping("new_session");
1930
+ }
1319
1931
  if (upstreamRuntime && typeof upstreamRuntime.handleSessionChanged === "function") {
1320
1932
  upstreamRuntime.handleSessionChanged("new_session");
1321
1933
  }
@@ -1342,6 +1954,20 @@ function createRelay(opts) {
1342
1954
  : Promise.resolve({ skills: [], fetchedAtMs: Date.now(), stale: true });
1343
1955
  },
1344
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
+
1345
1971
  onGetSonioxModels() {
1346
1972
  return getSonioxModelsSnapshot();
1347
1973
  },
@@ -1356,7 +1982,13 @@ function createRelay(opts) {
1356
1982
 
1357
1983
  async onSetSessionModelConfig(patch) {
1358
1984
  const result = await sessionService.setCurrentSessionModelConfig(patch || {});
1359
- 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;
1360
1992
  server.broadcast(handler.formatSessionModelConfig(result.config));
1361
1993
  }
1362
1994
  return result;
@@ -1371,50 +2003,7 @@ function createRelay(opts) {
1371
2003
  },
1372
2004
 
1373
2005
  async onGetEvenAiSessions() {
1374
- const dedicatedKey =
1375
- evenAiRouter && typeof evenAiRouter.getDedicatedSessionKey === "function"
1376
- ? evenAiRouter.getDedicatedSessionKey()
1377
- : opts.evenAiDedicatedSessionKey;
1378
- const dedicatedEvenAiKey = normalizeEvenAiSessionKeyForLookup(dedicatedKey);
1379
- const trackedThrowawayKeys =
1380
- typeof evenAiSettingsStore.getTrackedThrowawayKeys === "function"
1381
- ? evenAiSettingsStore.getTrackedThrowawayKeys()
1382
- : [];
1383
- const normalizedTrackedThrowawayKeys = dedupeNormalizedSessionKeys(
1384
- trackedThrowawayKeys,
1385
- );
1386
- const resolvedSessions = await sessionService.getSessionsByExactKeys([
1387
- ...normalizedTrackedThrowawayKeys,
1388
- ...(dedicatedEvenAiKey ? [dedicatedEvenAiKey] : []),
1389
- ]);
1390
- const normalizedDedicatedKey = dedicatedEvenAiKey.toLowerCase();
1391
- const sessions = [];
1392
- let dedicatedIncluded = false;
1393
- for (const session of resolvedSessions) {
1394
- if (
1395
- !dedicatedIncluded &&
1396
- session &&
1397
- typeof session.key === "string" &&
1398
- session.key.trim().toLowerCase() === normalizedDedicatedKey
1399
- ) {
1400
- sessions.push(session);
1401
- dedicatedIncluded = true;
1402
- continue;
1403
- }
1404
- sessions.push(session);
1405
- }
1406
- if (!dedicatedIncluded && dedicatedEvenAiKey) {
1407
- sessions.unshift({
1408
- key: dedicatedEvenAiKey,
1409
- updatedAt: 0,
1410
- preview: "",
1411
- firstUserMessage: "",
1412
- });
1413
- }
1414
- return {
1415
- sessions,
1416
- dedicatedKey,
1417
- };
2006
+ return buildEvenAiSessionsSnapshot();
1418
2007
  },
1419
2008
 
1420
2009
  async onSetEvenAiSettings(patch) {
@@ -1445,6 +2034,9 @@ function createRelay(opts) {
1445
2034
  sessionService.invalidateSessionsCache();
1446
2035
  resetActivityStatusAdapter();
1447
2036
  conversationState.clear();
2037
+ if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
2038
+ upstreamRuntime.clearTyping("slash_reset");
2039
+ }
1448
2040
  conversationState.setAgentName(
1449
2041
  (upstreamRuntime ? upstreamRuntime.getAgentName() : null) || "Agent",
1450
2042
  );
@@ -1460,7 +2052,7 @@ function createRelay(opts) {
1460
2052
  * @returns {boolean} Whether upstream is connected.
1461
2053
  */
1462
2054
  isUpstreamConnected() {
1463
- return upstreamRuntime ? upstreamRuntime.isConnected() : false;
2055
+ return true;
1464
2056
  },
1465
2057
 
1466
2058
  onConsoleLog(level, message) {
@@ -1532,6 +2124,13 @@ function createRelay(opts) {
1532
2124
  return result;
1533
2125
  },
1534
2126
 
2127
+ onTraceLogSet(clientId, request) {
2128
+ return applyTraceLogSet(clientId, request);
2129
+ },
2130
+ onTraceLogGet() {
2131
+ return { ok: true, enabled: liveUiTraceLogEnabled, persistedPath: liveUiTraceFlagPath };
2132
+ },
2133
+
1535
2134
  onDebugDump(clientId, request) {
1536
2135
  const result = debugStore.dump(request);
1537
2136
  if (!result.ok) {
@@ -1766,14 +2365,161 @@ function createRelay(opts) {
1766
2365
  },
1767
2366
  };
1768
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
+ },
1769
2495
  });
1770
2496
 
1771
- // --- Downstream server ---
2497
+ // --- Worker supervisor ---
1772
2498
 
1773
- 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(),
1774
2511
  logger,
1775
- externalDebugToolsEnabled,
1776
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,
1777
2523
  getCurrentPages() {
1778
2524
  return cachedPages;
1779
2525
  },
@@ -1789,82 +2535,30 @@ function createRelay(opts) {
1789
2535
  statusRevision: statusRevision || 0,
1790
2536
  };
1791
2537
  },
1792
- onClientConnected(meta) {
1793
- emitDebug(
1794
- "relay.session",
1795
- "downstream_client_connected",
1796
- "info",
1797
- { sessionKey: sessionService.peekSessionKey() || undefined },
1798
- () => ({
1799
- clientId: meta && meta.clientId ? meta.clientId : null,
1800
- connectedCount: meta && Number.isFinite(meta.connectedCount) ? meta.connectedCount : null,
1801
- connectedAtMs: meta && Number.isFinite(meta.connectedAtMs) ? meta.connectedAtMs : null,
1802
- remoteAddress: meta && meta.remoteAddress ? meta.remoteAddress : null,
1803
- userAgentTail: meta && meta.userAgent ? meta.userAgent : null,
1804
- }),
1805
- );
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);
1806
2548
  },
1807
- onClientDisconnected(meta) {
1808
- emitDebug(
1809
- "relay.session",
1810
- "downstream_client_disconnected",
1811
- "info",
1812
- { sessionKey: sessionService.peekSessionKey() || undefined },
1813
- () => ({
1814
- clientId: meta && meta.clientId ? meta.clientId : null,
1815
- connectedCount: meta && Number.isFinite(meta.connectedCount) ? meta.connectedCount : null,
1816
- connectedAtMs: meta && Number.isFinite(meta.connectedAtMs) ? meta.connectedAtMs : null,
1817
- lifetimeMs: meta && Number.isFinite(meta.lifetimeMs) ? meta.lifetimeMs : null,
1818
- closeCode: meta && Number.isFinite(meta.closeCode) ? meta.closeCode : null,
1819
- closeReasonTail: meta && meta.closeReason ? meta.closeReason : null,
1820
- role: meta && meta.role ? meta.role : null,
1821
- clientKind: meta && meta.clientKind ? meta.clientKind : null,
1822
- protocolVersion: meta && meta.protocolVersion ? meta.protocolVersion : null,
1823
- protocolReason: meta && meta.protocolReason ? meta.protocolReason : null,
1824
- clientName: meta && meta.clientName ? meta.clientName : null,
1825
- clientVersion: meta && meta.clientVersion ? meta.clientVersion : null,
1826
- firstMessageType: meta && meta.firstMessageType ? meta.firstMessageType : null,
1827
- textMessageCount: meta && Number.isFinite(meta.textMessageCount) ? meta.textMessageCount : null,
1828
- binaryMessageCount: meta && Number.isFinite(meta.binaryMessageCount) ? meta.binaryMessageCount : null,
1829
- remoteControlCount: meta && Number.isFinite(meta.remoteControlCount) ? meta.remoteControlCount : null,
1830
- }),
1831
- );
2549
+ cancelBufferedEvenAiHttpRequest(envelope) {
2550
+ return cancelBufferedEvenAiHttpRequest(envelope);
1832
2551
  },
1833
- onTransportControl(meta) {
1834
- if (!meta || meta.controlType !== "visibility") {
1835
- return;
1836
- }
1837
- emitDebug(
1838
- "relay.session",
1839
- "downstream_transport_visibility",
1840
- "info",
1841
- { sessionKey: meta.sessionKey || sessionService.peekSessionKey() || undefined },
1842
- () => ({
1843
- clientId: meta && meta.clientId ? meta.clientId : null,
1844
- state: meta && meta.state ? meta.state : null,
1845
- connectedCount: meta && Number.isFinite(meta.connectedCount) ? meta.connectedCount : null,
1846
- role: meta && meta.role ? meta.role : null,
1847
- clientKind: meta && meta.clientKind ? meta.clientKind : null,
1848
- clientName: meta && meta.clientName ? meta.clientName : null,
1849
- clientVersion: meta && meta.clientVersion ? meta.clientVersion : null,
1850
- protocolVersion: meta && meta.protocolVersion ? meta.protocolVersion : null,
1851
- }),
1852
- );
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);
1853
2560
  },
1854
- httpServer: sharedHttpServer,
1855
- port: opts.port,
1856
- host: opts.host,
1857
- token: opts.token,
1858
2561
  });
1859
- if (ownedHttpServer) {
1860
- ownedHttpServer.on("listening", () => {
1861
- server.wss.emit("listening");
1862
- });
1863
- ownedHttpServer.on("error", (err) => {
1864
- server.wss.emit("error", err);
1865
- });
1866
- listenOwnedRelayHttpServer(ownedHttpServer, opts.host, opts.port);
1867
- }
1868
2562
 
1869
2563
  // --- Helpers ---
1870
2564
 
@@ -1876,6 +2570,8 @@ function createRelay(opts) {
1876
2570
  ? "connected"
1877
2571
  : "disconnected",
1878
2572
  agent: upstreamRuntime ? upstreamRuntime.getAgentName() : null,
2573
+ agentEmoji: upstreamRuntime ? upstreamRuntime.getAgentEmoji() : null,
2574
+ agentAvatarHash: upstreamRuntime ? upstreamRuntime.getAgentAvatarHash() : null,
1879
2575
  session: sessionService.ensureSessionKey(),
1880
2576
  evenAiEnabled: opts.evenAiEnabled === true,
1881
2577
  };
@@ -1924,6 +2620,97 @@ function createRelay(opts) {
1924
2620
  }
1925
2621
  }
1926
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
+
1927
2714
  /**
1928
2715
  * Build, cache, and broadcast the current status.
1929
2716
  */
@@ -1932,10 +2719,24 @@ function createRelay(opts) {
1932
2719
  if (next !== null) {
1933
2720
  server.broadcast(next);
1934
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
+ }
1935
2735
  }
1936
2736
 
1937
2737
  upstreamRuntime = createUpstreamRuntime({
1938
2738
  logger,
2739
+ stateDir: opts.stateDir,
1939
2740
  gatewayBridge,
1940
2741
  conversationState,
1941
2742
  sessionService,
@@ -1944,6 +2745,11 @@ function createRelay(opts) {
1944
2745
  broadcastPages,
1945
2746
  broadcastStatus,
1946
2747
  broadcastActivity,
2748
+ broadcastProviderUsageSnapshot,
2749
+ operationRegistry: relayOperationRegistry,
2750
+ getCurrentSessionModelConfigSnapshot() {
2751
+ return currentSessionModelConfigSnapshot;
2752
+ },
1947
2753
  resetActivityStatusAdapter,
1948
2754
  modelsCacheTtlMs: opts.modelsCacheTtlMs,
1949
2755
  getServer() {
@@ -1952,8 +2758,39 @@ function createRelay(opts) {
1952
2758
  getVoiceRuntime() {
1953
2759
  return null;
1954
2760
  },
2761
+ gatewayUrl: opts.gatewayUrl,
2762
+ gatewayToken: opts.gatewayToken,
2763
+ fetchAgentAvatar: opts.fetchAgentAvatar,
1955
2764
  });
1956
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
+
1957
2794
  if (opts.evenAiEnabled === true) {
1958
2795
  evenAiRouter = createEvenAiRouter({
1959
2796
  sessionService,
@@ -1971,7 +2808,7 @@ function createRelay(opts) {
1971
2808
  logger,
1972
2809
  httpServer: sharedHttpServer,
1973
2810
  enabled: true,
1974
- externallyRouted: opts.evenAiExternalHttpRouting === true,
2811
+ externallyRouted: true,
1975
2812
  token: opts.evenAiToken,
1976
2813
  getSettingsSnapshot() {
1977
2814
  return evenAiSettingsStore.getSnapshot();
@@ -1992,6 +2829,9 @@ function createRelay(opts) {
1992
2829
  emitListenInterceptRecovery(params) {
1993
2830
  return emitListenInterceptRecovery(params);
1994
2831
  },
2832
+ emitListenInterceptBroadcast(params) {
2833
+ return emitListenInterceptBroadcast(params);
2834
+ },
1995
2835
  hasConnectedAppClient() {
1996
2836
  return server ? server.getConnectedAppCount() > 0 : false;
1997
2837
  },
@@ -2011,31 +2851,29 @@ function createRelay(opts) {
2011
2851
  },
2012
2852
  async shouldSeedThinkingForRoute(params) {
2013
2853
  const route = params && params.route ? params.route : params;
2014
- const routingMode =
2015
- route && typeof route.routingMode === "string"
2016
- ? route.routingMode.trim().toLowerCase()
2017
- : "active";
2018
2854
  const thinkingLevel =
2019
2855
  params && typeof params.thinkingLevel === "string"
2020
2856
  ? params.thinkingLevel.trim().toLowerCase()
2021
2857
  : "";
2022
- const sessionKey =
2023
- route && typeof route.sessionKey === "string" ? route.sessionKey.trim() : "";
2024
- if (!thinkingLevel || !sessionKey || routingMode === "active") {
2858
+ if (!thinkingLevel) {
2025
2859
  return false;
2026
2860
  }
2027
- if (routingMode === "background_new") {
2028
- return true;
2029
- }
2030
- 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) {
2031
2867
  return false;
2032
2868
  }
2033
- try {
2034
- const existingSessions = await sessionService.getSessionsByExactKeys([sessionKey]);
2035
- return existingSessions.length === 0;
2036
- } catch {
2869
+ if (!(await shouldSeedSessionScopedDefaultForRoute(route))) {
2037
2870
  return false;
2038
2871
  }
2872
+ const result = await sessionService.setSessionModelConfig(
2873
+ route.sessionKey.trim(),
2874
+ { fastMode: true },
2875
+ );
2876
+ return !!(result && result.status === "accepted");
2039
2877
  },
2040
2878
  onSessionActivated(route) {
2041
2879
  if (!route || !route.sessionChanged) {
@@ -2052,15 +2890,70 @@ function createRelay(opts) {
2052
2890
  });
2053
2891
  }
2054
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
+
2055
2938
  // --- Public API ---
2056
2939
 
2057
- 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
+ },
2058
2951
  /**
2059
2952
  * Start the upstream OpenClaw connection.
2060
2953
  * The downstream server is already listening from construction.
2061
- */
2954
+ */
2062
2955
  start() {
2063
- return Promise.resolve(gatewayBridge.start()).then(() => {
2956
+ const startGateway = () => Promise.resolve(gatewayBridge.start()).then(() => {
2064
2957
  prefetchSonioxModels("relay_start").catch((err) => {
2065
2958
  logger.warn(`[relay] Soniox models prefetch failed: ${err.message}`);
2066
2959
  });
@@ -2068,6 +2961,10 @@ function createRelay(opts) {
2068
2961
  return upstreamRuntime.start();
2069
2962
  }
2070
2963
  });
2964
+ if (server && typeof server.start === "function") {
2965
+ return Promise.resolve(server.start()).then(startGateway);
2966
+ }
2967
+ return startGateway();
2071
2968
  },
2072
2969
 
2073
2970
  /**
@@ -2086,10 +2983,12 @@ function createRelay(opts) {
2086
2983
  if (upstreamRuntime) {
2087
2984
  upstreamRuntime.stop();
2088
2985
  }
2986
+ relayHealth.stop();
2089
2987
  gatewayBridge.stop();
2090
- return Promise.resolve(server.close()).then(() =>
2091
- closeOwnedRelayHttpServer(ownedHttpServer),
2092
- );
2988
+ return Promise.all([
2989
+ sessionService.flushFirstSentUserMessageCache(),
2990
+ Promise.resolve(server.close()),
2991
+ ]).then(() => undefined);
2093
2992
  },
2094
2993
 
2095
2994
  handleEvenAiHttpRequest(req, res) {
@@ -2099,11 +2998,34 @@ function createRelay(opts) {
2099
2998
  return Promise.resolve(evenAiEndpoint.handleRequest(req, res));
2100
2999
  },
2101
3000
 
3001
+ handleBufferedEvenAiHttpRequest,
3002
+
2102
3003
  /** The downstream server instance. */
2103
3004
  get server() {
2104
3005
  return server;
2105
3006
  },
2106
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
+
2107
3029
  get httpServer() {
2108
3030
  return sharedHttpServer;
2109
3031
  },
@@ -2111,7 +3033,90 @@ function createRelay(opts) {
2111
3033
  getEvenAiSettingsSnapshot() {
2112
3034
  return evenAiSettingsStore.getSnapshot();
2113
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
+ },
2114
3118
  };
3119
+ return relayApi;
2115
3120
  }
2116
3121
 
2117
3122
  const createRelayCore = createRelay;