ocuclaw 1.2.4 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +21 -6
  2. package/dist/config/runtime-config.js +84 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +56 -182
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +754 -83
  27. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  28. package/dist/runtime/plugin-version-service.js +23 -0
  29. package/dist/runtime/protocol-adapter.js +9 -0
  30. package/dist/runtime/provider-usage-select.js +168 -0
  31. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  32. package/dist/runtime/relay-core.js +1293 -225
  33. package/dist/runtime/relay-health-monitor.js +172 -0
  34. package/dist/runtime/relay-operation-registry.js +263 -0
  35. package/dist/runtime/relay-service.js +201 -1
  36. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  37. package/dist/runtime/relay-worker-entry.js +32 -0
  38. package/dist/runtime/relay-worker-health.js +272 -0
  39. package/dist/runtime/relay-worker-protocol.js +281 -0
  40. package/dist/runtime/relay-worker-queue.js +202 -0
  41. package/dist/runtime/relay-worker-supervisor.js +1004 -0
  42. package/dist/runtime/relay-worker-transport.js +1051 -0
  43. package/dist/runtime/session-context-service.js +189 -0
  44. package/dist/runtime/session-service.js +638 -27
  45. package/dist/runtime/upstream-runtime.js +1167 -60
  46. package/dist/tools/device-info-tool.js +242 -0
  47. package/dist/tools/glasses-ui-cron.js +427 -0
  48. package/dist/tools/glasses-ui-descriptors.js +261 -0
  49. package/dist/tools/glasses-ui-limits.js +21 -0
  50. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  51. package/dist/tools/glasses-ui-recipes.js +581 -0
  52. package/dist/tools/glasses-ui-surfaces.js +278 -0
  53. package/dist/tools/glasses-ui-template.js +182 -0
  54. package/dist/tools/glasses-ui-tool.js +1111 -0
  55. package/dist/tools/session-title-tool.js +209 -0
  56. package/dist/version.js +2 -0
  57. package/openclaw.plugin.json +163 -15
  58. package/package.json +14 -5
  59. package/skills/glasses-ui/SKILL.md +156 -0
  60. package/dist/runtime/downstream-server.js +0 -1891
@@ -1,9 +1,14 @@
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 { EventEmitter } from "node:events";
4
+ import { createPluginVersionService } from "./plugin-version-service.js";
4
5
  import * as conversationStateModule from "../domain/conversation-state.js";
5
6
  import { createDebugStore } from "../domain/debug-store.js";
7
+ import { summarizeGlassesUiContent } from "../domain/glasses-ui-content-summary.js";
6
8
  import { composeReadabilitySystemPrompt } from "../domain/readability-system-prompt.js";
9
+ import { composeNeuralEmojiReactorSystemPrompt } from "../domain/neural-emoji-reactor-system-prompt.js";
10
+ import { composeNeuralPaceModulatorSystemPrompt } from "../domain/neural-pace-modulator-system-prompt.js";
11
+ import { composeGlassesUiNudgeSystemPrompt } from "../domain/glasses-ui-system-prompt.js";
7
12
  import { createActivityStatusAdapter } from "../domain/activity-status-adapter.js";
8
13
  import { createEvenAiEndpoint } from "../even-ai/even-ai-endpoint.js";
9
14
  import { createEvenAiRouter } from "../even-ai/even-ai-router.js";
@@ -12,15 +17,23 @@ import { createEvenAiSettingsStore } from "../even-ai/even-ai-settings-store.js"
12
17
  import { createPluginOpenclawClient } from "../gateway/openclaw-client.js";
13
18
  import { createPluginRpcGatewayBridge } from "../gateway/gateway-bridge.js";
14
19
  import { createDownstreamHandler } from "./downstream-handler.js";
15
- import { createDownstreamServer } from "./downstream-server.js";
16
20
  import { createOcuClawSettingsStore } from "./ocuclaw-settings-store.js";
17
- import { createSessionService } from "./session-service.js";
21
+ import { createRelayHealthMonitor } from "./relay-health-monitor.js";
22
+ import { createRelayOperationRegistry } from "./relay-operation-registry.js";
23
+ import { createRelayWorkerSupervisor } from "./relay-worker-supervisor.js";
24
+ import {
25
+ createSessionService,
26
+ NEW_SESSION_GREETING_PROMPT,
27
+ } from "./session-service.js";
18
28
  import { createUpstreamRuntime } from "./upstream-runtime.js";
19
29
 
20
30
  const SONIOX_TEMP_KEY_URL = "https://api.soniox.com/v1/auth/temporary-api-key";
21
31
  const SONIOX_MODELS_URL = "https://api.soniox.com/v1/models";
22
32
  const DEFAULT_SONIOX_TEMP_KEY_EXPIRES_IN_SECONDS = 3600;
23
- const EVEN_AI_HANDLED = Symbol.for("ocuclaw.evenai.handled");
33
+ // Maximum time (ms) to wait for a Soniox temp-key mint before aborting the
34
+ // fetch. 8 s is a conservative cold-path ceiling; tests inject a tiny value
35
+ // via opts.sonioxTemporaryKeyMintTimeoutMs to make assertions fast.
36
+ const DEFAULT_SONIOX_TEMP_KEY_MINT_TIMEOUT_MS = 8000;
24
37
  const EVEN_AI_NAMESPACE_PREFIX = "ocuclaw:even-ai";
25
38
  const EVEN_AI_NAMESPACE_PREFIX_WITH_DELIMITER = "ocuclaw:even-ai:";
26
39
  const LISTEN_INTERCEPT_RECOVERY_ERROR = "Voice interrupted; retry";
@@ -159,6 +172,10 @@ function normalizeSonioxTemporaryKeyErrorCode(err) {
159
172
  : "";
160
173
  const lowered = message.toLowerCase();
161
174
  if (!message) return "soniox_temp_key_request_failed";
175
+ // AbortError from the per-fetch timeout AbortController.
176
+ if (err && err.name === "AbortError") {
177
+ return "soniox_temp_key_mint_timeout";
178
+ }
162
179
  if (lowered.includes("api key is not configured")) {
163
180
  return "soniox_temp_key_not_configured";
164
181
  }
@@ -210,31 +227,71 @@ function normalizeSonioxModelEntryRows(result) {
210
227
  return models;
211
228
  }
212
229
 
213
- function createOwnedRelayHttpServer() {
214
- return http.createServer((_req, res) => {
215
- if (res.writableEnded || res[EVEN_AI_HANDLED]) {
216
- return;
230
+ function createBufferedHttpRequest(envelope) {
231
+ const req = new EventEmitter();
232
+ req.method = envelope && envelope.method ? envelope.method : "GET";
233
+ req.url = envelope && envelope.url ? envelope.url : "/";
234
+ req.headers = envelope && envelope.headers && typeof envelope.headers === "object"
235
+ ? envelope.headers
236
+ : {};
237
+ req.socket = {
238
+ remoteAddress: "127.0.0.1",
239
+ };
240
+ const body = Buffer.from((envelope && envelope.bodyBase64) || "", "base64");
241
+ process.nextTick(() => {
242
+ if (body.length > 0) {
243
+ req.emit("data", body);
217
244
  }
218
- res.statusCode = 404;
219
- res.setHeader("content-type", "text/plain; charset=utf-8");
220
- res.end("not found");
245
+ req.emit("end");
221
246
  });
247
+ return req;
222
248
  }
223
249
 
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
- });
250
+ function createBufferedHttpResponse(maxResponseBytes) {
251
+ const headers = {};
252
+ const chunks = [];
253
+ let totalBytes = 0;
254
+ const limit = Number.isFinite(maxResponseBytes) && maxResponseBytes > 0
255
+ ? Math.floor(maxResponseBytes)
256
+ : 262_144;
257
+ // EventEmitter shape: handlers like the Even-AI endpoint subscribe to
258
+ // res.once('close', ...) for client-disconnect detection. Worker-mode
259
+ // relays actual client closes through an http.cancel worker message.
260
+ const res = new EventEmitter();
261
+ res.statusCode = 200;
262
+ res.writableEnded = false;
263
+ res.setHeader = function (name, value) {
264
+ if (typeof name === "string" && name) {
265
+ headers[name.toLowerCase()] = value;
266
+ }
267
+ };
268
+ res.getHeader = function (name) {
269
+ return typeof name === "string" ? headers[name.toLowerCase()] : undefined;
270
+ };
271
+ res.write = function (chunk) {
272
+ if (this.writableEnded) return false;
273
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
274
+ totalBytes += buffer.length;
275
+ if (totalBytes > limit) {
276
+ throw new Error("Buffered HTTP response exceeded relay worker limit");
277
+ }
278
+ chunks.push(buffer);
279
+ return true;
280
+ };
281
+ res.end = function (chunk) {
282
+ if (chunk !== undefined && chunk !== null) {
283
+ this.write(chunk);
284
+ }
285
+ this.writableEnded = true;
286
+ };
287
+ res.toResult = function () {
288
+ return {
289
+ statusCode: this.statusCode,
290
+ headers: { ...headers },
291
+ body: Buffer.concat(chunks),
292
+ };
293
+ };
294
+ return res;
238
295
  }
239
296
 
240
297
  // --- Factory ---
@@ -283,9 +340,7 @@ function createRelay(opts) {
283
340
  const activityStatusAdapter = createActivityStatusAdapter(
284
341
  opts.activityStatusAdapter,
285
342
  );
286
- const ownedHttpServer =
287
- !opts.httpServer && opts.evenAiEnabled === true ? createOwnedRelayHttpServer() : null;
288
- const sharedHttpServer = opts.httpServer || ownedHttpServer || null;
343
+ const sharedHttpServer = opts.httpServer || null;
289
344
 
290
345
  // --- Cached state ---
291
346
 
@@ -298,6 +353,8 @@ function createRelay(opts) {
298
353
  let cachedStatus = null;
299
354
  /** Monotonic status snapshot revision used for resume handshake. */
300
355
  let statusRevision = 0;
356
+ /** @type {{sessionKey: string, modelProvider: string|null, model: string|null, thinkingLevel: string, reasoningLevel: string, verboseLevel: string}|null} */
357
+ let currentSessionModelConfigSnapshot = null;
301
358
 
302
359
  /** Relay-local deterministic simulate-stream run sequence counter. */
303
360
  let simulateStreamRunSeq = 0;
@@ -306,18 +363,74 @@ function createRelay(opts) {
306
363
 
307
364
  // --- Structured debug state ---
308
365
 
366
+ const debugCategories = Array.isArray(opts.debugCategories)
367
+ ? opts.debugCategories
368
+ : opts.debugCategories && typeof opts.debugCategories === "object"
369
+ ? Object.entries(opts.debugCategories)
370
+ .filter(([, enabled]) => enabled)
371
+ .map(([category]) => category)
372
+ : opts.debugCategories;
373
+ // Single relay-side clock: the debug store, the emitDebug ts stamp, and the
374
+ // liveui log tee all read from this one source so store records and `[liveui]`
375
+ // log lines share an identical ts (downstream reconcilers dedupe on it).
376
+ const debugNow =
377
+ typeof opts.debugNow === "function" ? opts.debugNow : () => Date.now();
378
+
379
+ // --- Durable debug-store arm (survives relay/gateway restarts) ---
380
+ // The capture arm (enabled categories + TTLs) lives only in the in-memory
381
+ // debug-store, which a restart rebuilds empty. We persist it to debug-arm.json
382
+ // (via persistDebugArm) and rehydrate it here at construction — read-once,
383
+ // mirroring liveUiTraceFlagPath below — so a restart no longer silently drops
384
+ // capture. This path covers process RESTART only: a pure WebUI *reload* does NOT
385
+ // restart the relay and already preserves + re-advertises the arm via
386
+ // relay-worker-transport.ts:327-328 (cache.debugConfig re-broadcast to app
387
+ // clients) — do not add reconnect machinery here.
388
+ const debugArmStatePath =
389
+ typeof opts.stateDir === "string" && opts.stateDir
390
+ ? path.join(opts.stateDir, "debug-arm.json")
391
+ : null;
392
+ let initialDebugArm = [];
393
+ if (debugArmStatePath) {
394
+ try {
395
+ const parsed = JSON.parse(fs.readFileSync(debugArmStatePath, "utf8"));
396
+ if (parsed && Array.isArray(parsed.enabled)) {
397
+ initialDebugArm = parsed.enabled;
398
+ }
399
+ } catch {
400
+ initialDebugArm = [];
401
+ }
402
+ }
309
403
  const debugStore = createDebugStore({
310
- categories: opts.debugCategories,
404
+ categories: debugCategories,
311
405
  capacity: opts.debugCapacity,
312
406
  payloadMaxBytes: opts.debugPayloadMaxBytes,
313
407
  defaultTtlMs: opts.debugDefaultTtlMs,
314
408
  maxTtlMs: opts.debugMaxTtlMs,
315
409
  dumpDefaultLimit: opts.debugDumpDefaultLimit,
316
410
  dumpMaxLimit: opts.debugDumpMaxLimit,
317
- now: opts.debugNow,
411
+ now: debugNow,
318
412
  noisyPolicies: opts.debugNoisyPolicies,
413
+ initialEnabled: initialDebugArm,
319
414
  });
320
415
 
416
+ // --- Live-interface trace-log flag (durable across restarts) ---
417
+ // Gates the glasses.lifecycle → gateway-log tee. Read once at construction;
418
+ // toggled live by applyTraceLogSet, which also rewrites this file so the
419
+ // value survives a relay/gateway restart (the store's enable-state does not).
420
+ const liveUiTraceFlagPath =
421
+ typeof opts.stateDir === "string" && opts.stateDir
422
+ ? path.join(opts.stateDir, "liveui-trace.json")
423
+ : null;
424
+ let liveUiTraceLogEnabled = false;
425
+ if (liveUiTraceFlagPath) {
426
+ try {
427
+ liveUiTraceLogEnabled =
428
+ JSON.parse(fs.readFileSync(liveUiTraceFlagPath, "utf8")).enabled === true;
429
+ } catch {
430
+ liveUiTraceLogEnabled = false;
431
+ }
432
+ }
433
+
321
434
  // --- Console log file ---
322
435
 
323
436
  const consoleLogPath =
@@ -373,7 +486,9 @@ function createRelay(opts) {
373
486
  */
374
487
  function emitDebug(cat, event, severity, context, buildData, options) {
375
488
  const force = !!(options && options.force === true);
376
- if (!force && !debugStore.isEnabled(cat)) return;
489
+ if (!force && !debugStore.isEnabled(cat) && !(liveUiTraceLogEnabled && (cat === "glasses.lifecycle" || cat === "openclaw.message"))) {
490
+ return;
491
+ }
377
492
 
378
493
  let data = {};
379
494
  if (typeof buildData === "function") {
@@ -384,18 +499,47 @@ function createRelay(opts) {
384
499
  }
385
500
  }
386
501
 
387
- const payload = {
388
- cat,
389
- event,
390
- severity,
391
- data,
392
- };
502
+ const ts = debugNow();
503
+ const payload = { ts, cat, event, severity, data };
393
504
 
394
505
  if (context && context.sessionKey) payload.sessionKey = context.sessionKey;
395
506
  if (context && context.runId) payload.runId = context.runId;
396
507
  if (context && context.screen) payload.screen = context.screen;
397
508
 
398
509
  debugStore.emit(payload, { force });
510
+
511
+ // Durable openclaw-side trace tee (gated by the persistent flag, NOT the
512
+ // store category enable). Must never throw into the emit path.
513
+ if (liveUiTraceLogEnabled && (cat === "glasses.lifecycle" || cat === "openclaw.message")) {
514
+ try {
515
+ const surfaceId =
516
+ data && typeof data.surfaceId === "string" ? data.surfaceId : null;
517
+ const sessionKey =
518
+ payload.sessionKey ||
519
+ (data && typeof data.sessionKey === "string" ? data.sessionKey : null) ||
520
+ null;
521
+ const side =
522
+ cat === "openclaw.message"
523
+ ? (event === "user_message" ? "user" : "agent")
524
+ : "openclaw";
525
+ logger.info(
526
+ "[liveui] " +
527
+ JSON.stringify({
528
+ trace: "liveui",
529
+ side,
530
+ ts,
531
+ cat,
532
+ event,
533
+ severity,
534
+ surfaceId,
535
+ sessionKey,
536
+ data,
537
+ }),
538
+ );
539
+ } catch {
540
+ // observability must never break the emit path
541
+ }
542
+ }
399
543
  }
400
544
 
401
545
  function isForcedReadinessProofEvent(payload) {
@@ -439,6 +583,11 @@ function createRelay(opts) {
439
583
  )
440
584
  ? Math.max(30, Math.floor(opts.sonioxTemporaryKeyExpiresInSeconds))
441
585
  : DEFAULT_SONIOX_TEMP_KEY_EXPIRES_IN_SECONDS;
586
+ const sonioxTemporaryKeyMintTimeoutMs = Number.isFinite(
587
+ opts.sonioxTemporaryKeyMintTimeoutMs,
588
+ )
589
+ ? Math.max(1, Math.floor(opts.sonioxTemporaryKeyMintTimeoutMs))
590
+ : DEFAULT_SONIOX_TEMP_KEY_MINT_TIMEOUT_MS;
442
591
  /** @type {Array<{id: string, name: string, supportsMaxEndpointDelay: boolean}>|null} */
443
592
  let cachedSonioxModels = null;
444
593
  let cachedSonioxModelsFetchedAt = 0;
@@ -667,18 +816,29 @@ function createRelay(opts) {
667
816
  throw new Error("fetch is not available for Soniox temporary-key minting");
668
817
  }
669
818
 
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
- });
819
+ const mintAbortController = new AbortController();
820
+ const mintTimeoutTimer = setTimeout(
821
+ () => mintAbortController.abort(),
822
+ sonioxTemporaryKeyMintTimeoutMs,
823
+ );
824
+ let response;
825
+ try {
826
+ response = await fetchImpl(SONIOX_TEMP_KEY_URL, {
827
+ method: "POST",
828
+ headers: {
829
+ Authorization: `Bearer ${configuredSonioxApiKey}`,
830
+ "Content-Type": "application/json",
831
+ },
832
+ body: JSON.stringify({
833
+ usage_type: "transcribe_websocket",
834
+ expires_in_seconds: sonioxTemporaryKeyExpiresInSeconds,
835
+ client_reference_id: voiceSessionId,
836
+ }),
837
+ signal: mintAbortController.signal,
838
+ });
839
+ } finally {
840
+ clearTimeout(mintTimeoutTimer);
841
+ }
682
842
 
683
843
  const rawText =
684
844
  response && typeof response.text === "function"
@@ -764,11 +924,63 @@ function createRelay(opts) {
764
924
  },
765
925
  });
766
926
 
767
- function currentOcuClawSendOptions() {
927
+ function currentOcuClawSendOptions(perTurnSignals) {
928
+ const signals = perTurnSignals || {};
929
+ const baseReadability = composeReadabilitySystemPrompt(
930
+ ocuClawSettingsStore.getSnapshot().systemPrompt,
931
+ );
932
+ const validState = (raw) =>
933
+ raw === "active" || raw === "recently-disabled" || raw === "inactive"
934
+ ? raw
935
+ : "inactive";
936
+ const reactorState = validState(signals.neuralEmojiReactorState);
937
+ const paceState = validState(signals.neuralPaceModulatorState);
938
+ const reactor = composeNeuralEmojiReactorSystemPrompt({ state: reactorState });
939
+ const pace = composeNeuralPaceModulatorSystemPrompt({ state: paceState });
940
+ // Only include the glasses-UI nudge when a downstream app client is
941
+ // connected. Keeps the prompt clean when the agent has nowhere to render
942
+ // the tool's output, and keeps existing prompt-assembly tests stable
943
+ // (they exercise the prompt without spinning up an app client).
944
+ const hasAppClient =
945
+ server &&
946
+ typeof server.getConnectedAppCount === "function" &&
947
+ server.getConnectedAppCount() > 0;
948
+ const glassesUiNudge = hasAppClient ? composeGlassesUiNudgeSystemPrompt() : "";
949
+ const parts = [];
950
+ if (baseReadability) parts.push(baseReadability);
951
+ if (reactor) parts.push(reactor);
952
+ if (pace) parts.push(pace);
953
+ if (glassesUiNudge) parts.push(glassesUiNudge);
768
954
  return {
769
- extraSystemPrompt: composeReadabilitySystemPrompt(
770
- ocuClawSettingsStore.getSnapshot().systemPrompt,
771
- ),
955
+ extraSystemPrompt: parts.join("\n\n"),
956
+ };
957
+ }
958
+
959
+ function buildOcuClawSendDiagnostic(params = {}) {
960
+ const attachment = params.attachment || null;
961
+ const messageId =
962
+ typeof params.id === "string" && params.id.trim()
963
+ ? params.id.trim()
964
+ : null;
965
+ const sessionKey =
966
+ typeof params.sessionKey === "string" && params.sessionKey.trim()
967
+ ? params.sessionKey.trim()
968
+ : sessionService.ensureSessionKey();
969
+ const source =
970
+ typeof params.source === "string" && params.source.trim()
971
+ ? params.source.trim()
972
+ : "relay_send";
973
+
974
+ return {
975
+ messageId,
976
+ sessionKey,
977
+ source,
978
+ textChars: typeof params.text === "string" ? params.text.length : 0,
979
+ hasAttachment: !!attachment,
980
+ attachmentBytes:
981
+ attachment && Number.isFinite(attachment.sizeBytes)
982
+ ? Math.floor(attachment.sizeBytes)
983
+ : null,
772
984
  };
773
985
  }
774
986
 
@@ -815,6 +1027,9 @@ function createRelay(opts) {
815
1027
  ) {
816
1028
  patch.thinkingLevel = settings.defaultThinking.trim().toLowerCase();
817
1029
  }
1030
+ if (settings && settings.defaultFastMode === true) {
1031
+ patch.fastMode = true;
1032
+ }
818
1033
  return Object.keys(patch).length > 0 ? patch : null;
819
1034
  }
820
1035
 
@@ -905,8 +1120,92 @@ function createRelay(opts) {
905
1120
  onSessionStateReset: resetActivityStatusAdapter,
906
1121
  onPagesChanged: cachePages,
907
1122
  onStatusChanged: broadcastStatus,
1123
+ onSessionModelConfig(config) {
1124
+ applyCurrentSessionModelConfigSnapshot(config);
1125
+ },
1126
+ broadcastSessions: () => broadcastSessions(),
1127
+ broadcastEvenAiSessions: () => broadcastEvenAiSessions(),
1128
+ });
1129
+
1130
+ const relayHealth = createRelayHealthMonitor({
1131
+ emitDebug(event, severity, data) {
1132
+ emitDebug(
1133
+ "relay.health",
1134
+ event,
1135
+ severity,
1136
+ { sessionKey: sessionService.peekSessionKey() || undefined },
1137
+ () => data,
1138
+ { force: event === "relay_queue_depth" },
1139
+ );
1140
+ },
1141
+ });
1142
+ relayHealth.start();
1143
+
1144
+ const relayOperationRegistry = createRelayOperationRegistry({
1145
+ emitDebug(event, severity, data, context = {}) {
1146
+ emitDebug(
1147
+ "relay.operation",
1148
+ event,
1149
+ severity,
1150
+ {
1151
+ sessionKey: context.sessionKey || sessionService.peekSessionKey() || undefined,
1152
+ runId: context.runId || undefined,
1153
+ },
1154
+ () => data,
1155
+ );
1156
+ },
908
1157
  });
909
1158
 
1159
+ function isActiveSessionModelConfig(config) {
1160
+ return !!(
1161
+ config &&
1162
+ typeof config.sessionKey === "string" &&
1163
+ (
1164
+ typeof sessionService.isCurrentSession === "function"
1165
+ ? sessionService.isCurrentSession(config.sessionKey)
1166
+ : config.sessionKey === sessionService.ensureSessionKey()
1167
+ )
1168
+ );
1169
+ }
1170
+
1171
+ function applyCurrentSessionModelConfigSnapshot(config) {
1172
+ if (!isActiveSessionModelConfig(config)) {
1173
+ return false;
1174
+ }
1175
+ currentSessionModelConfigSnapshot = config;
1176
+ if (
1177
+ upstreamRuntime &&
1178
+ typeof upstreamRuntime.handleCurrentSessionModelConfigChanged === "function"
1179
+ ) {
1180
+ upstreamRuntime.handleCurrentSessionModelConfigChanged().catch((err) => {
1181
+ logger.warn(`[relay] Provider usage rebroadcast failed after session config update: ${err.message}`);
1182
+ });
1183
+ }
1184
+ return true;
1185
+ }
1186
+
1187
+ function clearCurrentSessionModelConfigSnapshot(trigger) {
1188
+ currentSessionModelConfigSnapshot = null;
1189
+ if (
1190
+ upstreamRuntime &&
1191
+ typeof upstreamRuntime.handleCurrentSessionModelConfigCleared === "function"
1192
+ ) {
1193
+ upstreamRuntime.handleCurrentSessionModelConfigCleared().catch((err) => {
1194
+ logger.warn(`[relay] Provider usage clear broadcast failed after ${trigger}: ${err.message}`);
1195
+ });
1196
+ }
1197
+ }
1198
+
1199
+ // TTL fallback for set_session_title activity label. The tool itself
1200
+ // completes in <50ms but its label can linger if no follow-up activity
1201
+ // arrives (e.g. agent streams a response directly after, with no
1202
+ // intervening activity event). After 1s, synthesize a thinking-status
1203
+ // activity with no tool/label so the renderer falls back to the bare
1204
+ // animated spinner. Any real activity arriving in the meantime cancels
1205
+ // the timer.
1206
+ const SESSION_TITLE_STATUS_FALLBACK_MS = 1500;
1207
+ let sessionTitleStatusFallbackTimer = null;
1208
+
910
1209
  function broadcastActivity(rawActivity) {
911
1210
  const activity = activityStatusAdapter.augmentActivity(rawActivity || {});
912
1211
  const runId = activity && activity.runId ? activity.runId : null;
@@ -928,6 +1227,8 @@ function createRelay(opts) {
928
1227
  intent: (activity && activity.intent) || null,
929
1228
  thinkingSummarySource: (activity && activity.thinkingSummarySource) || null,
930
1229
  category: (activity && activity.category) || null,
1230
+ isError: typeof activity.isError === "boolean" ? activity.isError : null,
1231
+ code: (activity && activity.code) || null,
931
1232
  activityId: (activity && activity.activityId) || null,
932
1233
  seq: Number.isFinite(activity && activity.seq) ? activity.seq : null,
933
1234
  origin,
@@ -936,9 +1237,194 @@ function createRelay(opts) {
936
1237
  );
937
1238
 
938
1239
  server.broadcast(handler.formatActivity(activity));
1240
+
1241
+ if (sessionTitleStatusFallbackTimer) {
1242
+ clearTimeout(sessionTitleStatusFallbackTimer);
1243
+ sessionTitleStatusFallbackTimer = null;
1244
+ }
1245
+ if (
1246
+ activity &&
1247
+ activity.tool === "set_session_title" &&
1248
+ phase !== "end" &&
1249
+ origin !== "synthetic_session_title_fallback"
1250
+ ) {
1251
+ const fallbackSessionKey = activity.sessionKey || null;
1252
+ const fallbackRunId = runId;
1253
+ sessionTitleStatusFallbackTimer = setTimeout(() => {
1254
+ sessionTitleStatusFallbackTimer = null;
1255
+ broadcastActivity({
1256
+ state: "thinking",
1257
+ sessionKey: fallbackSessionKey,
1258
+ runId: fallbackRunId,
1259
+ origin: "synthetic_session_title_fallback",
1260
+ phase: "update",
1261
+ });
1262
+ }, SESSION_TITLE_STATUS_FALLBACK_MS);
1263
+ }
1264
+
939
1265
  return activity;
940
1266
  }
941
1267
 
1268
+ function broadcastProviderUsageSnapshot(snapshot) {
1269
+ if (!server || !handler || typeof handler.formatProviderUsageSnapshot !== "function") {
1270
+ return snapshot;
1271
+ }
1272
+ server.broadcast(handler.formatProviderUsageSnapshot(snapshot || {}));
1273
+ return snapshot;
1274
+ }
1275
+
1276
+ const appClientDisconnectHandlers = new Set();
1277
+ function onAppClientDisconnect(handler) {
1278
+ if (typeof handler !== "function") return () => {};
1279
+ appClientDisconnectHandlers.add(handler);
1280
+ return () => appClientDisconnectHandlers.delete(handler);
1281
+ }
1282
+ function dispatchAppClientDisconnect(sessionKey) {
1283
+ for (const handler of appClientDisconnectHandlers) {
1284
+ try { handler({ sessionKey }); } catch (err) {
1285
+ logger.warn(`[relay] app_client_disconnect handler threw: ${err && err.message ? err.message : err}`);
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ const glassesUiResultHandlers = new Set();
1291
+
1292
+ function sendGlassesUiRender(params) {
1293
+ if (!server) return;
1294
+ const payload = {
1295
+ type: "glasses_ui_render",
1296
+ sessionKey: params && typeof params.sessionKey === "string" ? params.sessionKey : null,
1297
+ surfaceId: params && typeof params.surfaceId === "string" ? params.surfaceId : "",
1298
+ depth: Number.isFinite(params && params.depth) ? Math.floor(params.depth) : 1,
1299
+ spec: params && params.spec ? params.spec : null,
1300
+ };
1301
+ server.broadcast(JSON.stringify(payload));
1302
+ emitDebug(
1303
+ "glasses.lifecycle",
1304
+ "surface_send",
1305
+ "debug",
1306
+ { sessionKey: payload.sessionKey || undefined },
1307
+ () => ({ surfaceId: payload.surfaceId, mode: "render", depth: payload.depth, ...summarizeGlassesUiContent(payload.spec) }),
1308
+ );
1309
+ }
1310
+
1311
+ function sendGlassesUiSurfaceUpdate(params) {
1312
+ if (!server) return;
1313
+ const patch = params && params.patch ? params.patch : null;
1314
+ if (!patch) return;
1315
+ const cleanPatch = {};
1316
+ if (typeof patch.title === "string") cleanPatch.title = patch.title;
1317
+ if (typeof patch.body === "string") cleanPatch.body = patch.body;
1318
+ if (Array.isArray(patch.items)) {
1319
+ // Items may be plain-string labels (list_surface / label-only) OR
1320
+ // {label, body} objects (list_with_details detail-body ticks). Keep both
1321
+ // shapes; drop anything malformed (no string, no string label).
1322
+ cleanPatch.items = patch.items
1323
+ .map((i) => {
1324
+ if (typeof i === "string") return i;
1325
+ if (i && typeof i === "object" && typeof i.label === "string") {
1326
+ const o = { label: i.label };
1327
+ if (typeof i.body === "string") o.body = i.body;
1328
+ return o;
1329
+ }
1330
+ return null;
1331
+ })
1332
+ .filter((i) => i !== null);
1333
+ }
1334
+ const payload = {
1335
+ type: "glasses_ui_surface_update",
1336
+ sessionKey: params && typeof params.sessionKey === "string" ? params.sessionKey : null,
1337
+ surfaceId: params && typeof params.surfaceId === "string" ? params.surfaceId : "",
1338
+ patch: cleanPatch,
1339
+ };
1340
+ server.broadcast(JSON.stringify(payload));
1341
+ emitDebug(
1342
+ "glasses.lifecycle",
1343
+ "surface_send",
1344
+ "debug",
1345
+ { sessionKey: payload.sessionKey || undefined },
1346
+ () => ({ surfaceId: payload.surfaceId, mode: "update", ...summarizeGlassesUiContent(cleanPatch) }),
1347
+ );
1348
+ }
1349
+
1350
+ function onGlassesUiResult(handler) {
1351
+ if (typeof handler !== "function") return () => {};
1352
+ glassesUiResultHandlers.add(handler);
1353
+ return () => glassesUiResultHandlers.delete(handler);
1354
+ }
1355
+
1356
+ function dispatchGlassesUiResult(frame) {
1357
+ if (!frame || typeof frame !== "object") return;
1358
+ for (const handler of glassesUiResultHandlers) {
1359
+ try {
1360
+ handler({
1361
+ surfaceId: typeof frame.surfaceId === "string" ? frame.surfaceId : "",
1362
+ outcome: frame.outcome,
1363
+ });
1364
+ } catch (err) {
1365
+ logger.warn(`[relay] glasses_ui_result handler threw: ${err.message}`);
1366
+ }
1367
+ }
1368
+ }
1369
+
1370
+ const glassesUiNavEventHandlers = new Set();
1371
+
1372
+ function onGlassesUiNavEvent(handler) {
1373
+ if (typeof handler !== "function") return () => {};
1374
+ glassesUiNavEventHandlers.add(handler);
1375
+ return () => glassesUiNavEventHandlers.delete(handler);
1376
+ }
1377
+
1378
+ function dispatchGlassesUiNavEvent(frame) {
1379
+ if (!frame || typeof frame !== "object") return;
1380
+ for (const handler of glassesUiNavEventHandlers) {
1381
+ try {
1382
+ handler({
1383
+ surfaceId: typeof frame.surfaceId === "string" ? frame.surfaceId : "",
1384
+ depth: Number.isFinite(frame.depth) ? Math.max(1, Math.floor(frame.depth)) : 1,
1385
+ });
1386
+ } catch (err) {
1387
+ logger.warn(`[relay] glasses_ui_nav_event handler threw: ${err.message}`);
1388
+ }
1389
+ }
1390
+ }
1391
+
1392
+ const deviceInfoResponseHandlers = new Set();
1393
+
1394
+ function sendDeviceInfoRequest(params) {
1395
+ if (!server) return;
1396
+ const payload = {
1397
+ type: "device_info_request",
1398
+ sessionKey: params && typeof params.sessionKey === "string" ? params.sessionKey : null,
1399
+ requestId: params && typeof params.requestId === "string" ? params.requestId : "",
1400
+ };
1401
+ server.broadcast(JSON.stringify(payload));
1402
+ }
1403
+
1404
+ function onDeviceInfoResponse(handler) {
1405
+ if (typeof handler !== "function") return () => {};
1406
+ deviceInfoResponseHandlers.add(handler);
1407
+ return () => deviceInfoResponseHandlers.delete(handler);
1408
+ }
1409
+
1410
+ function dispatchDeviceInfoResponse(frame) {
1411
+ if (!frame || typeof frame !== "object") return;
1412
+ for (const handler of deviceInfoResponseHandlers) {
1413
+ try {
1414
+ handler({
1415
+ requestId: typeof frame.requestId === "string" ? frame.requestId : "",
1416
+ ok: frame.ok === true,
1417
+ code: typeof frame.code === "string" ? frame.code : undefined,
1418
+ data: frame.data && typeof frame.data === "object" ? frame.data : undefined,
1419
+ });
1420
+ } catch (err) {
1421
+ logger.warn(
1422
+ `[relay] device_info_response handler threw: ${err && err.message ? err.message : err}`,
1423
+ );
1424
+ }
1425
+ }
1426
+ }
1427
+
942
1428
  function normalizeAttachmentErrorCode(err) {
943
1429
  if (!err) return "attachment_upstream_rejected";
944
1430
  const code = typeof err.code === "string" ? err.code.trim() : "";
@@ -971,10 +1457,18 @@ function createRelay(opts) {
971
1457
  const text = params.text;
972
1458
  const sessionKey = params.sessionKey;
973
1459
  const attachment = params.attachment || null;
1460
+ const clientDisplaySignals = params.clientDisplaySignals || null;
974
1461
  const resolvedSessionKey = sessionKey || sessionService.ensureSessionKey();
975
1462
  sessionService.recordFirstSentUserMessage(resolvedSessionKey, text);
1463
+ if (clientDisplaySignals && resolvedSessionKey) {
1464
+ sessionService.recordNeuralSessionNamesEnabled(
1465
+ resolvedSessionKey,
1466
+ clientDisplaySignals.neuralSessionNamesEnabled !== false,
1467
+ );
1468
+ }
976
1469
  const hasAttachment = !!attachment;
977
1470
  const sendStartedAt = Date.now();
1471
+ relayOperationRegistry.markStarted(id);
978
1472
  sessionService.invalidateSessionsCache();
979
1473
  emitDebug(
980
1474
  "relay.protocol",
@@ -999,13 +1493,24 @@ function createRelay(opts) {
999
1493
  text,
1000
1494
  resolvedSessionKey,
1001
1495
  attachment,
1002
- currentOcuClawSendOptions(),
1496
+ {
1497
+ ...currentOcuClawSendOptions(clientDisplaySignals),
1498
+ diagnostic: buildOcuClawSendDiagnostic({
1499
+ ...params,
1500
+ sessionKey: resolvedSessionKey,
1501
+ }),
1502
+ },
1003
1503
  );
1004
1504
  const upstreamDispatchedAt = Date.now();
1005
1505
 
1006
- conversationState.addMessage(
1007
- "user",
1008
- buildLocalUserMessageContent(text, attachment),
1506
+ const userContent = buildLocalUserMessageContent(text, attachment);
1507
+ conversationState.addMessage("user", userContent);
1508
+ emitDebug(
1509
+ "openclaw.message",
1510
+ "user_message",
1511
+ "info",
1512
+ { sessionKey: resolvedSessionKey },
1513
+ () => ({ text: typeof text === "string" ? text : "" }),
1009
1514
  );
1010
1515
  broadcastPages();
1011
1516
  const localPublishDoneAt = Date.now();
@@ -1028,6 +1533,10 @@ function createRelay(opts) {
1028
1533
  (result) => {
1029
1534
  const ackAt = Date.now();
1030
1535
  const runId = result && result.runId ? result.runId : null;
1536
+ relayOperationRegistry.markUpstreamAck(id, {
1537
+ runId,
1538
+ status: result && result.status ? result.status : null,
1539
+ });
1031
1540
  if (runId && upstreamRuntime) {
1032
1541
  upstreamRuntime.trackAcceptedRun({
1033
1542
  runId,
@@ -1053,8 +1562,16 @@ function createRelay(opts) {
1053
1562
  return result;
1054
1563
  },
1055
1564
  (err) => {
1056
- if (attachment && !err.errorCode) {
1057
- err.errorCode = normalizeAttachmentErrorCode(err);
1565
+ const mirroredErrorCode =
1566
+ err && typeof err.errorCode === "string" && err.errorCode.trim()
1567
+ ? err.errorCode.trim()
1568
+ : err && typeof err.code === "string" && err.code.trim()
1569
+ ? err.code.trim()
1570
+ : attachment
1571
+ ? normalizeAttachmentErrorCode(err)
1572
+ : null;
1573
+ if (mirroredErrorCode && err && typeof err === "object") {
1574
+ err.errorCode = mirroredErrorCode;
1058
1575
  }
1059
1576
  emitDebug(
1060
1577
  "relay.protocol",
@@ -1065,7 +1582,8 @@ function createRelay(opts) {
1065
1582
  messageId: id,
1066
1583
  elapsedMs: Date.now() - sendStartedAt,
1067
1584
  hasAttachment,
1068
- errorCode: err.errorCode || null,
1585
+ errorCode:
1586
+ err && typeof err.errorCode === "string" ? err.errorCode : null,
1069
1587
  message: err && err.message ? err.message : String(err),
1070
1588
  }),
1071
1589
  );
@@ -1097,13 +1615,82 @@ function createRelay(opts) {
1097
1615
  };
1098
1616
  }
1099
1617
 
1618
+ function emitListenInterceptBroadcast(params = {}) {
1619
+ if (!server || !handler) {
1620
+ return;
1621
+ }
1622
+ const sessionKey = params && typeof params.sessionKey === "string" ? params.sessionKey : null;
1623
+ server.broadcast(handler.formatEvenAiListenIntercepted(sessionKey));
1624
+ }
1625
+
1100
1626
  // --- Downstream handler ---
1101
1627
 
1102
- /** @type {ReturnType<typeof createDownstreamServer>|null} */
1628
+ /** @type {ReturnType<typeof createRelayWorkerSupervisor>|null} */
1103
1629
  let server = null;
1104
1630
  let evenAiEndpoint = null;
1105
1631
  let evenAiRouter = null;
1106
1632
  let evenAiRunWaiter = null;
1633
+ const pendingBufferedEvenAiResponses = new Map();
1634
+ let relayApi = null;
1635
+
1636
+ function applyTraceLogSet(clientId, request) {
1637
+ const enabled = !!(request && request.enabled === true);
1638
+ liveUiTraceLogEnabled = enabled;
1639
+ let persisted = false;
1640
+ if (liveUiTraceFlagPath) {
1641
+ try {
1642
+ fs.writeFileSync(liveUiTraceFlagPath, JSON.stringify({ enabled }) + "\n");
1643
+ persisted = true;
1644
+ } catch (err) {
1645
+ logger.warn(`[relay] liveui trace-log flag persist failed: ${err && err.message ? err.message : err}`);
1646
+ }
1647
+ }
1648
+ emitDebug("relay.protocol", "trace_log_set", "info", { sessionKey: sessionService.ensureSessionKey() }, () => ({ clientId, enabled, persisted }));
1649
+ return { ok: true, enabled, persisted, persistedPath: liveUiTraceFlagPath };
1650
+ }
1651
+
1652
+ // Persist the current debug-store arm to debug-arm.json. Mirrors the
1653
+ // applyTraceLogSet writeFileSync above (plain, non-atomic): a partial/corrupt
1654
+ // write degrades to an empty arm on next boot — acceptable, the nothing-armed
1655
+ // warning catches it. getSnapshot().enabled is already pruned of expired
1656
+ // categories, so the persisted JSON never holds an expired entry. Never throws
1657
+ // into the caller.
1658
+ function persistDebugArm() {
1659
+ if (!debugArmStatePath) return false;
1660
+ try {
1661
+ const enabled = debugStore.getSnapshot().enabled;
1662
+ fs.writeFileSync(debugArmStatePath, JSON.stringify({ enabled }) + "\n");
1663
+ return true;
1664
+ } catch (err) {
1665
+ logger.warn(`[relay] debug arm persist failed: ${err && err.message ? err.message : err}`);
1666
+ return false;
1667
+ }
1668
+ }
1669
+
1670
+ function applyDebugSet(clientId, request) {
1671
+ const result = debugStore.setCategories(request);
1672
+ if (!result.ok) {
1673
+ throw new Error(result.error || "debug-set failed");
1674
+ }
1675
+ // Persist after every successful set — enable AND disable-to-empty — so the
1676
+ // on-disk arm always tracks live state and a deliberately-cleared arm is not
1677
+ // resurrected on the next restart.
1678
+ persistDebugArm();
1679
+ emitDebug(
1680
+ "relay.protocol",
1681
+ "debug_set",
1682
+ "info",
1683
+ { sessionKey: sessionService.ensureSessionKey() },
1684
+ () => ({
1685
+ clientId,
1686
+ enable: result.applied.enable,
1687
+ disable: result.applied.disable,
1688
+ ttlMs: result.ttlMs,
1689
+ enabledCount: result.enabled.length,
1690
+ }),
1691
+ );
1692
+ return result;
1693
+ }
1107
1694
 
1108
1695
  const handler = createDownstreamHandler({
1109
1696
  logger,
@@ -1122,14 +1709,99 @@ function createRelay(opts) {
1122
1709
  * @param {object|null} attachment - Optional image attachment payload
1123
1710
  * @returns {Promise}
1124
1711
  */
1125
- onSend(id, text, sessionKey, attachment) {
1712
+ onSend(id, text, sessionKey, attachment, clientDisplaySignals) {
1126
1713
  return dispatchOcuClawUserSend({
1127
1714
  id,
1128
1715
  text,
1129
1716
  sessionKey,
1130
1717
  attachment,
1718
+ clientDisplaySignals: clientDisplaySignals || null,
1719
+ source: "phone_ui",
1131
1720
  });
1132
1721
  },
1722
+ onGlassesUiResult(frame) {
1723
+ emitDebug(
1724
+ "glasses.lifecycle",
1725
+ "surface_outcome",
1726
+ "debug",
1727
+ {},
1728
+ () => ({ surfaceId: frame && frame.surfaceId, outcome: frame && frame.outcome }),
1729
+ );
1730
+ dispatchGlassesUiResult(frame);
1731
+ },
1732
+ onGlassesUiNavEvent(frame) {
1733
+ emitDebug(
1734
+ "glasses.lifecycle",
1735
+ "nav_event_recv",
1736
+ "debug",
1737
+ {},
1738
+ () => ({ surfaceId: frame && frame.surfaceId, depth: frame && frame.depth }),
1739
+ );
1740
+ dispatchGlassesUiNavEvent(frame);
1741
+ },
1742
+ onDeviceInfoResponse(frame) {
1743
+ dispatchDeviceInfoResponse(frame);
1744
+ },
1745
+ onGlassesUiRenderInject(params) {
1746
+ sendGlassesUiRender(params);
1747
+ },
1748
+ onSetUserSessionTitle(sessionKey, title) {
1749
+ const result = sessionService.setSessionTitle(sessionKey, title, { userSet: true });
1750
+ if (result && result.ok) {
1751
+ broadcastSessions();
1752
+ }
1753
+ },
1754
+ onSetSessionPinned(sessionKey, pinned, kind) {
1755
+ const result = sessionService.setSessionPinned(kind, sessionKey, pinned);
1756
+ if (result && result.ok) {
1757
+ broadcastSessions();
1758
+ }
1759
+ return result;
1760
+ },
1761
+ onCompactSession({ sessionKey }) {
1762
+ if (!upstreamRuntime || typeof upstreamRuntime.compactActiveSession !== "function") {
1763
+ return Promise.resolve({
1764
+ status: "rejected",
1765
+ error: "upstream runtime not ready",
1766
+ });
1767
+ }
1768
+ return upstreamRuntime.compactActiveSession(sessionKey);
1769
+ },
1770
+ onDeleteSessions(sessionKeys, kind, switchBeforeDelete) {
1771
+ const action = switchBeforeDelete
1772
+ ? sessionService.switchAndDeleteSessions(kind, sessionKeys)
1773
+ : sessionService.deleteSessions(kind, sessionKeys);
1774
+ Promise.resolve(action)
1775
+ .then(() => broadcastSessions())
1776
+ .catch((err) => {
1777
+ logger.error(`[relay] deleteSessions failed: ${err && err.message ? err.message : err}`);
1778
+ });
1779
+ },
1780
+ onSearchTranscripts(clientId, query, kind) {
1781
+ Promise.resolve(sessionService.searchTranscripts(kind, query))
1782
+ .then((result) => {
1783
+ if (!server) return;
1784
+ const payload = {
1785
+ type: "ocuclaw.session.transcripts.search.result",
1786
+ query,
1787
+ kind,
1788
+ snippets: result.snippets,
1789
+ truncated: result.truncated,
1790
+ };
1791
+ server.unicast(clientId, JSON.stringify(payload));
1792
+ })
1793
+ .catch((err) => {
1794
+ logger.error(`[relay] searchTranscripts failed: ${err && err.message ? err.message : err}`);
1795
+ if (server) {
1796
+ const payload = {
1797
+ type: "ocuclaw.session.transcripts.search.result",
1798
+ query, kind, snippets: [], truncated: false,
1799
+ };
1800
+ server.unicast(clientId, JSON.stringify(payload));
1801
+ }
1802
+ });
1803
+ },
1804
+ operationRegistry: relayOperationRegistry,
1133
1805
 
1134
1806
  /**
1135
1807
  * Inject a fake assistant message into conversation state.
@@ -1285,6 +1957,9 @@ function createRelay(opts) {
1285
1957
  { sessionKey: sessionService.ensureSessionKey() },
1286
1958
  () => ({}),
1287
1959
  );
1960
+ if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
1961
+ upstreamRuntime.clearTyping("new_chat");
1962
+ }
1288
1963
  sessionService.invalidateSessionsCache();
1289
1964
  resetActivityStatusAdapter();
1290
1965
  conversationState.clear();
@@ -1294,6 +1969,12 @@ function createRelay(opts) {
1294
1969
  const pages = conversationState.getPages();
1295
1970
  cachePages(pages);
1296
1971
  if (upstreamRuntime && upstreamRuntime.isConnected()) {
1972
+ // NOTE: onNewChat targets the hard-coded "main" key (legacy) without
1973
+ // changing currentSessionKey, so it must NOT elicit a welcome turn here:
1974
+ // the turn's events would carry "main" and be dropped by isCurrentSession()
1975
+ // whenever the active session is an ocuclaw:* key. The welcome restore for
1976
+ // the real glasses paths lives in newSession() (New) and onSlashCommand
1977
+ // "/reset" (Reset). Unifying onNewChat onto newSession() is Phase-2 work.
1297
1978
  gatewayBridge.sendMessage("/new", "main").catch((err) => {
1298
1979
  logger.error(`[relay] Failed to send /new: ${err.message}`);
1299
1980
  });
@@ -1307,6 +1988,10 @@ function createRelay(opts) {
1307
1988
 
1308
1989
  onSwitchSession(sessionKey) {
1309
1990
  return sessionService.switchToSession(sessionKey).then((pages) => {
1991
+ clearCurrentSessionModelConfigSnapshot("switch_session");
1992
+ if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
1993
+ upstreamRuntime.clearTyping("switch_session");
1994
+ }
1310
1995
  if (upstreamRuntime && typeof upstreamRuntime.handleSessionChanged === "function") {
1311
1996
  upstreamRuntime.handleSessionChanged("switch_session");
1312
1997
  }
@@ -1316,6 +2001,10 @@ function createRelay(opts) {
1316
2001
 
1317
2002
  async onNewSession() {
1318
2003
  const result = await sessionService.newSession();
2004
+ clearCurrentSessionModelConfigSnapshot("new_session");
2005
+ if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
2006
+ upstreamRuntime.clearTyping("new_session");
2007
+ }
1319
2008
  if (upstreamRuntime && typeof upstreamRuntime.handleSessionChanged === "function") {
1320
2009
  upstreamRuntime.handleSessionChanged("new_session");
1321
2010
  }
@@ -1342,6 +2031,20 @@ function createRelay(opts) {
1342
2031
  : Promise.resolve({ skills: [], fetchedAtMs: Date.now(), stale: true });
1343
2032
  },
1344
2033
 
2034
+ onGetProviderUsageSnapshot() {
2035
+ return upstreamRuntime
2036
+ ? upstreamRuntime.getProviderUsageSnapshot()
2037
+ : Promise.resolve({
2038
+ sessionKey: null,
2039
+ provider: null,
2040
+ displayName: null,
2041
+ limitingWindowKey: null,
2042
+ windows: [],
2043
+ fetchedAtMs: Date.now(),
2044
+ stale: true,
2045
+ });
2046
+ },
2047
+
1345
2048
  onGetSonioxModels() {
1346
2049
  return getSonioxModelsSnapshot();
1347
2050
  },
@@ -1356,7 +2059,13 @@ function createRelay(opts) {
1356
2059
 
1357
2060
  async onSetSessionModelConfig(patch) {
1358
2061
  const result = await sessionService.setCurrentSessionModelConfig(patch || {});
1359
- if (result && result.status === "accepted" && result.config) {
2062
+ if (
2063
+ result &&
2064
+ result.status === "accepted" &&
2065
+ result.config &&
2066
+ isActiveSessionModelConfig(result.config)
2067
+ ) {
2068
+ currentSessionModelConfigSnapshot = result.config;
1360
2069
  server.broadcast(handler.formatSessionModelConfig(result.config));
1361
2070
  }
1362
2071
  return result;
@@ -1371,50 +2080,7 @@ function createRelay(opts) {
1371
2080
  },
1372
2081
 
1373
2082
  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
- };
2083
+ return buildEvenAiSessionsSnapshot();
1418
2084
  },
1419
2085
 
1420
2086
  async onSetEvenAiSettings(patch) {
@@ -1445,13 +2111,26 @@ function createRelay(opts) {
1445
2111
  sessionService.invalidateSessionsCache();
1446
2112
  resetActivityStatusAdapter();
1447
2113
  conversationState.clear();
2114
+ if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
2115
+ upstreamRuntime.clearTyping("slash_reset");
2116
+ }
1448
2117
  conversationState.setAgentName(
1449
2118
  (upstreamRuntime ? upstreamRuntime.getAgentName() : null) || "Agent",
1450
2119
  );
1451
2120
  broadcastPages();
1452
2121
  }
1453
2122
  if (upstreamRuntime && upstreamRuntime.isConnected()) {
1454
- return gatewayBridge.sendMessage(command, sessionService.ensureSessionKey());
2123
+ // Bare /reset no longer elicits an agent turn on OpenClaw 2026.6.x
2124
+ // (fast-reset). Append the greeting prompt so Reset gets the same
2125
+ // welcome as New. Other slash commands forward verbatim.
2126
+ const outboundCommand =
2127
+ command === "/reset"
2128
+ ? `/reset ${NEW_SESSION_GREETING_PROMPT}`
2129
+ : command;
2130
+ return gatewayBridge.sendMessage(
2131
+ outboundCommand,
2132
+ sessionService.ensureSessionKey(),
2133
+ );
1455
2134
  }
1456
2135
  return Promise.resolve();
1457
2136
  },
@@ -1460,7 +2139,7 @@ function createRelay(opts) {
1460
2139
  * @returns {boolean} Whether upstream is connected.
1461
2140
  */
1462
2141
  isUpstreamConnected() {
1463
- return upstreamRuntime ? upstreamRuntime.isConnected() : false;
2142
+ return true;
1464
2143
  },
1465
2144
 
1466
2145
  onConsoleLog(level, message) {
@@ -1510,26 +2189,14 @@ function createRelay(opts) {
1510
2189
  },
1511
2190
 
1512
2191
  onDebugSet(clientId, request) {
1513
- const result = debugStore.setCategories(request);
1514
- if (!result.ok) {
1515
- throw new Error(result.error || "debug-set failed");
1516
- }
1517
-
1518
- emitDebug(
1519
- "relay.protocol",
1520
- "debug_set",
1521
- "info",
1522
- { sessionKey: sessionService.ensureSessionKey() },
1523
- () => ({
1524
- clientId,
1525
- enable: result.applied.enable,
1526
- disable: result.applied.disable,
1527
- ttlMs: result.ttlMs,
1528
- enabledCount: result.enabled.length,
1529
- }),
1530
- );
2192
+ return applyDebugSet(clientId, request);
2193
+ },
1531
2194
 
1532
- return result;
2195
+ onTraceLogSet(clientId, request) {
2196
+ return applyTraceLogSet(clientId, request);
2197
+ },
2198
+ onTraceLogGet() {
2199
+ return { ok: true, enabled: liveUiTraceLogEnabled, persistedPath: liveUiTraceFlagPath };
1533
2200
  },
1534
2201
 
1535
2202
  onDebugDump(clientId, request) {
@@ -1766,14 +2433,153 @@ function createRelay(opts) {
1766
2433
  },
1767
2434
  };
1768
2435
  },
2436
+
2437
+ onAutomationState(clientId, request) {
2438
+ // Mirrors onReadinessProbe (above): identify the single connected app
2439
+ // client via the readiness snapshot, then return a dispatch envelope
2440
+ // that downstream-handler.handleAutomationState wraps into
2441
+ // `automationStateRequest`. Without this callback wired, the handler
2442
+ // returns null and the request is silently dropped at the relay —
2443
+ // simctl/debugctl times out with no failure response, no trace event,
2444
+ // no outbox drop. The lack of wiring was found 2026-05-28 while
2445
+ // validating the streaming-thinking-emoji-demotion fix on the sim.
2446
+ const now = Date.now();
2447
+ const requestId =
2448
+ (typeof request.requestId === "string" && request.requestId.trim()) ||
2449
+ `automation-${now}-${Math.random().toString(16).slice(2, 8)}`;
2450
+ const requestedSessionKey =
2451
+ typeof request.sessionKey === "string" && request.sessionKey.trim()
2452
+ ? request.sessionKey.trim()
2453
+ : null;
2454
+ const snapshot =
2455
+ server && typeof server.getReadinessSnapshot === "function"
2456
+ ? server.getReadinessSnapshot()
2457
+ : {
2458
+ connectedClientCount: 0,
2459
+ fanoutRecipientCount: 0,
2460
+ clients: [],
2461
+ };
2462
+ const targetEntry =
2463
+ snapshot &&
2464
+ snapshot.connectedClientCount === 1 &&
2465
+ snapshot.fanoutRecipientCount === 1 &&
2466
+ Array.isArray(snapshot.clients) &&
2467
+ snapshot.clients.length === 1
2468
+ ? snapshot.clients[0]
2469
+ : null;
2470
+ const targetClientId =
2471
+ targetEntry && typeof targetEntry.clientId === "string"
2472
+ ? targetEntry.clientId
2473
+ : null;
2474
+ // A connected app client that has never published a readiness snapshot
2475
+ // cannot answer an automation state request; forwarding anyway would
2476
+ // park the request in pendingAutomationStateRequests with no reply.
2477
+ // Same predicate as the downstream readiness gate; this wired callback
2478
+ // bypasses the normal dispatch path.
2479
+ const readinessPublished =
2480
+ !!(
2481
+ targetEntry &&
2482
+ targetEntry.readinessSnapshot &&
2483
+ Number.isFinite(targetEntry.readinessSnapshot.emittedAtMs)
2484
+ );
2485
+
2486
+ emitDebug(
2487
+ "relay.protocol",
2488
+ "automation_state_requested",
2489
+ "info",
2490
+ { sessionKey: sessionService.ensureSessionKey() },
2491
+ () => ({
2492
+ clientId,
2493
+ requestId,
2494
+ requestedSessionKey,
2495
+ connectedClientCount:
2496
+ snapshot && Number.isFinite(snapshot.connectedClientCount)
2497
+ ? snapshot.connectedClientCount
2498
+ : 0,
2499
+ fanoutRecipientCount:
2500
+ snapshot && Number.isFinite(snapshot.fanoutRecipientCount)
2501
+ ? snapshot.fanoutRecipientCount
2502
+ : 0,
2503
+ }),
2504
+ );
2505
+
2506
+ if (
2507
+ !snapshot ||
2508
+ snapshot.connectedClientCount <= 0 ||
2509
+ snapshot.fanoutRecipientCount <= 0
2510
+ ) {
2511
+ return {
2512
+ ok: false,
2513
+ requestId,
2514
+ reasonCode: "no_downstream_client",
2515
+ message: "No downstream app clients connected",
2516
+ };
2517
+ }
2518
+
2519
+ if (
2520
+ snapshot.connectedClientCount > 1 ||
2521
+ snapshot.fanoutRecipientCount > 1 ||
2522
+ !targetClientId
2523
+ ) {
2524
+ return {
2525
+ ok: false,
2526
+ requestId,
2527
+ reasonCode: "multi_recipient_fanout",
2528
+ message: "Multiple downstream app clients connected",
2529
+ };
2530
+ }
2531
+
2532
+ if (!readinessPublished) {
2533
+ return {
2534
+ ok: false,
2535
+ requestId,
2536
+ reasonCode: "snapshot_unavailable",
2537
+ message: "Automation state snapshot is unavailable",
2538
+ };
2539
+ }
2540
+
2541
+ emitDebug(
2542
+ "relay.protocol",
2543
+ "automation_state_dispatched",
2544
+ "info",
2545
+ { sessionKey: sessionService.ensureSessionKey() },
2546
+ () => ({
2547
+ clientId,
2548
+ requestId,
2549
+ targetClientId,
2550
+ }),
2551
+ );
2552
+
2553
+ return {
2554
+ ok: true,
2555
+ requestId,
2556
+ targetClientId,
2557
+ request: {
2558
+ requestId,
2559
+ sessionKey: requestedSessionKey,
2560
+ },
2561
+ };
2562
+ },
1769
2563
  });
1770
2564
 
1771
- // --- Downstream server ---
2565
+ // --- Worker supervisor ---
2566
+
2567
+ const pluginVersionService = createPluginVersionService();
1772
2568
 
1773
- server = createDownstreamServer({
2569
+ server = createRelayWorkerSupervisor({
2570
+ pluginId: "ocuclaw",
2571
+ getPluginVersion: () => pluginVersionService.getPluginVersion(),
2572
+ getRequiresClientVersion: () => pluginVersionService.getRequiresClientVersion(),
1774
2573
  logger,
1775
- externalDebugToolsEnabled,
1776
2574
  handler,
2575
+ operationRegistry: relayOperationRegistry,
2576
+ host: opts.host,
2577
+ port: opts.port,
2578
+ token: opts.token,
2579
+ externalDebugToolsEnabled,
2580
+ evenAiRequestTimeoutMs: opts.evenAiRequestTimeoutMs,
2581
+ evenAiMaxBodyBytes: opts.evenAiMaxBodyBytes,
2582
+ evenAiMaxResponseBytes: opts.evenAiMaxResponseBytes,
1777
2583
  getCurrentPages() {
1778
2584
  return cachedPages;
1779
2585
  },
@@ -1789,82 +2595,30 @@ function createRelay(opts) {
1789
2595
  statusRevision: statusRevision || 0,
1790
2596
  };
1791
2597
  },
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
- );
2598
+ getAgentAvatarHash: () =>
2599
+ upstreamRuntime && typeof upstreamRuntime.getAgentAvatarHash === "function"
2600
+ ? upstreamRuntime.getAgentAvatarHash()
2601
+ : null,
2602
+ getAgentAvatarDataUriByHash: (hash) =>
2603
+ upstreamRuntime && typeof upstreamRuntime.getAgentAvatarDataUriByHash === "function"
2604
+ ? upstreamRuntime.getAgentAvatarDataUriByHash(hash)
2605
+ : null,
2606
+ handleBufferedEvenAiHttpRequest(envelope) {
2607
+ return handleBufferedEvenAiHttpRequest(envelope);
1806
2608
  },
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
- );
2609
+ cancelBufferedEvenAiHttpRequest(envelope) {
2610
+ return cancelBufferedEvenAiHttpRequest(envelope);
1832
2611
  },
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
- );
2612
+ getActiveSessionKey() {
2613
+ return sessionService.peekSessionKey() || null;
2614
+ },
2615
+ onAppClientDisconnect(sessionKey) {
2616
+ dispatchAppClientDisconnect(sessionKey);
2617
+ },
2618
+ emitDebug(category, event, severity, context, payloadFactory, options) {
2619
+ emitDebug(category, event, severity, context, payloadFactory, options);
1853
2620
  },
1854
- httpServer: sharedHttpServer,
1855
- port: opts.port,
1856
- host: opts.host,
1857
- token: opts.token,
1858
2621
  });
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
2622
 
1869
2623
  // --- Helpers ---
1870
2624
 
@@ -1876,6 +2630,8 @@ function createRelay(opts) {
1876
2630
  ? "connected"
1877
2631
  : "disconnected",
1878
2632
  agent: upstreamRuntime ? upstreamRuntime.getAgentName() : null,
2633
+ agentEmoji: upstreamRuntime ? upstreamRuntime.getAgentEmoji() : null,
2634
+ agentAvatarHash: upstreamRuntime ? upstreamRuntime.getAgentAvatarHash() : null,
1879
2635
  session: sessionService.ensureSessionKey(),
1880
2636
  evenAiEnabled: opts.evenAiEnabled === true,
1881
2637
  };
@@ -1924,6 +2680,97 @@ function createRelay(opts) {
1924
2680
  }
1925
2681
  }
1926
2682
 
2683
+ /**
2684
+ * Fetch the latest sessions snapshot and broadcast it. Used after a session
2685
+ * title changes so connected clients refresh the title in the main webui
2686
+ * status row and Session Settings tab without waiting for a manual
2687
+ * session-list open.
2688
+ */
2689
+ function broadcastSessions() {
2690
+ sessionService
2691
+ .getSessions()
2692
+ .then((sessions) => {
2693
+ server.broadcast(handler.formatSessions(sessions));
2694
+ })
2695
+ .catch((err) => {
2696
+ emitDebug(
2697
+ "relay.session",
2698
+ "session_broadcast_failed",
2699
+ "debug",
2700
+ { sessionKey: sessionService.peekSessionKey() || undefined },
2701
+ () => ({ message: err && err.message ? err.message : String(err) }),
2702
+ );
2703
+ });
2704
+ }
2705
+
2706
+ /**
2707
+ * Resolve the current Even AI sessions snapshot for unicast/broadcast.
2708
+ * Mirrors the shape that `formatEvenAiSessions` expects.
2709
+ */
2710
+ async function buildEvenAiSessionsSnapshot() {
2711
+ const dedicatedKey =
2712
+ evenAiRouter && typeof evenAiRouter.getDedicatedSessionKey === "function"
2713
+ ? evenAiRouter.getDedicatedSessionKey()
2714
+ : opts.evenAiDedicatedSessionKey;
2715
+ const dedicatedEvenAiKey = normalizeEvenAiSessionKeyForLookup(dedicatedKey);
2716
+ const trackedThrowawayKeys =
2717
+ typeof evenAiSettingsStore.getTrackedThrowawayKeys === "function"
2718
+ ? evenAiSettingsStore.getTrackedThrowawayKeys()
2719
+ : [];
2720
+ const normalizedTrackedThrowawayKeys = dedupeNormalizedSessionKeys(
2721
+ trackedThrowawayKeys,
2722
+ );
2723
+ const resolvedSessions = await sessionService.getSessionsByExactKeys([
2724
+ ...normalizedTrackedThrowawayKeys,
2725
+ ...(dedicatedEvenAiKey ? [dedicatedEvenAiKey] : []),
2726
+ ]);
2727
+ const normalizedDedicatedKey = dedicatedEvenAiKey.toLowerCase();
2728
+ const sessions = [];
2729
+ let dedicatedIncluded = false;
2730
+ for (const session of resolvedSessions) {
2731
+ if (
2732
+ !dedicatedIncluded &&
2733
+ session &&
2734
+ typeof session.key === "string" &&
2735
+ session.key.trim().toLowerCase() === normalizedDedicatedKey
2736
+ ) {
2737
+ sessions.push(session);
2738
+ dedicatedIncluded = true;
2739
+ continue;
2740
+ }
2741
+ sessions.push(session);
2742
+ }
2743
+ if (!dedicatedIncluded && dedicatedEvenAiKey) {
2744
+ sessions.unshift({
2745
+ key: dedicatedEvenAiKey,
2746
+ updatedAt: 0,
2747
+ preview: "",
2748
+ firstUserMessage: "",
2749
+ });
2750
+ }
2751
+ return { sessions, dedicatedKey };
2752
+ }
2753
+
2754
+ function broadcastEvenAiSessions() {
2755
+ if (!server) return;
2756
+ buildEvenAiSessionsSnapshot()
2757
+ .then((payload) => {
2758
+ server.broadcast(handler.formatEvenAiSessions(payload));
2759
+ })
2760
+ .catch((err) => {
2761
+ emitDebug(
2762
+ "relay.session",
2763
+ "session_broadcast_failed",
2764
+ "debug",
2765
+ { sessionKey: sessionService.peekSessionKey() || undefined },
2766
+ () => ({
2767
+ kind: "evenai",
2768
+ message: err && err.message ? err.message : String(err),
2769
+ }),
2770
+ );
2771
+ });
2772
+ }
2773
+
1927
2774
  /**
1928
2775
  * Build, cache, and broadcast the current status.
1929
2776
  */
@@ -1932,10 +2779,24 @@ function createRelay(opts) {
1932
2779
  if (next !== null) {
1933
2780
  server.broadcast(next);
1934
2781
  }
2782
+ if (server && typeof server.notifyAgentAvatarChanged === "function") {
2783
+ const hash =
2784
+ upstreamRuntime && typeof upstreamRuntime.getAgentAvatarHash === "function"
2785
+ ? upstreamRuntime.getAgentAvatarHash()
2786
+ : null;
2787
+ const dataUri =
2788
+ hash &&
2789
+ upstreamRuntime &&
2790
+ typeof upstreamRuntime.getAgentAvatarDataUriByHash === "function"
2791
+ ? upstreamRuntime.getAgentAvatarDataUriByHash(hash)
2792
+ : null;
2793
+ server.notifyAgentAvatarChanged(hash, dataUri);
2794
+ }
1935
2795
  }
1936
2796
 
1937
2797
  upstreamRuntime = createUpstreamRuntime({
1938
2798
  logger,
2799
+ stateDir: opts.stateDir,
1939
2800
  gatewayBridge,
1940
2801
  conversationState,
1941
2802
  sessionService,
@@ -1944,6 +2805,11 @@ function createRelay(opts) {
1944
2805
  broadcastPages,
1945
2806
  broadcastStatus,
1946
2807
  broadcastActivity,
2808
+ broadcastProviderUsageSnapshot,
2809
+ operationRegistry: relayOperationRegistry,
2810
+ getCurrentSessionModelConfigSnapshot() {
2811
+ return currentSessionModelConfigSnapshot;
2812
+ },
1947
2813
  resetActivityStatusAdapter,
1948
2814
  modelsCacheTtlMs: opts.modelsCacheTtlMs,
1949
2815
  getServer() {
@@ -1952,8 +2818,39 @@ function createRelay(opts) {
1952
2818
  getVoiceRuntime() {
1953
2819
  return null;
1954
2820
  },
2821
+ gatewayUrl: opts.gatewayUrl,
2822
+ gatewayToken: opts.gatewayToken,
2823
+ fetchAgentAvatar: opts.fetchAgentAvatar,
1955
2824
  });
1956
2825
 
2826
+ // Shared routing gate for session-scoped Even AI defaults (thinking seed,
2827
+ // fast-mode patch): never touch active-routed sessions; always seed fresh
2828
+ // background_new sessions; seed persistent background sessions only before
2829
+ // their first turn exists.
2830
+ async function shouldSeedSessionScopedDefaultForRoute(route) {
2831
+ const routingMode =
2832
+ route && typeof route.routingMode === "string"
2833
+ ? route.routingMode.trim().toLowerCase()
2834
+ : "active";
2835
+ const sessionKey =
2836
+ route && typeof route.sessionKey === "string" ? route.sessionKey.trim() : "";
2837
+ if (!sessionKey || routingMode === "active") {
2838
+ return false;
2839
+ }
2840
+ if (routingMode === "background_new") {
2841
+ return true;
2842
+ }
2843
+ if (routingMode !== "background") {
2844
+ return false;
2845
+ }
2846
+ try {
2847
+ const existingSessions = await sessionService.getSessionsByExactKeys([sessionKey]);
2848
+ return existingSessions.length === 0;
2849
+ } catch {
2850
+ return false;
2851
+ }
2852
+ }
2853
+
1957
2854
  if (opts.evenAiEnabled === true) {
1958
2855
  evenAiRouter = createEvenAiRouter({
1959
2856
  sessionService,
@@ -1971,7 +2868,7 @@ function createRelay(opts) {
1971
2868
  logger,
1972
2869
  httpServer: sharedHttpServer,
1973
2870
  enabled: true,
1974
- externallyRouted: opts.evenAiExternalHttpRouting === true,
2871
+ externallyRouted: true,
1975
2872
  token: opts.evenAiToken,
1976
2873
  getSettingsSnapshot() {
1977
2874
  return evenAiSettingsStore.getSnapshot();
@@ -1992,6 +2889,9 @@ function createRelay(opts) {
1992
2889
  emitListenInterceptRecovery(params) {
1993
2890
  return emitListenInterceptRecovery(params);
1994
2891
  },
2892
+ emitListenInterceptBroadcast(params) {
2893
+ return emitListenInterceptBroadcast(params);
2894
+ },
1995
2895
  hasConnectedAppClient() {
1996
2896
  return server ? server.getConnectedAppCount() > 0 : false;
1997
2897
  },
@@ -2011,31 +2911,29 @@ function createRelay(opts) {
2011
2911
  },
2012
2912
  async shouldSeedThinkingForRoute(params) {
2013
2913
  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
2914
  const thinkingLevel =
2019
2915
  params && typeof params.thinkingLevel === "string"
2020
2916
  ? params.thinkingLevel.trim().toLowerCase()
2021
2917
  : "";
2022
- const sessionKey =
2023
- route && typeof route.sessionKey === "string" ? route.sessionKey.trim() : "";
2024
- if (!thinkingLevel || !sessionKey || routingMode === "active") {
2918
+ if (!thinkingLevel) {
2025
2919
  return false;
2026
2920
  }
2027
- if (routingMode === "background_new") {
2028
- return true;
2029
- }
2030
- if (routingMode !== "background") {
2921
+ return shouldSeedSessionScopedDefaultForRoute(route);
2922
+ },
2923
+ async seedFastModeForRoute(params) {
2924
+ const route = params && params.route ? params.route : params;
2925
+ const settings = evenAiSettingsStore.getSnapshot();
2926
+ if (!settings || settings.defaultFastMode !== true) {
2031
2927
  return false;
2032
2928
  }
2033
- try {
2034
- const existingSessions = await sessionService.getSessionsByExactKeys([sessionKey]);
2035
- return existingSessions.length === 0;
2036
- } catch {
2929
+ if (!(await shouldSeedSessionScopedDefaultForRoute(route))) {
2037
2930
  return false;
2038
2931
  }
2932
+ const result = await sessionService.setSessionModelConfig(
2933
+ route.sessionKey.trim(),
2934
+ { fastMode: true },
2935
+ );
2936
+ return !!(result && result.status === "accepted");
2039
2937
  },
2040
2938
  onSessionActivated(route) {
2041
2939
  if (!route || !route.sessionChanged) {
@@ -2052,15 +2950,70 @@ function createRelay(opts) {
2052
2950
  });
2053
2951
  }
2054
2952
 
2953
+ async function handleBufferedEvenAiHttpRequest(envelope) {
2954
+ if (!evenAiEndpoint || typeof evenAiEndpoint.handleRequest !== "function") {
2955
+ return {
2956
+ statusCode: 404,
2957
+ headers: { "content-type": "text/plain; charset=utf-8" },
2958
+ body: Buffer.from("not found"),
2959
+ };
2960
+ }
2961
+ const req = createBufferedHttpRequest(envelope);
2962
+ const res = createBufferedHttpResponse(opts.evenAiMaxResponseBytes || 262_144);
2963
+ const requestId =
2964
+ envelope && typeof envelope.requestId === "string" ? envelope.requestId : null;
2965
+ if (requestId) {
2966
+ pendingBufferedEvenAiResponses.set(requestId, { req, res });
2967
+ }
2968
+ try {
2969
+ await Promise.resolve(evenAiEndpoint.handleRequest(req, res));
2970
+ if (!res.writableEnded) {
2971
+ res.statusCode = 404;
2972
+ res.setHeader("content-type", "text/plain; charset=utf-8");
2973
+ res.end("not found");
2974
+ }
2975
+ return res.toResult();
2976
+ } finally {
2977
+ if (requestId) {
2978
+ pendingBufferedEvenAiResponses.delete(requestId);
2979
+ }
2980
+ }
2981
+ }
2982
+
2983
+ function cancelBufferedEvenAiHttpRequest(envelope) {
2984
+ const requestId =
2985
+ envelope && typeof envelope.requestId === "string" ? envelope.requestId : null;
2986
+ if (!requestId) {
2987
+ return false;
2988
+ }
2989
+ const pending = pendingBufferedEvenAiResponses.get(requestId);
2990
+ if (!pending) {
2991
+ return false;
2992
+ }
2993
+ pending.res.emit("close");
2994
+ pending.req.emit("close");
2995
+ return true;
2996
+ }
2997
+
2055
2998
  // --- Public API ---
2056
2999
 
2057
- return {
3000
+ relayApi = {
3001
+ /**
3002
+ * Emit a glasses-UI surface-lifecycle event on the permanent
3003
+ * `glasses.lifecycle` debug category (nav reconcile + cron pause/resume/
3004
+ * tick). Recorded only when the category is enabled via debug-set. Wired
3005
+ * through the relay-service facade into the glasses-ui tool handler + cron
3006
+ * engine. See docs/superpowers/findings/2026-05-30-glasses-ui-phase4-hardware.md.
3007
+ */
3008
+ emitGlassesUiLifecycle(event, severity, data) {
3009
+ emitDebug("glasses.lifecycle", event, severity, {}, () => data || {});
3010
+ },
2058
3011
  /**
2059
3012
  * Start the upstream OpenClaw connection.
2060
3013
  * The downstream server is already listening from construction.
2061
- */
3014
+ */
2062
3015
  start() {
2063
- return Promise.resolve(gatewayBridge.start()).then(() => {
3016
+ const startGateway = () => Promise.resolve(gatewayBridge.start()).then(() => {
2064
3017
  prefetchSonioxModels("relay_start").catch((err) => {
2065
3018
  logger.warn(`[relay] Soniox models prefetch failed: ${err.message}`);
2066
3019
  });
@@ -2068,6 +3021,10 @@ function createRelay(opts) {
2068
3021
  return upstreamRuntime.start();
2069
3022
  }
2070
3023
  });
3024
+ if (server && typeof server.start === "function") {
3025
+ return Promise.resolve(server.start()).then(startGateway);
3026
+ }
3027
+ return startGateway();
2071
3028
  },
2072
3029
 
2073
3030
  /**
@@ -2086,10 +3043,12 @@ function createRelay(opts) {
2086
3043
  if (upstreamRuntime) {
2087
3044
  upstreamRuntime.stop();
2088
3045
  }
3046
+ relayHealth.stop();
2089
3047
  gatewayBridge.stop();
2090
- return Promise.resolve(server.close()).then(() =>
2091
- closeOwnedRelayHttpServer(ownedHttpServer),
2092
- );
3048
+ return Promise.all([
3049
+ sessionService.flushFirstSentUserMessageCache(),
3050
+ Promise.resolve(server.close()),
3051
+ ]).then(() => undefined);
2093
3052
  },
2094
3053
 
2095
3054
  handleEvenAiHttpRequest(req, res) {
@@ -2099,11 +3058,37 @@ function createRelay(opts) {
2099
3058
  return Promise.resolve(evenAiEndpoint.handleRequest(req, res));
2100
3059
  },
2101
3060
 
3061
+ handleBufferedEvenAiHttpRequest,
3062
+
2102
3063
  /** The downstream server instance. */
2103
3064
  get server() {
2104
3065
  return server;
2105
3066
  },
2106
3067
 
3068
+ get workerReadyForTest() {
3069
+ return server && server.readyPromise ? server.readyPromise : Promise.resolve();
3070
+ },
3071
+
3072
+ get debugStoreForTest() {
3073
+ return debugStore;
3074
+ },
3075
+
3076
+ get liveUiTraceLogEnabledForTest() {
3077
+ return liveUiTraceLogEnabled;
3078
+ },
3079
+ __onTraceLogSetForTest(clientId, request) {
3080
+ return applyTraceLogSet(clientId, request);
3081
+ },
3082
+ __onDebugSetForTest(clientId, request) {
3083
+ return applyDebugSet(clientId, request);
3084
+ },
3085
+
3086
+ get operationRegistryForTest() {
3087
+ return relayOperationRegistry;
3088
+ },
3089
+
3090
+ relayHealth,
3091
+
2107
3092
  get httpServer() {
2108
3093
  return sharedHttpServer;
2109
3094
  },
@@ -2111,7 +3096,90 @@ function createRelay(opts) {
2111
3096
  getEvenAiSettingsSnapshot() {
2112
3097
  return evenAiSettingsStore.getSnapshot();
2113
3098
  },
3099
+
3100
+ getSessionTitle(sessionKey) {
3101
+ return sessionService.getSessionTitle(sessionKey);
3102
+ },
3103
+
3104
+ hasRecordedUserMessage(sessionKey) {
3105
+ return sessionService.hasRecordedFirstUserMessage(sessionKey);
3106
+ },
3107
+
3108
+ isNeuralSessionNamesEnabled(sessionKey) {
3109
+ return sessionService.isNeuralSessionNamesEnabled(sessionKey);
3110
+ },
3111
+
3112
+ isSessionUserLocked(sessionKey) {
3113
+ return sessionService.isSessionUserLocked(sessionKey);
3114
+ },
3115
+
3116
+ peekSessionKey() {
3117
+ return sessionService.peekSessionKey();
3118
+ },
3119
+
3120
+ /**
3121
+ * Test/shutdown hook: resolves once the async first-user-message cache
3122
+ * write has fully drained (no write in flight, no dirty mark pending) so
3123
+ * the on-disk file reflects the latest in-memory map.
3124
+ */
3125
+ flushFirstSentUserMessageCache() {
3126
+ return sessionService.flushFirstSentUserMessageCache();
3127
+ },
3128
+
3129
+ recordNeuralSessionNamesEnabled(sessionKey, enabled) {
3130
+ sessionService.recordNeuralSessionNamesEnabled(sessionKey, enabled);
3131
+ },
3132
+
3133
+ setSessionTitle(sessionKey, title, opts) {
3134
+ const result = sessionService.setSessionTitle(sessionKey, title, opts);
3135
+ if (result && result.ok) {
3136
+ broadcastSessions();
3137
+ }
3138
+ return result;
3139
+ },
3140
+
3141
+ /**
3142
+ * Test-only: direct access to dispatchOcuClawUserSend so integration
3143
+ * tests can drive per-turn signal plumbing without a live downstream
3144
+ * WebSocket connection.
3145
+ */
3146
+ _dispatchOcuClawUserSend(params) {
3147
+ return dispatchOcuClawUserSend(params || {});
3148
+ },
3149
+
3150
+ sendGlassesUiRender(params) {
3151
+ sendGlassesUiRender(params);
3152
+ },
3153
+
3154
+ sendGlassesUiSurfaceUpdate(params) {
3155
+ sendGlassesUiSurfaceUpdate(params);
3156
+ },
3157
+
3158
+ onGlassesUiResult(handler) {
3159
+ return onGlassesUiResult(handler);
3160
+ },
3161
+
3162
+ onGlassesUiNavEvent(handler) {
3163
+ return onGlassesUiNavEvent(handler);
3164
+ },
3165
+
3166
+ sendDeviceInfoRequest(params) {
3167
+ sendDeviceInfoRequest(params);
3168
+ },
3169
+
3170
+ onDeviceInfoResponse(handler) {
3171
+ return onDeviceInfoResponse(handler);
3172
+ },
3173
+
3174
+ hasConnectedAppClient() {
3175
+ return server ? server.getConnectedAppCount() > 0 : false;
3176
+ },
3177
+
3178
+ onAppClientDisconnect(handler) {
3179
+ return onAppClientDisconnect(handler);
3180
+ },
2114
3181
  };
3182
+ return relayApi;
2115
3183
  }
2116
3184
 
2117
3185
  const createRelayCore = createRelay;