ocuclaw 1.3.3 → 1.3.4

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 (83) hide show
  1. package/README.md +29 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +0 -3
  3. package/dist/config/runtime-config.js +22 -33
  4. package/dist/domain/activity-status-adapter.js +0 -7
  5. package/dist/domain/activity-status-arbiter.js +3 -27
  6. package/dist/domain/activity-status-labels.js +8 -38
  7. package/dist/domain/code-span-regions.js +4 -24
  8. package/dist/domain/constant-time-equal.js +9 -0
  9. package/dist/domain/constant-time-equal.test.js +28 -0
  10. package/dist/domain/conversation-state.js +27 -138
  11. package/dist/domain/debug-bundle-cache.js +52 -0
  12. package/dist/domain/debug-bundle-format.js +60 -0
  13. package/dist/domain/debug-bundle-preview.js +123 -0
  14. package/dist/domain/debug-bundle-redaction.js +182 -0
  15. package/dist/domain/debug-bundle-save.js +11 -0
  16. package/dist/domain/debug-bundle-zip.js +15 -0
  17. package/dist/domain/debug-bundle.js +97 -0
  18. package/dist/domain/debug-store.js +6 -17
  19. package/dist/domain/debug-upload-preset.js +27 -0
  20. package/dist/domain/glasses-display-system-prompt.js +0 -5
  21. package/dist/domain/glasses-display-system-prompt.test.js +1 -1
  22. package/dist/domain/glasses-ui-content-summary.js +0 -6
  23. package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
  24. package/dist/domain/message-emoji-allowlist.js +0 -7
  25. package/dist/domain/message-emoji-filter.js +3 -9
  26. package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
  27. package/dist/domain/prompt-channel-fragments.js +1 -10
  28. package/dist/domain/tagged-span-parser.js +3 -26
  29. package/dist/domain/tagged-span-strip.js +0 -7
  30. package/dist/even-ai/even-ai-endpoint.js +77 -24
  31. package/dist/even-ai/even-ai-run-waiter.js +0 -1
  32. package/dist/even-ai/even-ai-settings-store.js +11 -0
  33. package/dist/gateway/gateway-bridge.js +8 -9
  34. package/dist/gateway/gateway-timing-ledger.js +8 -6
  35. package/dist/gateway/openclaw-client.js +97 -297
  36. package/dist/gateway/sanitize-connect-reason.js +10 -0
  37. package/dist/gateway/sanitize-connect-reason.test.js +34 -0
  38. package/dist/index.js +3 -3
  39. package/dist/runtime/channel-two-hook.js +1 -6
  40. package/dist/runtime/container-env.js +1 -5
  41. package/dist/runtime/debug-bundle-handler.js +159 -0
  42. package/dist/runtime/display-toggle-states.js +6 -17
  43. package/dist/runtime/downstream-handler.js +682 -508
  44. package/dist/runtime/glasses-backpressure-latch.js +2 -24
  45. package/dist/runtime/ocuclaw-settings-store.js +10 -1
  46. package/dist/runtime/openclaw-host-version.js +5 -0
  47. package/dist/runtime/plugin-version-service.js +13 -6
  48. package/dist/runtime/provider-usage-select.js +0 -6
  49. package/dist/runtime/register-session-title-distiller.js +14 -16
  50. package/dist/runtime/relay-core.js +601 -290
  51. package/dist/runtime/relay-service.js +19 -47
  52. package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
  53. package/dist/runtime/relay-worker-entry.js +1 -2
  54. package/dist/runtime/relay-worker-health.js +2 -10
  55. package/dist/runtime/relay-worker-protocol.js +6 -1
  56. package/dist/runtime/relay-worker-supervisor.js +103 -41
  57. package/dist/runtime/relay-worker-transport.js +150 -17
  58. package/dist/runtime/session-context-service.js +5 -45
  59. package/dist/runtime/session-service.js +157 -175
  60. package/dist/runtime/session-title-distiller-budget.js +1 -5
  61. package/dist/runtime/session-title-distiller-helpers.js +14 -24
  62. package/dist/runtime/session-title-distiller.js +109 -122
  63. package/dist/runtime/session-title-record.js +0 -6
  64. package/dist/runtime/stable-prompt-snapshot.js +3 -14
  65. package/dist/runtime/upstream-runtime.js +600 -103
  66. package/dist/tools/device-info-tool.js +4 -21
  67. package/dist/tools/glasses-ui-cron.js +22 -77
  68. package/dist/tools/glasses-ui-descriptors.js +4 -33
  69. package/dist/tools/glasses-ui-limits.js +0 -13
  70. package/dist/tools/glasses-ui-paint-floor.js +5 -39
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +31 -163
  73. package/dist/tools/glasses-ui-template.js +7 -22
  74. package/dist/tools/glasses-ui-tool-description.test.js +2 -2
  75. package/dist/tools/glasses-ui-tool.js +87 -451
  76. package/dist/tools/glasses-ui-voicemail.js +6 -63
  77. package/dist/tools/glasses-ui-wake.js +9 -76
  78. package/dist/tools/session-title-tool.js +2 -7
  79. package/dist/tools/session-title-tool.test.js +1 -1
  80. package/dist/version.js +3 -2
  81. package/openclaw.plugin.json +60 -13
  82. package/package.json +3 -2
  83. package/dist/runtime/protocol-adapter.js +0 -387
@@ -1,9 +1,12 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import * as crypto from "node:crypto";
4
+ import * as os from "node:os";
3
5
  import { EventEmitter } from "node:events";
4
6
  import { createPluginVersionService } from "./plugin-version-service.js";
5
7
  import * as conversationStateModule from "../domain/conversation-state.js";
6
8
  import { createDebugStore } from "../domain/debug-store.js";
9
+ import { startUploadCaptureArming, UPLOAD_CAPTURE_PRESET } from "../domain/debug-upload-preset.js";
7
10
  import { summarizeGlassesUiContent } from "../domain/glasses-ui-content-summary.js";
8
11
  import { composeReadabilitySystemPrompt } from "../domain/readability-system-prompt.js";
9
12
  import { composeNeuralEmojiReactorSystemPrompt } from "../domain/neural-emoji-reactor-system-prompt.js";
@@ -15,12 +18,21 @@ import { createActivityStatusAdapter } from "../domain/activity-status-adapter.j
15
18
  import { createEvenAiEndpoint } from "../even-ai/even-ai-endpoint.js";
16
19
  import { createEvenAiRouter } from "../even-ai/even-ai-router.js";
17
20
  import { createEvenAiRunWaiter } from "../even-ai/even-ai-run-waiter.js";
18
- import { createEvenAiSettingsStore } from "../even-ai/even-ai-settings-store.js";
21
+ import {
22
+ createEvenAiSettingsStore,
23
+ normalizeEvenAiDefaultAgent,
24
+ } from "../even-ai/even-ai-settings-store.js";
19
25
  import { createPluginOpenclawClient } from "../gateway/openclaw-client.js";
20
26
  import { createPluginRpcGatewayBridge } from "../gateway/gateway-bridge.js";
21
27
  import { createAgentTurnTracker } from "../tools/glasses-ui-wake.js";
22
28
  import { createDownstreamHandler } from "./downstream-handler.js";
23
- import { createOcuClawSettingsStore } from "./ocuclaw-settings-store.js";
29
+ import { handleDebugBundleRequest, handleDebugBundleSave, handleDebugBundleFetch } from "./debug-bundle-handler.js";
30
+ import { createBundleCache } from "../domain/debug-bundle-cache.js";
31
+ import { saveBundleToDisk } from "../domain/debug-bundle-save.js";
32
+ import {
33
+ createOcuClawSettingsStore,
34
+ normalizeOcuClawDefaultAgent,
35
+ } from "./ocuclaw-settings-store.js";
24
36
  import { createRelayHealthMonitor } from "./relay-health-monitor.js";
25
37
  import { createGlassesBackpressureLatch } from "./glasses-backpressure-latch.js";
26
38
  import { createRelayOperationRegistry } from "./relay-operation-registry.js";
@@ -37,10 +49,12 @@ export function sanitizeGlassesMarker(v) { return GLASSES_UI_MARKERS.has(v) ? v
37
49
  const SONIOX_TEMP_KEY_URL = "https://api.soniox.com/v1/auth/temporary-api-key";
38
50
  const SONIOX_MODELS_URL = "https://api.soniox.com/v1/models";
39
51
  const DEFAULT_SONIOX_TEMP_KEY_EXPIRES_IN_SECONDS = 3600;
40
- // Maximum time (ms) to wait for a Soniox temp-key mint before aborting the
41
- // fetch. 8 s is a conservative cold-path ceiling; tests inject a tiny value
42
- // via opts.sonioxTemporaryKeyMintTimeoutMs to make assertions fast.
52
+
43
53
  const DEFAULT_SONIOX_TEMP_KEY_MINT_TIMEOUT_MS = 8000;
54
+ const CARTESIA_ACCESS_TOKEN_URL = "https://api.cartesia.ai/access-token";
55
+ const CARTESIA_VERSION = "2026-03-01";
56
+ const DEFAULT_CARTESIA_ACCESS_TOKEN_EXPIRES_IN_SECONDS = 3600;
57
+ const DEFAULT_CARTESIA_ACCESS_TOKEN_MINT_TIMEOUT_MS = 8000;
44
58
  const EVEN_AI_NAMESPACE_PREFIX = "ocuclaw:even-ai";
45
59
  const EVEN_AI_NAMESPACE_PREFIX_WITH_DELIMITER = "ocuclaw:even-ai:";
46
60
  const LISTEN_INTERCEPT_RECOVERY_ERROR = "Voice interrupted; retry";
@@ -179,7 +193,7 @@ function normalizeSonioxTemporaryKeyErrorCode(err) {
179
193
  : "";
180
194
  const lowered = message.toLowerCase();
181
195
  if (!message) return "soniox_temp_key_request_failed";
182
- // AbortError from the per-fetch timeout AbortController.
196
+
183
197
  if (err && err.name === "AbortError") {
184
198
  return "soniox_temp_key_mint_timeout";
185
199
  }
@@ -261,9 +275,7 @@ function createBufferedHttpResponse(maxResponseBytes) {
261
275
  const limit = Number.isFinite(maxResponseBytes) && maxResponseBytes > 0
262
276
  ? Math.floor(maxResponseBytes)
263
277
  : 262_144;
264
- // EventEmitter shape: handlers like the Even-AI endpoint subscribe to
265
- // res.once('close', ...) for client-disconnect detection. Worker-mode
266
- // relays actual client closes through an http.cancel worker message.
278
+
267
279
  const res = new EventEmitter();
268
280
  res.statusCode = 200;
269
281
  res.writableEnded = false;
@@ -301,29 +313,18 @@ function createBufferedHttpResponse(maxResponseBytes) {
301
313
  return res;
302
314
  }
303
315
 
304
- // --- Factory ---
305
-
306
- /**
307
- * Create the relay orchestrator.
308
- *
309
- * Wires the upstream gateway bridge to downstream clients via the
310
- * conversation-state module, downstream handler, and downstream server.
311
- * This is the only module that knows about both sides.
312
- *
313
- * @param {object} opts
314
- * @param {number} opts.port - WebSocket server port
315
- * @param {string} opts.host - WebSocket server bind address
316
- * @param {string} opts.token - Authentication token for downstream clients
317
- * @param {object} [opts.gatewayBridge] - Override bridge for testing/integration
318
- * @param {object} [opts.openclawClient] - Override for testing (default: plugin gateway client)
319
- * @param {object} [opts.conversationState] - Override for testing (default: require singleton)
320
- * @param {object} [opts.logger] - Structured logger for shared runtime logs
321
- * @param {string|null} [opts.consoleLogPath] - Optional shim-only browser console sink path
322
- * @returns {object} Relay instance with start(), stop(), server
323
- */
324
316
  function createRelay(opts) {
325
317
  const logger = normalizeLogger(opts.logger);
326
318
  const externalDebugToolsEnabled = opts.externalDebugToolsEnabled !== false;
319
+
320
+ const allowDebugUpload = opts.allowDebugUpload === true;
321
+
322
+ const debugUploadMaxZipBytes =
323
+ Number.isFinite(opts.debugUploadMaxZipBytes) && opts.debugUploadMaxZipBytes > 0
324
+ ? Math.floor(opts.debugUploadMaxZipBytes)
325
+ : 4_000_000;
326
+
327
+ const debugBundleIdSalt = crypto.randomBytes(16).toString("hex");
327
328
  const openclawClient =
328
329
  opts.openclawClient ||
329
330
  (opts.gatewayBridge
@@ -347,36 +348,23 @@ function createRelay(opts) {
347
348
  const activityStatusAdapter = createActivityStatusAdapter(
348
349
  opts.activityStatusAdapter,
349
350
  );
350
- // Per-session "agent turn in flight or imminent" signal (roadmap 6f):
351
- // marked busy on every dispatched send (voice/user/wake), refreshed by the
352
- // gateway activity stream, idled on end-phase activity, decay-bounded
353
- // (fail open). The glasses-ui wake controller consults it so a wake never
354
- // races a genuine turn (voice absorbs wake, §2.6c).
351
+
355
352
  const agentTurnTracker = createAgentTurnTracker();
356
353
  const sharedHttpServer = opts.httpServer || null;
357
354
 
358
- // --- Cached state ---
359
-
360
- /** @type {string|null} Last formatted pages JSON string. */
361
355
  let cachedPages = null;
362
- /** Monotonic pages snapshot revision used for resume handshake. */
356
+
363
357
  let pagesRevision = 0;
364
358
 
365
- /** @type {string|null} Last formatted status JSON string. */
366
359
  let cachedStatus = null;
367
- /** Monotonic status snapshot revision used for resume handshake. */
360
+
368
361
  let statusRevision = 0;
369
- /** @type {{sessionKey: string, modelProvider: string|null, model: string|null, thinkingLevel: string, reasoningLevel: string, verboseLevel: string}|null} */
362
+
370
363
  let currentSessionModelConfigSnapshot = null;
371
364
 
372
- /** Relay-local deterministic simulate-stream run sequence counter. */
373
365
  let simulateStreamRunSeq = 0;
374
- /** Active timers for relay-local deterministic simulate-stream runs. */
375
- // timer -> sessionKey, so new-chat//reset/new-session can cancel ONLY the
376
- // affected session's pending injections (re-land of a8a29032, session-scoped).
377
- const simulateStreamTimers = new Map();
378
366
 
379
- // --- Structured debug state ---
367
+ const simulateStreamTimers = new Map();
380
368
 
381
369
  const debugCategories = Array.isArray(opts.debugCategories)
382
370
  ? opts.debugCategories
@@ -385,21 +373,10 @@ function createRelay(opts) {
385
373
  .filter(([, enabled]) => enabled)
386
374
  .map(([category]) => category)
387
375
  : opts.debugCategories;
388
- // Single relay-side clock: the debug store, the emitDebug ts stamp, and the
389
- // liveui log tee all read from this one source so store records and `[liveui]`
390
- // log lines share an identical ts (downstream reconcilers dedupe on it).
376
+
391
377
  const debugNow =
392
378
  typeof opts.debugNow === "function" ? opts.debugNow : () => Date.now();
393
379
 
394
- // --- Durable debug-store arm (survives relay/gateway restarts) ---
395
- // The capture arm (enabled categories + TTLs) lives only in the in-memory
396
- // debug-store, which a restart rebuilds empty. We persist it to debug-arm.json
397
- // (via persistDebugArm) and rehydrate it here at construction — read-once,
398
- // mirroring liveUiTraceFlagPath below — so a restart no longer silently drops
399
- // capture. This path covers process RESTART only: a pure WebUI *reload* does NOT
400
- // restart the relay and already preserves + re-advertises the arm via
401
- // relay-worker-transport.ts:327-328 (cache.debugConfig re-broadcast to app
402
- // clients) — do not add reconnect machinery here.
403
380
  const debugArmStatePath =
404
381
  typeof opts.stateDir === "string" && opts.stateDir
405
382
  ? path.join(opts.stateDir, "debug-arm.json")
@@ -418,7 +395,6 @@ function createRelay(opts) {
418
395
  const debugStore = createDebugStore({
419
396
  categories: debugCategories,
420
397
  capacity: opts.debugCapacity,
421
- payloadMaxBytes: opts.debugPayloadMaxBytes,
422
398
  defaultTtlMs: opts.debugDefaultTtlMs,
423
399
  maxTtlMs: opts.debugMaxTtlMs,
424
400
  dumpDefaultLimit: opts.debugDumpDefaultLimit,
@@ -428,10 +404,14 @@ function createRelay(opts) {
428
404
  initialEnabled: initialDebugArm,
429
405
  });
430
406
 
431
- // --- Live-interface trace-log flag (durable across restarts) ---
432
- // Gates the glasses.lifecycle → gateway-log tee. Read once at construction;
433
- // toggled live by applyTraceLogSet, which also rewrites this file so the
434
- // value survives a relay/gateway restart (the store's enable-state does not).
407
+ const bundleCache = createBundleCache({ maxEntries: 4, ttlMs: 5 * 60_000, now: () => Date.now() });
408
+ let bundleCacheSweepTimer = null;
409
+
410
+ function resolveSaveDir() {
411
+ const c = opts.debugBundleSaveDir;
412
+ return (typeof c === "string" && c.trim()) ? c : path.join(os.homedir(), ".openclaw", "ocuclaw-debug-bundles");
413
+ }
414
+
435
415
  const liveUiTraceFlagPath =
436
416
  typeof opts.stateDir === "string" && opts.stateDir
437
417
  ? path.join(opts.stateDir, "liveui-trace.json")
@@ -446,13 +426,11 @@ function createRelay(opts) {
446
426
  }
447
427
  }
448
428
 
449
- // --- Console log file ---
450
-
451
429
  const consoleLogPath =
452
430
  typeof opts.consoleLogPath === "string" && opts.consoleLogPath.trim()
453
431
  ? opts.consoleLogPath
454
432
  : null;
455
- // Clear the shim-only browser console sink on startup.
433
+
456
434
  if (consoleLogPath) {
457
435
  try {
458
436
  fs.writeFileSync(consoleLogPath, "");
@@ -461,13 +439,6 @@ function createRelay(opts) {
461
439
  const CONSOLE_LOG_MAX_LINES = 500;
462
440
  const CONSOLE_LOG_TRIM_TO = 250;
463
441
 
464
- /**
465
- * Append a browser console message to the log file.
466
- * Trims the file when it exceeds CONSOLE_LOG_MAX_LINES.
467
- *
468
- * @param {string} level - "log", "warn", or "error"
469
- * @param {string} message - Console message text
470
- */
471
442
  function writeConsoleLog(level, message) {
472
443
  if (!consoleLogPath) {
473
444
  logger.debug(`[browser:${level}] ${message}`);
@@ -477,7 +448,7 @@ function createRelay(opts) {
477
448
  const line = `[${timestamp}] [${level}] ${message}\n`;
478
449
  try {
479
450
  fs.appendFileSync(consoleLogPath, line);
480
- // Trim if too large
451
+
481
452
  const content = fs.readFileSync(consoleLogPath, "utf8");
482
453
  const lines = content.split("\n");
483
454
  if (lines.length > CONSOLE_LOG_MAX_LINES) {
@@ -489,16 +460,6 @@ function createRelay(opts) {
489
460
  }
490
461
  }
491
462
 
492
- /**
493
- * Emit a structured debug event when a category is enabled.
494
- * This keeps the disabled path cheap by avoiding payload construction.
495
- *
496
- * @param {string} cat
497
- * @param {string} event
498
- * @param {"debug"|"info"|"warn"|"error"} severity
499
- * @param {object} context
500
- * @param {() => object} buildData
501
- */
502
463
  function emitDebug(cat, event, severity, context, buildData, options) {
503
464
  const force = !!(options && options.force === true);
504
465
  if (!force && !debugStore.isEnabled(cat) && !(liveUiTraceLogEnabled && (cat === "glasses.lifecycle" || cat === "openclaw.message"))) {
@@ -523,8 +484,6 @@ function createRelay(opts) {
523
484
 
524
485
  debugStore.emit(payload, { force });
525
486
 
526
- // Durable openclaw-side trace tee (gated by the persistent flag, NOT the
527
- // store category enable). Must never throw into the emit path.
528
487
  if (liveUiTraceLogEnabled && (cat === "glasses.lifecycle" || cat === "openclaw.message")) {
529
488
  try {
530
489
  const surfaceId =
@@ -552,7 +511,7 @@ function createRelay(opts) {
552
511
  }),
553
512
  );
554
513
  } catch {
555
- // observability must never break the emit path
514
+
556
515
  }
557
516
  }
558
517
  }
@@ -620,12 +579,26 @@ function createRelay(opts) {
620
579
  )
621
580
  ? Math.max(1, Math.floor(opts.sonioxTemporaryKeyMintTimeoutMs))
622
581
  : DEFAULT_SONIOX_TEMP_KEY_MINT_TIMEOUT_MS;
623
- /** @type {Array<{id: string, name: string, supportsMaxEndpointDelay: boolean}>|null} */
582
+ const configuredCartesiaApiKey =
583
+ opts.cartesiaApiKey !== undefined
584
+ ? opts.cartesiaApiKey
585
+ : (opts.config && opts.config.cartesiaApiKey) || "";
586
+ const cartesiaAccessTokenExpiresInSeconds = Number.isFinite(
587
+ opts.cartesiaAccessTokenExpiresInSeconds,
588
+ )
589
+ ? Math.max(30, Math.min(3600, Math.floor(opts.cartesiaAccessTokenExpiresInSeconds)))
590
+ : DEFAULT_CARTESIA_ACCESS_TOKEN_EXPIRES_IN_SECONDS;
591
+ const cartesiaAccessTokenMintTimeoutMs = Number.isFinite(
592
+ opts.cartesiaAccessTokenMintTimeoutMs,
593
+ )
594
+ ? Math.max(1, Math.floor(opts.cartesiaAccessTokenMintTimeoutMs))
595
+ : DEFAULT_CARTESIA_ACCESS_TOKEN_MINT_TIMEOUT_MS;
596
+
624
597
  let cachedSonioxModels = null;
625
598
  let cachedSonioxModelsFetchedAt = 0;
626
599
  let cachedSonioxModelsStale = true;
627
600
  let sonioxModelsFetchStarted = false;
628
- /** @type {Promise<{models: Array, fetchedAtMs: number, stale: boolean}>|null} */
601
+
629
602
  let inFlightSonioxModelsFetch = null;
630
603
 
631
604
  function resolveFetchImpl() {
@@ -937,6 +910,138 @@ function createRelay(opts) {
937
910
  }
938
911
  }
939
912
 
913
+ function normalizeCartesiaAccessTokenResult(result, voiceSessionId, nowMs) {
914
+ const accessToken =
915
+ pickTrimmedString(result && (result.accessToken || result.token)) || "";
916
+ if (!accessToken) {
917
+ throw new Error("Cartesia access-token response missing token");
918
+ }
919
+ const expiresInSeconds = Number.isFinite(result && result.expiresInSeconds)
920
+ ? result.expiresInSeconds
921
+ : cartesiaAccessTokenExpiresInSeconds;
922
+ const expiresAtMs = Number.isFinite(result && result.expiresAtMs)
923
+ ? Math.floor(result.expiresAtMs)
924
+ : Math.floor(nowMs + expiresInSeconds * 1000);
925
+ return { voiceSessionId, accessToken, expiresAtMs };
926
+ }
927
+
928
+ async function mintCartesiaAccessToken(clientId, request) {
929
+ const voiceSessionId = pickTrimmedString(request && request.voiceSessionId);
930
+ if (!voiceSessionId) {
931
+ throw new Error("voiceSessionId is required");
932
+ }
933
+ const sessionKey = pickTrimmedString(request && request.sessionKey) || null;
934
+ const nowMs = Date.now();
935
+ const resolvedSessionKey = sessionKey || sessionService.peekSessionKey() || undefined;
936
+ const emitIssued = (normalized, source) => {
937
+ logger.info(
938
+ `[relay] cartesia access token issued: clientId=${clientId} voiceSessionId=${voiceSessionId} source=${source} expiresAtMs=${normalized.expiresAtMs}`,
939
+ );
940
+ emitDebug(
941
+ "voice.timeline",
942
+ "cartesia_access_token_issued",
943
+ "info",
944
+ { sessionKey: resolvedSessionKey },
945
+ () => ({ clientId, voiceSessionId, expiresAtMs: normalized.expiresAtMs, source }),
946
+ );
947
+ return normalized;
948
+ };
949
+
950
+ try {
951
+ if (typeof opts.createCartesiaAccessToken === "function") {
952
+ const overrideResult = await Promise.resolve(
953
+ opts.createCartesiaAccessToken({
954
+ voiceSessionId,
955
+ sessionKey,
956
+ expiresInSeconds: cartesiaAccessTokenExpiresInSeconds,
957
+ }),
958
+ );
959
+ return emitIssued(
960
+ normalizeCartesiaAccessTokenResult(overrideResult || {}, voiceSessionId, nowMs),
961
+ "override",
962
+ );
963
+ }
964
+
965
+ if (!configuredCartesiaApiKey) {
966
+ throw new Error("Cartesia API key is not configured");
967
+ }
968
+ const fetchImpl = resolveFetchImpl();
969
+ if (!fetchImpl) {
970
+ throw new Error("fetch is not available for Cartesia access-token minting");
971
+ }
972
+
973
+ const mintAbortController = new AbortController();
974
+ const mintTimeoutTimer = setTimeout(
975
+ () => mintAbortController.abort(),
976
+ cartesiaAccessTokenMintTimeoutMs,
977
+ );
978
+ let response;
979
+ try {
980
+ response = await fetchImpl(CARTESIA_ACCESS_TOKEN_URL, {
981
+ method: "POST",
982
+ headers: {
983
+ Authorization: `Bearer ${configuredCartesiaApiKey}`,
984
+ "Cartesia-Version": CARTESIA_VERSION,
985
+ "Content-Type": "application/json",
986
+ },
987
+ body: JSON.stringify({
988
+ grants: { stt: true },
989
+ expires_in: cartesiaAccessTokenExpiresInSeconds,
990
+ }),
991
+ signal: mintAbortController.signal,
992
+ });
993
+ } finally {
994
+ clearTimeout(mintTimeoutTimer);
995
+ }
996
+
997
+ const rawText =
998
+ response && typeof response.text === "function" ? await response.text() : "";
999
+ let payload = {};
1000
+ if (rawText) {
1001
+ try {
1002
+ payload = JSON.parse(rawText);
1003
+ } catch (err) {
1004
+ throw new Error(
1005
+ `Cartesia access-token response was not valid JSON (${response.status})`,
1006
+ );
1007
+ }
1008
+ }
1009
+ if (!response.ok) {
1010
+ const errorDetail =
1011
+ pickTrimmedString(
1012
+ payload && payload.message,
1013
+ payload && payload.error,
1014
+ rawText,
1015
+ ) || `HTTP ${response.status}`;
1016
+ throw new Error(
1017
+ `Cartesia access-token request failed (${response.status}): ${tailForLog(errorDetail)}`,
1018
+ );
1019
+ }
1020
+
1021
+ return emitIssued(
1022
+ normalizeCartesiaAccessTokenResult(
1023
+ { token: payload && payload.token },
1024
+ voiceSessionId,
1025
+ nowMs,
1026
+ ),
1027
+ "cartesia_api",
1028
+ );
1029
+ } catch (err) {
1030
+ const message = err && err.message ? err.message : "Cartesia access-token request failed";
1031
+ logger.warn(
1032
+ `[relay] cartesia access token failed: clientId=${clientId} voiceSessionId=${voiceSessionId} message=${tailForLog(message)}`,
1033
+ );
1034
+ emitDebug(
1035
+ "voice.timeline",
1036
+ "cartesia_access_token_failed",
1037
+ "warn",
1038
+ { sessionKey: resolvedSessionKey },
1039
+ () => ({ clientId, voiceSessionId, message: tailForLog(message) }),
1040
+ );
1041
+ throw err;
1042
+ }
1043
+ }
1044
+
940
1045
  let upstreamRuntime = null;
941
1046
  const evenAiSettingsStore = createEvenAiSettingsStore({
942
1047
  logger,
@@ -959,16 +1064,11 @@ function createRelay(opts) {
959
1064
  stateDir: opts.stateDir,
960
1065
  emitDebug,
961
1066
  });
962
- // Hourly TTL sweep for stale stable-prompt snapshots; started in start(),
963
- // cleared in stop(). Declared here so both can see it.
1067
+
964
1068
  let stablePromptSweepTimer = null;
965
1069
 
966
- // Channel 1: the per-session-immutable extraSystemPrompt. Computed once from
967
- // the feature set AT SESSION START and then served byte-identical for the
968
- // session's lifetime (see stable-prompt-snapshot). Mid-session toggles are
969
- // bridged by the Channel-2 hook composer, NOT here. The glasses-UI pointer is
970
- // always present (its disconnected gate moved to Channel 2); the emoji/pace
971
- // blocks are included only when active at session start.
1070
+ let uploadCaptureArmingDisposer = null;
1071
+
972
1072
  function computeStableChannelOne(startSignals) {
973
1073
  const baseReadability = composeReadabilitySystemPrompt(
974
1074
  ocuClawSettingsStore.getSnapshot().systemPrompt,
@@ -987,8 +1087,7 @@ function createRelay(opts) {
987
1087
 
988
1088
  function stableSendOptions(resolvedSessionKey, sessionId, perTurnSignals) {
989
1089
  const signals = perTurnSignals || {};
990
- // "enabled at start" = the client's reported state on the FIRST send of the
991
- // session. The 3-state client signal maps to a boolean: only "active" counts.
1090
+
992
1091
  const startEmoji = signals.neuralEmojiReactorState === "active";
993
1092
  const startPace = signals.neuralPaceModulatorState === "active";
994
1093
  const extraSystemPrompt = stablePromptSnapshots.getOrCreate(
@@ -996,10 +1095,7 @@ function createRelay(opts) {
996
1095
  sessionId,
997
1096
  () => computeStableChannelOne({ emoji: startEmoji, pace: startPace }),
998
1097
  );
999
- // Churn guard: if recomputing TODAY would differ from the served snapshot,
1000
- // something is mutating Channel 1 mid-session (e.g. a toggle flipped the
1001
- // start-state signals) — the case that used to reset the CLI session.
1002
- // Surface it loudly; the served prompt stays the frozen snapshot.
1098
+
1003
1099
  if (
1004
1100
  stablePromptSnapshots.wouldChurn(
1005
1101
  resolvedSessionKey,
@@ -1015,7 +1111,13 @@ function createRelay(opts) {
1015
1111
  () => ({ sessionId: sessionId || null }),
1016
1112
  );
1017
1113
  }
1018
- return { extraSystemPrompt };
1114
+ const options = { extraSystemPrompt };
1115
+
1116
+ const agentId = sessionService.getSessionAgentId(resolvedSessionKey);
1117
+ if (typeof agentId === "string" && agentId.trim()) {
1118
+ options.agentId = agentId.trim();
1119
+ }
1120
+ return options;
1019
1121
  }
1020
1122
 
1021
1123
  function buildOcuClawSendDiagnostic(params = {}) {
@@ -1077,6 +1179,36 @@ function createRelay(opts) {
1077
1179
  return userContent;
1078
1180
  }
1079
1181
 
1182
+ function buildGatewayAttachment(attachment) {
1183
+ if (
1184
+ !attachment ||
1185
+ typeof attachment !== "object" ||
1186
+ typeof attachment.base64Data !== "string" ||
1187
+ !attachment.base64Data
1188
+ ) {
1189
+ return null;
1190
+ }
1191
+ const normalizedAttachment = {
1192
+ type: attachment.kind || "image",
1193
+ mimeType: attachment.mimeType || "image/jpeg",
1194
+ fileName: attachment.name || "image.jpg",
1195
+ content: attachment.base64Data,
1196
+ };
1197
+ if (typeof attachment.source === "string" && attachment.source) {
1198
+ normalizedAttachment.source = attachment.source;
1199
+ }
1200
+ if (Number.isFinite(attachment.sizeBytes) && attachment.sizeBytes > 0) {
1201
+ normalizedAttachment.sizeBytes = Math.floor(attachment.sizeBytes);
1202
+ }
1203
+ if (Number.isFinite(attachment.widthPx) && attachment.widthPx > 0) {
1204
+ normalizedAttachment.widthPx = Math.floor(attachment.widthPx);
1205
+ }
1206
+ if (Number.isFinite(attachment.heightPx) && attachment.heightPx > 0) {
1207
+ normalizedAttachment.heightPx = Math.floor(attachment.heightPx);
1208
+ }
1209
+ return normalizedAttachment;
1210
+ }
1211
+
1080
1212
  function buildOcuClawInitialSessionConfigPatch(settings) {
1081
1213
  const patch = {};
1082
1214
  if (settings && typeof settings.defaultModel === "string" && settings.defaultModel.trim()) {
@@ -1095,6 +1227,30 @@ function createRelay(opts) {
1095
1227
  return Object.keys(patch).length > 0 ? patch : null;
1096
1228
  }
1097
1229
 
1230
+ function seedSessionAgentDefault(sessionKey, defaultAgent) {
1231
+ if (
1232
+ !sessionKey ||
1233
+ !sessionService ||
1234
+ typeof sessionService.setSessionAgentId !== "function" ||
1235
+ typeof sessionService.getSessionAgentId !== "function"
1236
+ ) {
1237
+ return;
1238
+ }
1239
+ const normalized =
1240
+ typeof defaultAgent === "string" ? defaultAgent.trim() : "";
1241
+ if (!normalized) {
1242
+ return;
1243
+ }
1244
+ if (
1245
+ typeof sessionService.hasExplicitSessionAgent === "function" &&
1246
+ sessionService.hasExplicitSessionAgent(sessionKey)
1247
+ ) {
1248
+
1249
+ return;
1250
+ }
1251
+ sessionService.setSessionAgentId(sessionKey, normalized);
1252
+ }
1253
+
1098
1254
  async function maybeSeedOcuClawSessionConfig(sessionKey) {
1099
1255
  if (
1100
1256
  !sessionKey ||
@@ -1106,6 +1262,7 @@ function createRelay(opts) {
1106
1262
  }
1107
1263
 
1108
1264
  const settings = ocuClawSettingsStore.getSnapshot();
1265
+ seedSessionAgentDefault(sessionKey, settings.defaultAgent);
1109
1266
  const patch = buildOcuClawInitialSessionConfigPatch(settings);
1110
1267
  if (!patch) {
1111
1268
  sessionService.clearPendingInitialConfig(sessionKey);
@@ -1133,6 +1290,7 @@ function createRelay(opts) {
1133
1290
  }
1134
1291
 
1135
1292
  const settings = ocuClawSettingsStore.getSnapshot();
1293
+ seedSessionAgentDefault(sessionKey, settings.defaultAgent);
1136
1294
  const patch = buildOcuClawInitialSessionConfigPatch(settings);
1137
1295
  if (!patch) {
1138
1296
  return null;
@@ -1165,6 +1323,17 @@ function createRelay(opts) {
1165
1323
  getAgentName() {
1166
1324
  return upstreamRuntime ? upstreamRuntime.getAgentName() : null;
1167
1325
  },
1326
+ getAgentDisplayName(agentId) {
1327
+ return upstreamRuntime &&
1328
+ typeof upstreamRuntime.getAgentDisplayName === "function"
1329
+ ? upstreamRuntime.getAgentDisplayName(agentId)
1330
+ : null;
1331
+ },
1332
+ getDefaultAgentId() {
1333
+ return normalizeOcuClawDefaultAgent(
1334
+ ocuClawSettingsStore.getSnapshot().defaultAgent,
1335
+ );
1336
+ },
1168
1337
  isPinnedFirstUserMessageKey(sessionKey) {
1169
1338
  const normalizedSessionKey = normalizeEvenAiSessionKeyForLookup(sessionKey);
1170
1339
  if (!normalizedSessionKey) {
@@ -1258,13 +1427,6 @@ function createRelay(opts) {
1258
1427
  }
1259
1428
  }
1260
1429
 
1261
- // TTL fallback for set_session_title activity label. The tool itself
1262
- // completes in <50ms but its label can linger if no follow-up activity
1263
- // arrives (e.g. agent streams a response directly after, with no
1264
- // intervening activity event). After 1s, synthesize a thinking-status
1265
- // activity with no tool/label so the renderer falls back to the bare
1266
- // animated spinner. Any real activity arriving in the meantime cancels
1267
- // the timer.
1268
1430
  const SESSION_TITLE_STATUS_FALLBACK_MS = 1500;
1269
1431
  let sessionTitleStatusFallbackTimer = null;
1270
1432
 
@@ -1339,6 +1501,20 @@ function createRelay(opts) {
1339
1501
  return snapshot;
1340
1502
  }
1341
1503
 
1504
+ function broadcastAgentsCatalog(snapshot) {
1505
+ if (!server || !handler || typeof handler.formatAgentsCatalog !== "function") {
1506
+ return snapshot;
1507
+ }
1508
+
1509
+ const agents =
1510
+ snapshot && Array.isArray(snapshot.agents) ? snapshot.agents : [];
1511
+ if (agents.length === 0 && !(snapshot && snapshot.unsupported)) {
1512
+ return snapshot;
1513
+ }
1514
+ server.broadcast(handler.formatAgentsCatalog(snapshot || {}));
1515
+ return snapshot;
1516
+ }
1517
+
1342
1518
  const appClientDisconnectHandlers = new Set();
1343
1519
  function onAppClientDisconnect(handler) {
1344
1520
  if (typeof handler !== "function") return () => {};
@@ -1383,9 +1559,7 @@ function createRelay(opts) {
1383
1559
  if (typeof patch.title === "string") cleanPatch.title = patch.title;
1384
1560
  if (typeof patch.body === "string") cleanPatch.body = patch.body;
1385
1561
  if (Array.isArray(patch.items)) {
1386
- // Items may be plain-string labels (list_surface / label-only) OR
1387
- // {label, body} objects (list_with_details detail-body ticks). Keep both
1388
- // shapes; drop anything malformed (no string, no string label).
1562
+
1389
1563
  cleanPatch.items = patch.items
1390
1564
  .map((i) => {
1391
1565
  if (typeof i === "string") return i;
@@ -1559,11 +1733,9 @@ function createRelay(opts) {
1559
1733
  );
1560
1734
 
1561
1735
  return maybeSeedOcuClawSessionConfig(resolvedSessionKey).then(() => {
1562
- // A genuine user/voice send is starting an agent turn — mark the
1563
- // session busy so a racing glasses wake is absorbed (§2.6c).
1736
+
1564
1737
  agentTurnTracker.markBusy(resolvedSessionKey);
1565
- // Dispatch upstream first so local transcript work cannot delay first
1566
- // model tokens on large histories.
1738
+
1567
1739
  const upstreamPromise = gatewayBridge.sendMessage(
1568
1740
  text,
1569
1741
  resolvedSessionKey,
@@ -1571,11 +1743,7 @@ function createRelay(opts) {
1571
1743
  {
1572
1744
  ...stableSendOptions(
1573
1745
  resolvedSessionKey,
1574
- // No synchronous OpenClaw sessionId is available at send time
1575
- // (resolveSessionCanonicalKey is async). Use the sessionKey as the
1576
- // snapshot's id; the sessionId-mismatch guard is therefore a no-op,
1577
- // and new-session safety rests on logical-session-end eviction
1578
- // (onNewSession / onNewChat / onDeleteSessions evict the snapshot).
1746
+
1579
1747
  resolvedSessionKey,
1580
1748
  clientDisplaySignals,
1581
1749
  ),
@@ -1677,6 +1845,107 @@ function createRelay(opts) {
1677
1845
  });
1678
1846
  }
1679
1847
 
1848
+ function dispatchOcuClawSessionAbort(params = {}) {
1849
+ const requestId = params.requestId;
1850
+ const sessionKey =
1851
+ typeof params.sessionKey === "string" && params.sessionKey.trim()
1852
+ ? params.sessionKey.trim()
1853
+ : sessionService.ensureSessionKey();
1854
+ emitDebug(
1855
+ "relay.protocol",
1856
+ "session_abort_requested",
1857
+ "info",
1858
+ { sessionKey },
1859
+ () => ({ requestId }),
1860
+ );
1861
+ return gatewayBridge.request("sessions.abort", { key: sessionKey }).then(
1862
+ (result) => ({
1863
+ status: "accepted",
1864
+ ...(result && typeof result === "object" ? result : {}),
1865
+ }),
1866
+ );
1867
+ }
1868
+
1869
+ function dispatchOcuClawSessionSteer(params = {}) {
1870
+ const requestId = params.requestId;
1871
+ const steerStartedAt = Date.now();
1872
+ const sessionKey =
1873
+ typeof params.sessionKey === "string" && params.sessionKey.trim()
1874
+ ? params.sessionKey.trim()
1875
+ : sessionService.ensureSessionKey();
1876
+ const message = typeof params.message === "string" ? params.message : "";
1877
+ const attachment = params.attachment || null;
1878
+ const gatewayAttachment = buildGatewayAttachment(attachment);
1879
+ const request = {
1880
+ key: sessionKey,
1881
+ message,
1882
+ idempotencyKey: requestId,
1883
+ };
1884
+ if (gatewayAttachment) {
1885
+ request.attachments = [gatewayAttachment];
1886
+ }
1887
+
1888
+ sessionService.recordFirstSentUserMessage(sessionKey, message);
1889
+ sessionService.invalidateSessionsCache();
1890
+ agentTurnTracker.markBusy(sessionKey);
1891
+ emitDebug(
1892
+ "relay.protocol",
1893
+ "session_steer_requested",
1894
+ "info",
1895
+ { sessionKey },
1896
+ () => ({
1897
+ requestId,
1898
+ messageChars: message.length,
1899
+ hasAttachment: !!attachment,
1900
+ }),
1901
+ );
1902
+
1903
+ const diagnostic = {
1904
+ messageId: requestId,
1905
+ sessionKey,
1906
+ source: "phone_ui_replace",
1907
+ textChars: message.length,
1908
+ hasAttachment: !!attachment,
1909
+ attachmentBytes:
1910
+ attachment && Number.isFinite(attachment.sizeBytes)
1911
+ ? Math.floor(attachment.sizeBytes)
1912
+ : null,
1913
+ };
1914
+
1915
+ return maybeSeedOcuClawSessionConfig(sessionKey)
1916
+ .then(() => gatewayBridge.request("sessions.steer", request, {
1917
+ expectFinal: false,
1918
+ diagnostic,
1919
+ }))
1920
+ .then((result) => {
1921
+ const ackAt = Date.now();
1922
+ const runId = result && result.runId ? result.runId : null;
1923
+ if (runId && upstreamRuntime) {
1924
+ upstreamRuntime.trackAcceptedRun({
1925
+ runId,
1926
+ sessionKey,
1927
+ messageId: requestId,
1928
+ sendStartedAt: steerStartedAt,
1929
+ ackAt,
1930
+ });
1931
+ }
1932
+ const userContent = buildLocalUserMessageContent(message, attachment);
1933
+ conversationState.addMessage("user", userContent);
1934
+ emitDebug(
1935
+ "openclaw.message",
1936
+ "user_message",
1937
+ "info",
1938
+ { sessionKey },
1939
+ () => ({ text: message }),
1940
+ );
1941
+ broadcastPages();
1942
+ return {
1943
+ ...(result && typeof result === "object" ? result : {}),
1944
+ status: "accepted",
1945
+ };
1946
+ });
1947
+ }
1948
+
1680
1949
  function emitListenInterceptRecovery(params = {}) {
1681
1950
  const connectedAppClients = server ? server.getConnectedAppCount() : 0;
1682
1951
  if (!server || !handler) {
@@ -1707,9 +1976,6 @@ function createRelay(opts) {
1707
1976
  server.broadcast(handler.formatEvenAiListenIntercepted(sessionKey));
1708
1977
  }
1709
1978
 
1710
- // --- Downstream handler ---
1711
-
1712
- /** @type {ReturnType<typeof createRelayWorkerSupervisor>|null} */
1713
1979
  let server = null;
1714
1980
  let evenAiEndpoint = null;
1715
1981
  let evenAiRouter = null;
@@ -1733,12 +1999,6 @@ function createRelay(opts) {
1733
1999
  return { ok: true, enabled, persisted, persistedPath: liveUiTraceFlagPath };
1734
2000
  }
1735
2001
 
1736
- // Persist the current debug-store arm to debug-arm.json. Mirrors the
1737
- // applyTraceLogSet writeFileSync above (plain, non-atomic): a partial/corrupt
1738
- // write degrades to an empty arm on next boot — acceptable, the nothing-armed
1739
- // warning catches it. getSnapshot().enabled is already pruned of expired
1740
- // categories, so the persisted JSON never holds an expired entry. Never throws
1741
- // into the caller.
1742
2002
  function persistDebugArm() {
1743
2003
  if (!debugArmStatePath) return false;
1744
2004
  try {
@@ -1756,9 +2016,7 @@ function createRelay(opts) {
1756
2016
  if (!result.ok) {
1757
2017
  throw new Error(result.error || "debug-set failed");
1758
2018
  }
1759
- // Persist after every successful set — enable AND disable-to-empty — so the
1760
- // on-disk arm always tracks live state and a deliberately-cleared arm is not
1761
- // resurrected on the next restart.
2019
+
1762
2020
  persistDebugArm();
1763
2021
  emitDebug(
1764
2022
  "relay.protocol",
@@ -1784,15 +2042,7 @@ function createRelay(opts) {
1784
2042
  if (kind === "status") return statusRevision;
1785
2043
  return null;
1786
2044
  },
1787
- /**
1788
- * Forward a user message to the upstream OpenClaw agent.
1789
- *
1790
- * @param {string} id - Message ID
1791
- * @param {string} text - User message text
1792
- * @param {string|null} sessionKey - Session key
1793
- * @param {object|null} attachment - Optional image attachment payload
1794
- * @returns {Promise}
1795
- */
2045
+
1796
2046
  onSend(id, text, sessionKey, attachment, clientDisplaySignals) {
1797
2047
  return dispatchOcuClawUserSend({
1798
2048
  id,
@@ -1803,6 +2053,17 @@ function createRelay(opts) {
1803
2053
  source: "phone_ui",
1804
2054
  });
1805
2055
  },
2056
+ onAbortSession({ requestId, sessionKey }) {
2057
+ return dispatchOcuClawSessionAbort({ requestId, sessionKey });
2058
+ },
2059
+ onSteerSession({ requestId, sessionKey, message, attachment }) {
2060
+ return dispatchOcuClawSessionSteer({
2061
+ requestId,
2062
+ sessionKey,
2063
+ message,
2064
+ attachment,
2065
+ });
2066
+ },
1806
2067
  onGlassesUiResult(frame) {
1807
2068
  emitDebug(
1808
2069
  "glasses.lifecycle",
@@ -1893,19 +2154,89 @@ function createRelay(opts) {
1893
2154
  }
1894
2155
  });
1895
2156
  },
2157
+
2158
+ onDebugBundleRequest(clientId, msg) {
2159
+
2160
+ const reportedClientVersion = (() => {
2161
+ try {
2162
+ const snap =
2163
+ server && typeof server.getReadinessSnapshot === "function"
2164
+ ? server.getReadinessSnapshot()
2165
+ : null;
2166
+ const entry =
2167
+ snap && Array.isArray(snap.clients)
2168
+ ? snap.clients.find((c) => c.clientId === clientId)
2169
+ : null;
2170
+ const v = entry && typeof entry.clientVersion === "string" ? entry.clientVersion.trim() : "";
2171
+ return v.length ? v : null;
2172
+ } catch {
2173
+ return null;
2174
+ }
2175
+ })();
2176
+ const deps = {
2177
+ gatesOn: () => externalDebugToolsEnabled && allowDebugUpload,
2178
+ dump: (query) => debugStore.dump(query),
2179
+
2180
+ preset:
2181
+ opts.debugUploadCapturePreset &&
2182
+ Array.isArray(opts.debugUploadCapturePreset) &&
2183
+ opts.debugUploadCapturePreset.length
2184
+ ? opts.debugUploadCapturePreset
2185
+ : UPLOAD_CAPTURE_PRESET,
2186
+
2187
+ build: {
2188
+ clientVersion: reportedClientVersion,
2189
+ requiresClientVersion: pluginVersionService.getRequiresClientVersion(),
2190
+ pluginVersion: pluginVersionService.getPluginVersion(),
2191
+ openclawVersion: pluginVersionService.getOpenClawHostVersion(),
2192
+ distHash: pluginVersionService.getDistHash(),
2193
+ },
2194
+ idSalt: debugBundleIdSalt,
2195
+ maxZipBytes: debugUploadMaxZipBytes,
2196
+ chunkBytes: 64000,
2197
+ send: (id, frame) => {
2198
+ if (server) server.unicast(id, JSON.stringify(frame));
2199
+ },
2200
+
2201
+ emit: (event, data) =>
2202
+ emitDebug("relay.operation", event, "debug", {}, () => data),
2203
+ newBundleId: () => crypto.randomUUID(),
2204
+ cachePut: (id, e) => bundleCache.put(id, e),
2205
+ now: () => Date.now(),
2206
+ };
2207
+ return Promise.resolve(handleDebugBundleRequest(deps, clientId, msg)).catch(
2208
+ (err) => {
2209
+ logger.error(
2210
+ `[relay] debug-bundle-request failed: ${err && err.message ? err.message : err}`,
2211
+ );
2212
+ },
2213
+ );
2214
+ },
2215
+ onDebugBundleSave(clientId, msg) {
2216
+ return Promise.resolve(handleDebugBundleSave({
2217
+ gatesOn: () => externalDebugToolsEnabled && allowDebugUpload,
2218
+ cacheGet: (id) => bundleCache.get(id),
2219
+ saveBundle: (a) => saveBundleToDisk({ ...a, saveDir: resolveSaveDir(), fs, path }),
2220
+ now: () => Date.now(),
2221
+ send: (id, frame) => { if (server) server.unicast(id, JSON.stringify(frame)); },
2222
+ emit: (event, data) => emitDebug("relay.operation", event, "debug", {}, () => data),
2223
+ }, clientId, msg)).catch((err) => {
2224
+ logger.error(`[relay] debug-bundle-save failed: ${err && err.message ? err.message : err}`);
2225
+ });
2226
+ },
2227
+ onDebugBundleFetch(clientId, msg) {
2228
+ return Promise.resolve(handleDebugBundleFetch({
2229
+ gatesOn: () => externalDebugToolsEnabled && allowDebugUpload,
2230
+ cacheGet: (id) => bundleCache.get(id),
2231
+ chunkBytes: 64000,
2232
+ send: (id, frame) => { if (server) server.unicast(id, JSON.stringify(frame)); },
2233
+ emit: (event, data) => emitDebug("relay.operation", event, "debug", {}, () => data),
2234
+ }, clientId, msg)).catch((err) => {
2235
+ logger.error(`[relay] debug-bundle-fetch failed: ${err && err.message ? err.message : err}`);
2236
+ });
2237
+ },
1896
2238
  operationRegistry: relayOperationRegistry,
1897
2239
 
1898
- /**
1899
- * Inject a fake assistant message into conversation state.
1900
- *
1901
- * The sender name is used as a temporary agent name prefix for
1902
- * this message. If no agent identity has been established yet,
1903
- * the sender becomes the default agent name going forward.
1904
- *
1905
- * @param {string} sender - Display name for the simulated message
1906
- * @param {string} text - Message text
1907
- * @returns {Array<{content: string, subPage: [number, number]|null}>}
1908
- */
1909
2240
  onSimulate(sender, text) {
1910
2241
  emitDebug(
1911
2242
  "relay.protocol",
@@ -1917,7 +2248,7 @@ function createRelay(opts) {
1917
2248
  textChars: typeof text === "string" ? text.length : 0,
1918
2249
  }),
1919
2250
  );
1920
- // Add with per-message name override (doesn't affect other messages' prefix)
2251
+
1921
2252
  conversationState.addMessage("assistant", [{ type: "text", text }], sender || "Simulator");
1922
2253
 
1923
2254
  const pages = conversationState.getPages();
@@ -2035,12 +2366,6 @@ function createRelay(opts) {
2035
2366
  });
2036
2367
  },
2037
2368
 
2038
- /**
2039
- * Clear conversation state, reset cached pages, and send /new to OpenClaw.
2040
- * Legacy support: delegates to newSession.
2041
- *
2042
- * @returns {Promise<Array>} Empty pages array
2043
- */
2044
2369
  onNewChat() {
2045
2370
  emitDebug(
2046
2371
  "relay.session",
@@ -2054,15 +2379,10 @@ function createRelay(opts) {
2054
2379
  }
2055
2380
  sessionService.invalidateSessionsCache();
2056
2381
  resetActivityStatusAdapter();
2057
- // Cancel THIS session's pending simulate-stream timers BEFORE clearing —
2058
- // a deferred addMessage firing after the clear repopulates the fresh chat
2059
- // (the 2026-05-15 canary-pollution mechanism).
2382
+
2060
2383
  clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
2061
2384
  conversationState.clear();
2062
- // Logical session end: this key is REUSED for a fresh conversation, so the
2063
- // Channel-1 snapshot must be dropped or the next send serves a stale prompt,
2064
- // and ALL other per-session state keyed to it (title + upstream name,
2065
- // toggles, distiller budget, first-user marker) must be cleared too.
2385
+
2066
2386
  const newChatSessionKey = sessionService.ensureSessionKey();
2067
2387
  stablePromptSnapshots.evict(newChatSessionKey);
2068
2388
  sessionService.clearLogicalSessionState(newChatSessionKey);
@@ -2072,12 +2392,7 @@ function createRelay(opts) {
2072
2392
  const pages = conversationState.getPages();
2073
2393
  cachePages(pages);
2074
2394
  if (upstreamRuntime && upstreamRuntime.isConnected()) {
2075
- // NOTE: onNewChat targets the hard-coded "main" key (legacy) without
2076
- // changing currentSessionKey, so it must NOT elicit a welcome turn here:
2077
- // the turn's events would carry "main" and be dropped by isCurrentSession()
2078
- // whenever the active session is an ocuclaw:* key. The welcome restore for
2079
- // the real glasses paths lives in newSession() (New) and onSlashCommand
2080
- // "/reset" (Reset). Unifying onNewChat onto newSession() is Phase-2 work.
2395
+
2081
2396
  gatewayBridge.sendMessage("/new", "main").catch((err) => {
2082
2397
  logger.error(`[relay] Failed to send /new: ${err.message}`);
2083
2398
  });
@@ -2103,16 +2418,10 @@ function createRelay(opts) {
2103
2418
  },
2104
2419
 
2105
2420
  async onNewSession() {
2106
- // Cancel pending simulate-stream timers scheduled under the outgoing key
2107
- // BEFORE the new key is minted — a deferred addMessage firing after the
2108
- // switch would repopulate the fresh session's shared conversation view.
2421
+
2109
2422
  clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
2110
2423
  const result = await sessionService.newSession();
2111
- // newSession() mints a FRESH key; defensively clear only the NEW key (it
2112
- // has no snapshot yet). Do NOT touch the previous key: that session stays
2113
- // resumable via onSwitchSession, and dropping its frozen snapshot would
2114
- // recompute — and churn — Channel 1 if the user switches back to it. The
2115
- // previous session's snapshot is released by delete or the TTL sweep.
2424
+
2116
2425
  if (result && typeof result.sessionKey === "string" && result.sessionKey.trim()) {
2117
2426
  stablePromptSnapshots.evict(result.sessionKey);
2118
2427
  sessionService.clearDisplayToggleStates(result.sessionKey);
@@ -2147,6 +2456,20 @@ function createRelay(opts) {
2147
2456
  : Promise.resolve({ skills: [], fetchedAtMs: Date.now(), stale: true });
2148
2457
  },
2149
2458
 
2459
+ onGetAgentsCatalog() {
2460
+ return upstreamRuntime
2461
+ ? upstreamRuntime.getAgentsCatalogSnapshot()
2462
+ : Promise.resolve({
2463
+ agents: [],
2464
+ defaultId: null,
2465
+ mainKey: null,
2466
+ scope: null,
2467
+ fetchedAtMs: Date.now(),
2468
+ stale: true,
2469
+ unsupported: true,
2470
+ });
2471
+ },
2472
+
2150
2473
  onGetProviderUsageSnapshot() {
2151
2474
  return upstreamRuntime
2152
2475
  ? upstreamRuntime.getProviderUsageSnapshot()
@@ -2187,6 +2510,32 @@ function createRelay(opts) {
2187
2510
  return result;
2188
2511
  },
2189
2512
 
2513
+ onSetSessionAgent(patch) {
2514
+ const sessionKey = sessionService.ensureSessionKey();
2515
+ const result = sessionService.setSessionAgentId(
2516
+ sessionKey,
2517
+ (patch && patch.agentId) || "",
2518
+ );
2519
+ if (!result || result.ok !== true) {
2520
+ return {
2521
+ status: "rejected",
2522
+ error: (result && result.reason) || "invalid session agent",
2523
+ };
2524
+ }
2525
+
2526
+ if (typeof sessionService.primeSessionModelConfig === "function") {
2527
+ const config = sessionService.primeSessionModelConfig(sessionKey, {});
2528
+ if (config && isActiveSessionModelConfig(config)) {
2529
+ currentSessionModelConfigSnapshot = config;
2530
+ if (server) {
2531
+ server.broadcast(handler.formatSessionModelConfig(config));
2532
+ }
2533
+ }
2534
+ }
2535
+ broadcastSessions();
2536
+ return { status: "accepted" };
2537
+ },
2538
+
2190
2539
  onGetEvenAiSettings() {
2191
2540
  return evenAiSettingsStore.getSnapshot();
2192
2541
  },
@@ -2236,23 +2585,15 @@ function createRelay(opts) {
2236
2585
  );
2237
2586
  broadcastPages();
2238
2587
  }
2239
- // A user-typed /new or /reset is a logical session reset on the CURRENT
2240
- // key (distinct from an automatic CLI session reset, which keeps the same
2241
- // logical session and must survive). Drop the frozen Channel-1 snapshot so
2242
- // the next real message recomputes it for the fresh conversation; otherwise
2243
- // the old conversation's prompt + display start-state bleed into the new one.
2588
+
2244
2589
  if (command === "/new" || command === "/reset") {
2245
2590
  const resetKey = sessionService.ensureSessionKey();
2246
2591
  stablePromptSnapshots.evict(resetKey);
2247
- // Clear ALL per-session state keyed to the reused key (title + upstream
2248
- // name, toggles, distiller budget, first-user marker) so nothing from the
2249
- // old conversation bleeds into the fresh one.
2592
+
2250
2593
  sessionService.clearLogicalSessionState(resetKey);
2251
2594
  }
2252
2595
  if (upstreamRuntime && upstreamRuntime.isConnected()) {
2253
- // Bare /reset no longer elicits an agent turn on OpenClaw 2026.6.x
2254
- // (fast-reset). Append the greeting prompt so Reset gets the same
2255
- // welcome as New. Other slash commands forward verbatim.
2596
+
2256
2597
  const outboundCommand =
2257
2598
  command === "/reset"
2258
2599
  ? `/reset ${NEW_SESSION_GREETING_PROMPT}`
@@ -2265,9 +2606,6 @@ function createRelay(opts) {
2265
2606
  return Promise.resolve();
2266
2607
  },
2267
2608
 
2268
- /**
2269
- * @returns {boolean} Whether upstream is connected.
2270
- */
2271
2609
  isUpstreamConnected() {
2272
2610
  return true;
2273
2611
  },
@@ -2318,6 +2656,10 @@ function createRelay(opts) {
2318
2656
  return mintSonioxTemporaryKey(clientId, request);
2319
2657
  },
2320
2658
 
2659
+ onRequestCartesiaAccessToken(clientId, request) {
2660
+ return mintCartesiaAccessToken(clientId, request);
2661
+ },
2662
+
2321
2663
  onDebugSet(clientId, request) {
2322
2664
  return applyDebugSet(clientId, request);
2323
2665
  },
@@ -2565,14 +2907,7 @@ function createRelay(opts) {
2565
2907
  },
2566
2908
 
2567
2909
  onAutomationState(clientId, request) {
2568
- // Mirrors onReadinessProbe (above): identify the single connected app
2569
- // client via the readiness snapshot, then return a dispatch envelope
2570
- // that downstream-handler.handleAutomationState wraps into
2571
- // `automationStateRequest`. Without this callback wired, the handler
2572
- // returns null and the request is silently dropped at the relay —
2573
- // simctl/debugctl times out with no failure response, no trace event,
2574
- // no outbox drop. The lack of wiring was found 2026-05-28 while
2575
- // validating the streaming-thinking-emoji-demotion fix on the sim.
2910
+
2576
2911
  const now = Date.now();
2577
2912
  const requestId =
2578
2913
  (typeof request.requestId === "string" && request.requestId.trim()) ||
@@ -2601,11 +2936,7 @@ function createRelay(opts) {
2601
2936
  targetEntry && typeof targetEntry.clientId === "string"
2602
2937
  ? targetEntry.clientId
2603
2938
  : null;
2604
- // A connected app client that has never published a readiness snapshot
2605
- // cannot answer an automation state request; forwarding anyway would
2606
- // park the request in pendingAutomationStateRequests with no reply.
2607
- // Same predicate as the downstream readiness gate; this wired callback
2608
- // bypasses the normal dispatch path.
2939
+
2609
2940
  const readinessPublished =
2610
2941
  !!(
2611
2942
  targetEntry &&
@@ -2692,13 +3023,8 @@ function createRelay(opts) {
2692
3023
  },
2693
3024
  });
2694
3025
 
2695
- // --- Worker supervisor ---
2696
-
2697
3026
  const pluginVersionService = createPluginVersionService();
2698
3027
 
2699
- // Roadmap 4a: latch the worker's per-heartbeat send-buffer pressure counts
2700
- // into the boolean the glasses-ui paint-floor shed queries
2701
- // (isGlassesSendBufferOverHighWater on the relay API / relay-service facade).
2702
3028
  const glassesBackpressureLatch = createGlassesBackpressureLatch({
2703
3029
  emitDebug: (event, severity, data) =>
2704
3030
  emitDebug("relay.health", event, severity, null, () => data || {}),
@@ -2759,8 +3085,6 @@ function createRelay(opts) {
2759
3085
  },
2760
3086
  });
2761
3087
 
2762
- // --- Helpers ---
2763
-
2764
3088
  function buildStatusObject(options = {}) {
2765
3089
  const includeDownstreamReadiness = options.includeDownstreamReadiness === true;
2766
3090
  const status = {
@@ -2808,9 +3132,6 @@ function createRelay(opts) {
2808
3132
  return cachedStatus;
2809
3133
  }
2810
3134
 
2811
- /**
2812
- * Recompute pages from conversation state, cache, and broadcast.
2813
- */
2814
3135
  function broadcastPages() {
2815
3136
  const pages = conversationState.getPages();
2816
3137
  const next = cachePages(pages);
@@ -2819,12 +3140,6 @@ function createRelay(opts) {
2819
3140
  }
2820
3141
  }
2821
3142
 
2822
- /**
2823
- * Fetch the latest sessions snapshot and broadcast it. Used after a session
2824
- * title changes so connected clients refresh the title in the main webui
2825
- * status row and Session Settings tab without waiting for a manual
2826
- * session-list open.
2827
- */
2828
3143
  function broadcastSessions() {
2829
3144
  sessionService
2830
3145
  .getSessions()
@@ -2842,10 +3157,6 @@ function createRelay(opts) {
2842
3157
  });
2843
3158
  }
2844
3159
 
2845
- /**
2846
- * Resolve the current Even AI sessions snapshot for unicast/broadcast.
2847
- * Mirrors the shape that `formatEvenAiSessions` expects.
2848
- */
2849
3160
  async function buildEvenAiSessionsSnapshot() {
2850
3161
  const dedicatedKey =
2851
3162
  evenAiRouter && typeof evenAiRouter.getDedicatedSessionKey === "function"
@@ -2910,9 +3221,6 @@ function createRelay(opts) {
2910
3221
  });
2911
3222
  }
2912
3223
 
2913
- /**
2914
- * Build, cache, and broadcast the current status.
2915
- */
2916
3224
  function broadcastStatus() {
2917
3225
  const next = cacheStatus(buildStatusObject());
2918
3226
  if (next !== null) {
@@ -2945,6 +3253,7 @@ function createRelay(opts) {
2945
3253
  broadcastStatus,
2946
3254
  broadcastActivity,
2947
3255
  broadcastProviderUsageSnapshot,
3256
+ broadcastAgentsCatalog,
2948
3257
  operationRegistry: relayOperationRegistry,
2949
3258
  getCurrentSessionModelConfigSnapshot() {
2950
3259
  return currentSessionModelConfigSnapshot;
@@ -2962,10 +3271,6 @@ function createRelay(opts) {
2962
3271
  fetchAgentAvatar: opts.fetchAgentAvatar,
2963
3272
  });
2964
3273
 
2965
- // Shared routing gate for session-scoped Even AI defaults (thinking seed,
2966
- // fast-mode patch): never touch active-routed sessions; always seed fresh
2967
- // background_new sessions; seed persistent background sessions only before
2968
- // their first turn exists.
2969
3274
  async function shouldSeedSessionScopedDefaultForRoute(route) {
2970
3275
  const routingMode =
2971
3276
  route && typeof route.routingMode === "string"
@@ -3074,6 +3379,29 @@ function createRelay(opts) {
3074
3379
  );
3075
3380
  return !!(result && result.status === "accepted");
3076
3381
  },
3382
+ resolveAgentForRoute(params) {
3383
+ const route = params && params.route ? params.route : params;
3384
+ const routingMode =
3385
+ (route && typeof route.routingMode === "string"
3386
+ ? route.routingMode.trim().toLowerCase()
3387
+ : "") || "active";
3388
+ const sessionKey =
3389
+ route && typeof route.sessionKey === "string"
3390
+ ? route.sessionKey.trim()
3391
+ : "";
3392
+
3393
+ if (routingMode === "active") {
3394
+ return sessionKey ? sessionService.getSessionAgentId(sessionKey) : "";
3395
+ }
3396
+ const evenAiDefault = normalizeEvenAiDefaultAgent(
3397
+ evenAiSettingsStore.getSnapshot().defaultAgent,
3398
+ );
3399
+ if (sessionKey && evenAiDefault) {
3400
+
3401
+ sessionService.setSessionAgentId(sessionKey, evenAiDefault);
3402
+ }
3403
+ return evenAiDefault;
3404
+ },
3077
3405
  onSessionActivated(route) {
3078
3406
  if (!route || !route.sessionChanged) {
3079
3407
  return;
@@ -3134,34 +3462,45 @@ function createRelay(opts) {
3134
3462
  return true;
3135
3463
  }
3136
3464
 
3137
- // --- Public API ---
3138
-
3139
3465
  relayApi = {
3140
- /**
3141
- * Emit a glasses-UI surface-lifecycle event on the permanent
3142
- * `glasses.lifecycle` debug category (nav reconcile + cron pause/resume/
3143
- * tick). Recorded only when the category is enabled via debug-set. Wired
3144
- * through the relay-service facade into the glasses-ui tool handler + cron
3145
- * engine. See docs/superpowers/findings/2026-05-30-glasses-ui-phase4-hardware.md.
3146
- */
3466
+
3147
3467
  emitGlassesUiLifecycle(event, severity, data) {
3148
3468
  emitDebug("glasses.lifecycle", event, severity, {}, () => data || {});
3149
3469
  },
3150
- /**
3151
- * Start the upstream OpenClaw connection.
3152
- * The downstream server is already listening from construction.
3153
- */
3470
+
3154
3471
  start() {
3155
- // Bounded cleanup of stale stable-prompt snapshots (14-day TTL).
3472
+
3473
+ if (!bundleCacheSweepTimer) {
3474
+ bundleCacheSweepTimer = setInterval(() => bundleCache.sweep(), 60_000);
3475
+ if (typeof bundleCacheSweepTimer.unref === "function") bundleCacheSweepTimer.unref();
3476
+ }
3477
+
3156
3478
  if (!stablePromptSweepTimer) {
3157
3479
  stablePromptSweepTimer = setInterval(
3158
3480
  () => stablePromptSnapshots.sweep(),
3159
- 60 * 60 * 1000, // hourly
3481
+ 60 * 60 * 1000,
3160
3482
  );
3161
3483
  if (typeof stablePromptSweepTimer.unref === "function") {
3162
3484
  stablePromptSweepTimer.unref();
3163
3485
  }
3164
3486
  }
3487
+
3488
+ if (!uploadCaptureArmingDisposer) {
3489
+ uploadCaptureArmingDisposer = startUploadCaptureArming({
3490
+ gatesOn: () => externalDebugToolsEnabled && allowDebugUpload,
3491
+ armCategories: (cats, ttlMs) =>
3492
+ applyDebugSet("upload-capture-arming", { enable: cats, ttlMs }),
3493
+ maxTtlMs: debugStore.getConfig().maxTtlMs,
3494
+
3495
+ preset: opts.debugUploadCapturePreset,
3496
+ onArmError: (err) =>
3497
+ logger.warn(
3498
+ `[relay] upload-capture arming failed (preset override?): ${err && err.message}`,
3499
+ ),
3500
+ setInterval,
3501
+ clearInterval,
3502
+ });
3503
+ }
3165
3504
  const startGateway = () => Promise.resolve(gatewayBridge.start()).then(() => {
3166
3505
  prefetchSonioxModels("relay_start").catch((err) => {
3167
3506
  logger.warn(`[relay] Soniox models prefetch failed: ${err.message}`);
@@ -3176,17 +3515,20 @@ function createRelay(opts) {
3176
3515
  return startGateway();
3177
3516
  },
3178
3517
 
3179
- /**
3180
- * Stop the upstream connection and shut down the downstream server.
3181
- *
3182
- * @returns {Promise<void>}
3183
- */
3184
3518
  stop() {
3185
3519
  clearSimulateStreamTimers();
3520
+ if (bundleCacheSweepTimer) {
3521
+ clearInterval(bundleCacheSweepTimer);
3522
+ bundleCacheSweepTimer = null;
3523
+ }
3186
3524
  if (stablePromptSweepTimer) {
3187
3525
  clearInterval(stablePromptSweepTimer);
3188
3526
  stablePromptSweepTimer = null;
3189
3527
  }
3528
+ if (uploadCaptureArmingDisposer) {
3529
+ uploadCaptureArmingDisposer();
3530
+ uploadCaptureArmingDisposer = null;
3531
+ }
3190
3532
  if (evenAiEndpoint) {
3191
3533
  evenAiEndpoint.close();
3192
3534
  }
@@ -3213,7 +3555,6 @@ function createRelay(opts) {
3213
3555
 
3214
3556
  handleBufferedEvenAiHttpRequest,
3215
3557
 
3216
- /** The downstream server instance. */
3217
3558
  get server() {
3218
3559
  return server;
3219
3560
  },
@@ -3274,7 +3615,6 @@ function createRelay(opts) {
3274
3615
  return sessionService.getDisplayCurrentStates(sessionKey);
3275
3616
  },
3276
3617
 
3277
- // Accessors used by the session-title distiller sidecar.
3278
3618
  getSessionTitleRecord(sessionKey) {
3279
3619
  return sessionService.getSessionTitleRecord(sessionKey);
3280
3620
  },
@@ -3287,12 +3627,7 @@ function createRelay(opts) {
3287
3627
  getDistillerBudget() {
3288
3628
  return sessionService.getDistillerBudget();
3289
3629
  },
3290
- // Canonical-key cleanup for the distiller's throwaway upstream session.
3291
- // The native subagent deleteSession passes the bare key straight to
3292
- // sessions.delete, which the 2026.6.x gateway indexes under the canonical
3293
- // agent:<id>: form — the bare-key delete silently no-ops and the
3294
- // excerpt-bearing transcript survives. deleteSessions() resolves the
3295
- // canonical key via sessions.resolve first.
3630
+
3296
3631
  deleteDistillerSession(sessionKey) {
3297
3632
  return sessionService.deleteSessions("ocuclaw", [sessionKey]);
3298
3633
  },
@@ -3313,11 +3648,6 @@ function createRelay(opts) {
3313
3648
  return sessionService.peekSessionKey();
3314
3649
  },
3315
3650
 
3316
- /**
3317
- * Test/shutdown hook: resolves once the async first-user-message cache
3318
- * write has fully drained (no write in flight, no dirty mark pending) so
3319
- * the on-disk file reflects the latest in-memory map.
3320
- */
3321
3651
  flushFirstSentUserMessageCache() {
3322
3652
  return sessionService.flushFirstSentUserMessageCache();
3323
3653
  },
@@ -3334,20 +3664,10 @@ function createRelay(opts) {
3334
3664
  return result;
3335
3665
  },
3336
3666
 
3337
- /**
3338
- * Test-only: direct access to dispatchOcuClawUserSend so integration
3339
- * tests can drive per-turn signal plumbing without a live downstream
3340
- * WebSocket connection.
3341
- */
3342
3667
  _dispatchOcuClawUserSend(params) {
3343
3668
  return dispatchOcuClawUserSend(params || {});
3344
3669
  },
3345
3670
 
3346
- /**
3347
- * Test-only: run the logical-reset state clear (the same call the /new,
3348
- * /reset, and new-chat paths make) so integration tests can verify all
3349
- * per-session state is dropped for a reused session key.
3350
- */
3351
3671
  _clearLogicalSessionState(sessionKey) {
3352
3672
  sessionService.clearLogicalSessionState(sessionKey);
3353
3673
  },
@@ -3360,15 +3680,6 @@ function createRelay(opts) {
3360
3680
  sendGlassesUiSurfaceUpdate(params);
3361
3681
  },
3362
3682
 
3363
- /**
3364
- * Tap-to-wake lane (roadmap 6f): ONE agent turn for a parked glasses
3365
- * gesture, dispatched through the same gateway client the voice send
3366
- * uses. The MESSAGE is built (and sanitized) by the glasses-ui wake
3367
- * controller — refs-only with non-wearer provenance framing; this method
3368
- * is a dumb transport and deliberately does NOT touch the local
3369
- * conversation state (a wake is not a wearer utterance — no synthetic
3370
- * user message without provenance, §2.6).
3371
- */
3372
3683
  dispatchGlassesWake(params) {
3373
3684
  const sessionKey =
3374
3685
  params && typeof params.sessionKey === "string" && params.sessionKey