ocuclaw 1.3.2 → 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 (84) 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 +93 -0
  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 +657 -271
  51. package/dist/runtime/relay-service.js +40 -36
  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 +109 -39
  57. package/dist/runtime/relay-worker-transport.js +157 -15
  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 +58 -63
  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 +22 -34
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +295 -100
  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 +475 -331
  76. package/dist/tools/glasses-ui-voicemail.js +242 -0
  77. package/dist/tools/glasses-ui-wake.js +195 -0
  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/skills/glasses-ui/SKILL.md +19 -3
  84. 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,23 @@ 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";
27
+ import { createAgentTurnTracker } from "../tools/glasses-ui-wake.js";
21
28
  import { createDownstreamHandler } from "./downstream-handler.js";
22
- 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";
23
36
  import { createRelayHealthMonitor } from "./relay-health-monitor.js";
37
+ import { createGlassesBackpressureLatch } from "./glasses-backpressure-latch.js";
24
38
  import { createRelayOperationRegistry } from "./relay-operation-registry.js";
25
39
  import { createRelayWorkerSupervisor } from "./relay-worker-supervisor.js";
26
40
  import {
@@ -29,13 +43,18 @@ import {
29
43
  } from "./session-service.js";
30
44
  import { createUpstreamRuntime } from "./upstream-runtime.js";
31
45
 
46
+ const GLASSES_UI_MARKERS = new Set(["listening", "parked", "inflight"]);
47
+ export function sanitizeGlassesMarker(v) { return GLASSES_UI_MARKERS.has(v) ? v : undefined; }
48
+
32
49
  const SONIOX_TEMP_KEY_URL = "https://api.soniox.com/v1/auth/temporary-api-key";
33
50
  const SONIOX_MODELS_URL = "https://api.soniox.com/v1/models";
34
51
  const DEFAULT_SONIOX_TEMP_KEY_EXPIRES_IN_SECONDS = 3600;
35
- // Maximum time (ms) to wait for a Soniox temp-key mint before aborting the
36
- // fetch. 8 s is a conservative cold-path ceiling; tests inject a tiny value
37
- // via opts.sonioxTemporaryKeyMintTimeoutMs to make assertions fast.
52
+
38
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;
39
58
  const EVEN_AI_NAMESPACE_PREFIX = "ocuclaw:even-ai";
40
59
  const EVEN_AI_NAMESPACE_PREFIX_WITH_DELIMITER = "ocuclaw:even-ai:";
41
60
  const LISTEN_INTERCEPT_RECOVERY_ERROR = "Voice interrupted; retry";
@@ -174,7 +193,7 @@ function normalizeSonioxTemporaryKeyErrorCode(err) {
174
193
  : "";
175
194
  const lowered = message.toLowerCase();
176
195
  if (!message) return "soniox_temp_key_request_failed";
177
- // AbortError from the per-fetch timeout AbortController.
196
+
178
197
  if (err && err.name === "AbortError") {
179
198
  return "soniox_temp_key_mint_timeout";
180
199
  }
@@ -256,9 +275,7 @@ function createBufferedHttpResponse(maxResponseBytes) {
256
275
  const limit = Number.isFinite(maxResponseBytes) && maxResponseBytes > 0
257
276
  ? Math.floor(maxResponseBytes)
258
277
  : 262_144;
259
- // EventEmitter shape: handlers like the Even-AI endpoint subscribe to
260
- // res.once('close', ...) for client-disconnect detection. Worker-mode
261
- // relays actual client closes through an http.cancel worker message.
278
+
262
279
  const res = new EventEmitter();
263
280
  res.statusCode = 200;
264
281
  res.writableEnded = false;
@@ -296,29 +313,18 @@ function createBufferedHttpResponse(maxResponseBytes) {
296
313
  return res;
297
314
  }
298
315
 
299
- // --- Factory ---
300
-
301
- /**
302
- * Create the relay orchestrator.
303
- *
304
- * Wires the upstream gateway bridge to downstream clients via the
305
- * conversation-state module, downstream handler, and downstream server.
306
- * This is the only module that knows about both sides.
307
- *
308
- * @param {object} opts
309
- * @param {number} opts.port - WebSocket server port
310
- * @param {string} opts.host - WebSocket server bind address
311
- * @param {string} opts.token - Authentication token for downstream clients
312
- * @param {object} [opts.gatewayBridge] - Override bridge for testing/integration
313
- * @param {object} [opts.openclawClient] - Override for testing (default: plugin gateway client)
314
- * @param {object} [opts.conversationState] - Override for testing (default: require singleton)
315
- * @param {object} [opts.logger] - Structured logger for shared runtime logs
316
- * @param {string|null} [opts.consoleLogPath] - Optional shim-only browser console sink path
317
- * @returns {object} Relay instance with start(), stop(), server
318
- */
319
316
  function createRelay(opts) {
320
317
  const logger = normalizeLogger(opts.logger);
321
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");
322
328
  const openclawClient =
323
329
  opts.openclawClient ||
324
330
  (opts.gatewayBridge
@@ -342,30 +348,23 @@ function createRelay(opts) {
342
348
  const activityStatusAdapter = createActivityStatusAdapter(
343
349
  opts.activityStatusAdapter,
344
350
  );
345
- const sharedHttpServer = opts.httpServer || null;
346
351
 
347
- // --- Cached state ---
352
+ const agentTurnTracker = createAgentTurnTracker();
353
+ const sharedHttpServer = opts.httpServer || null;
348
354
 
349
- /** @type {string|null} Last formatted pages JSON string. */
350
355
  let cachedPages = null;
351
- /** Monotonic pages snapshot revision used for resume handshake. */
356
+
352
357
  let pagesRevision = 0;
353
358
 
354
- /** @type {string|null} Last formatted status JSON string. */
355
359
  let cachedStatus = null;
356
- /** Monotonic status snapshot revision used for resume handshake. */
360
+
357
361
  let statusRevision = 0;
358
- /** @type {{sessionKey: string, modelProvider: string|null, model: string|null, thinkingLevel: string, reasoningLevel: string, verboseLevel: string}|null} */
362
+
359
363
  let currentSessionModelConfigSnapshot = null;
360
364
 
361
- /** Relay-local deterministic simulate-stream run sequence counter. */
362
365
  let simulateStreamRunSeq = 0;
363
- /** Active timers for relay-local deterministic simulate-stream runs. */
364
- // timer -> sessionKey, so new-chat//reset/new-session can cancel ONLY the
365
- // affected session's pending injections (re-land of a8a29032, session-scoped).
366
- const simulateStreamTimers = new Map();
367
366
 
368
- // --- Structured debug state ---
367
+ const simulateStreamTimers = new Map();
369
368
 
370
369
  const debugCategories = Array.isArray(opts.debugCategories)
371
370
  ? opts.debugCategories
@@ -374,21 +373,10 @@ function createRelay(opts) {
374
373
  .filter(([, enabled]) => enabled)
375
374
  .map(([category]) => category)
376
375
  : opts.debugCategories;
377
- // Single relay-side clock: the debug store, the emitDebug ts stamp, and the
378
- // liveui log tee all read from this one source so store records and `[liveui]`
379
- // log lines share an identical ts (downstream reconcilers dedupe on it).
376
+
380
377
  const debugNow =
381
378
  typeof opts.debugNow === "function" ? opts.debugNow : () => Date.now();
382
379
 
383
- // --- Durable debug-store arm (survives relay/gateway restarts) ---
384
- // The capture arm (enabled categories + TTLs) lives only in the in-memory
385
- // debug-store, which a restart rebuilds empty. We persist it to debug-arm.json
386
- // (via persistDebugArm) and rehydrate it here at construction — read-once,
387
- // mirroring liveUiTraceFlagPath below — so a restart no longer silently drops
388
- // capture. This path covers process RESTART only: a pure WebUI *reload* does NOT
389
- // restart the relay and already preserves + re-advertises the arm via
390
- // relay-worker-transport.ts:327-328 (cache.debugConfig re-broadcast to app
391
- // clients) — do not add reconnect machinery here.
392
380
  const debugArmStatePath =
393
381
  typeof opts.stateDir === "string" && opts.stateDir
394
382
  ? path.join(opts.stateDir, "debug-arm.json")
@@ -407,7 +395,6 @@ function createRelay(opts) {
407
395
  const debugStore = createDebugStore({
408
396
  categories: debugCategories,
409
397
  capacity: opts.debugCapacity,
410
- payloadMaxBytes: opts.debugPayloadMaxBytes,
411
398
  defaultTtlMs: opts.debugDefaultTtlMs,
412
399
  maxTtlMs: opts.debugMaxTtlMs,
413
400
  dumpDefaultLimit: opts.debugDumpDefaultLimit,
@@ -417,10 +404,14 @@ function createRelay(opts) {
417
404
  initialEnabled: initialDebugArm,
418
405
  });
419
406
 
420
- // --- Live-interface trace-log flag (durable across restarts) ---
421
- // Gates the glasses.lifecycle → gateway-log tee. Read once at construction;
422
- // toggled live by applyTraceLogSet, which also rewrites this file so the
423
- // 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
+
424
415
  const liveUiTraceFlagPath =
425
416
  typeof opts.stateDir === "string" && opts.stateDir
426
417
  ? path.join(opts.stateDir, "liveui-trace.json")
@@ -435,13 +426,11 @@ function createRelay(opts) {
435
426
  }
436
427
  }
437
428
 
438
- // --- Console log file ---
439
-
440
429
  const consoleLogPath =
441
430
  typeof opts.consoleLogPath === "string" && opts.consoleLogPath.trim()
442
431
  ? opts.consoleLogPath
443
432
  : null;
444
- // Clear the shim-only browser console sink on startup.
433
+
445
434
  if (consoleLogPath) {
446
435
  try {
447
436
  fs.writeFileSync(consoleLogPath, "");
@@ -450,13 +439,6 @@ function createRelay(opts) {
450
439
  const CONSOLE_LOG_MAX_LINES = 500;
451
440
  const CONSOLE_LOG_TRIM_TO = 250;
452
441
 
453
- /**
454
- * Append a browser console message to the log file.
455
- * Trims the file when it exceeds CONSOLE_LOG_MAX_LINES.
456
- *
457
- * @param {string} level - "log", "warn", or "error"
458
- * @param {string} message - Console message text
459
- */
460
442
  function writeConsoleLog(level, message) {
461
443
  if (!consoleLogPath) {
462
444
  logger.debug(`[browser:${level}] ${message}`);
@@ -466,7 +448,7 @@ function createRelay(opts) {
466
448
  const line = `[${timestamp}] [${level}] ${message}\n`;
467
449
  try {
468
450
  fs.appendFileSync(consoleLogPath, line);
469
- // Trim if too large
451
+
470
452
  const content = fs.readFileSync(consoleLogPath, "utf8");
471
453
  const lines = content.split("\n");
472
454
  if (lines.length > CONSOLE_LOG_MAX_LINES) {
@@ -478,16 +460,6 @@ function createRelay(opts) {
478
460
  }
479
461
  }
480
462
 
481
- /**
482
- * Emit a structured debug event when a category is enabled.
483
- * This keeps the disabled path cheap by avoiding payload construction.
484
- *
485
- * @param {string} cat
486
- * @param {string} event
487
- * @param {"debug"|"info"|"warn"|"error"} severity
488
- * @param {object} context
489
- * @param {() => object} buildData
490
- */
491
463
  function emitDebug(cat, event, severity, context, buildData, options) {
492
464
  const force = !!(options && options.force === true);
493
465
  if (!force && !debugStore.isEnabled(cat) && !(liveUiTraceLogEnabled && (cat === "glasses.lifecycle" || cat === "openclaw.message"))) {
@@ -512,8 +484,6 @@ function createRelay(opts) {
512
484
 
513
485
  debugStore.emit(payload, { force });
514
486
 
515
- // Durable openclaw-side trace tee (gated by the persistent flag, NOT the
516
- // store category enable). Must never throw into the emit path.
517
487
  if (liveUiTraceLogEnabled && (cat === "glasses.lifecycle" || cat === "openclaw.message")) {
518
488
  try {
519
489
  const surfaceId =
@@ -541,7 +511,7 @@ function createRelay(opts) {
541
511
  }),
542
512
  );
543
513
  } catch {
544
- // observability must never break the emit path
514
+
545
515
  }
546
516
  }
547
517
  }
@@ -609,12 +579,26 @@ function createRelay(opts) {
609
579
  )
610
580
  ? Math.max(1, Math.floor(opts.sonioxTemporaryKeyMintTimeoutMs))
611
581
  : DEFAULT_SONIOX_TEMP_KEY_MINT_TIMEOUT_MS;
612
- /** @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
+
613
597
  let cachedSonioxModels = null;
614
598
  let cachedSonioxModelsFetchedAt = 0;
615
599
  let cachedSonioxModelsStale = true;
616
600
  let sonioxModelsFetchStarted = false;
617
- /** @type {Promise<{models: Array, fetchedAtMs: number, stale: boolean}>|null} */
601
+
618
602
  let inFlightSonioxModelsFetch = null;
619
603
 
620
604
  function resolveFetchImpl() {
@@ -926,6 +910,138 @@ function createRelay(opts) {
926
910
  }
927
911
  }
928
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
+
929
1045
  let upstreamRuntime = null;
930
1046
  const evenAiSettingsStore = createEvenAiSettingsStore({
931
1047
  logger,
@@ -948,16 +1064,11 @@ function createRelay(opts) {
948
1064
  stateDir: opts.stateDir,
949
1065
  emitDebug,
950
1066
  });
951
- // Hourly TTL sweep for stale stable-prompt snapshots; started in start(),
952
- // cleared in stop(). Declared here so both can see it.
1067
+
953
1068
  let stablePromptSweepTimer = null;
954
1069
 
955
- // Channel 1: the per-session-immutable extraSystemPrompt. Computed once from
956
- // the feature set AT SESSION START and then served byte-identical for the
957
- // session's lifetime (see stable-prompt-snapshot). Mid-session toggles are
958
- // bridged by the Channel-2 hook composer, NOT here. The glasses-UI pointer is
959
- // always present (its disconnected gate moved to Channel 2); the emoji/pace
960
- // blocks are included only when active at session start.
1070
+ let uploadCaptureArmingDisposer = null;
1071
+
961
1072
  function computeStableChannelOne(startSignals) {
962
1073
  const baseReadability = composeReadabilitySystemPrompt(
963
1074
  ocuClawSettingsStore.getSnapshot().systemPrompt,
@@ -976,8 +1087,7 @@ function createRelay(opts) {
976
1087
 
977
1088
  function stableSendOptions(resolvedSessionKey, sessionId, perTurnSignals) {
978
1089
  const signals = perTurnSignals || {};
979
- // "enabled at start" = the client's reported state on the FIRST send of the
980
- // session. The 3-state client signal maps to a boolean: only "active" counts.
1090
+
981
1091
  const startEmoji = signals.neuralEmojiReactorState === "active";
982
1092
  const startPace = signals.neuralPaceModulatorState === "active";
983
1093
  const extraSystemPrompt = stablePromptSnapshots.getOrCreate(
@@ -985,10 +1095,7 @@ function createRelay(opts) {
985
1095
  sessionId,
986
1096
  () => computeStableChannelOne({ emoji: startEmoji, pace: startPace }),
987
1097
  );
988
- // Churn guard: if recomputing TODAY would differ from the served snapshot,
989
- // something is mutating Channel 1 mid-session (e.g. a toggle flipped the
990
- // start-state signals) — the case that used to reset the CLI session.
991
- // Surface it loudly; the served prompt stays the frozen snapshot.
1098
+
992
1099
  if (
993
1100
  stablePromptSnapshots.wouldChurn(
994
1101
  resolvedSessionKey,
@@ -1004,7 +1111,13 @@ function createRelay(opts) {
1004
1111
  () => ({ sessionId: sessionId || null }),
1005
1112
  );
1006
1113
  }
1007
- 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;
1008
1121
  }
1009
1122
 
1010
1123
  function buildOcuClawSendDiagnostic(params = {}) {
@@ -1066,6 +1179,36 @@ function createRelay(opts) {
1066
1179
  return userContent;
1067
1180
  }
1068
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
+
1069
1212
  function buildOcuClawInitialSessionConfigPatch(settings) {
1070
1213
  const patch = {};
1071
1214
  if (settings && typeof settings.defaultModel === "string" && settings.defaultModel.trim()) {
@@ -1084,6 +1227,30 @@ function createRelay(opts) {
1084
1227
  return Object.keys(patch).length > 0 ? patch : null;
1085
1228
  }
1086
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
+
1087
1254
  async function maybeSeedOcuClawSessionConfig(sessionKey) {
1088
1255
  if (
1089
1256
  !sessionKey ||
@@ -1095,6 +1262,7 @@ function createRelay(opts) {
1095
1262
  }
1096
1263
 
1097
1264
  const settings = ocuClawSettingsStore.getSnapshot();
1265
+ seedSessionAgentDefault(sessionKey, settings.defaultAgent);
1098
1266
  const patch = buildOcuClawInitialSessionConfigPatch(settings);
1099
1267
  if (!patch) {
1100
1268
  sessionService.clearPendingInitialConfig(sessionKey);
@@ -1122,6 +1290,7 @@ function createRelay(opts) {
1122
1290
  }
1123
1291
 
1124
1292
  const settings = ocuClawSettingsStore.getSnapshot();
1293
+ seedSessionAgentDefault(sessionKey, settings.defaultAgent);
1125
1294
  const patch = buildOcuClawInitialSessionConfigPatch(settings);
1126
1295
  if (!patch) {
1127
1296
  return null;
@@ -1154,6 +1323,17 @@ function createRelay(opts) {
1154
1323
  getAgentName() {
1155
1324
  return upstreamRuntime ? upstreamRuntime.getAgentName() : null;
1156
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
+ },
1157
1337
  isPinnedFirstUserMessageKey(sessionKey) {
1158
1338
  const normalizedSessionKey = normalizeEvenAiSessionKeyForLookup(sessionKey);
1159
1339
  if (!normalizedSessionKey) {
@@ -1247,13 +1427,6 @@ function createRelay(opts) {
1247
1427
  }
1248
1428
  }
1249
1429
 
1250
- // TTL fallback for set_session_title activity label. The tool itself
1251
- // completes in <50ms but its label can linger if no follow-up activity
1252
- // arrives (e.g. agent streams a response directly after, with no
1253
- // intervening activity event). After 1s, synthesize a thinking-status
1254
- // activity with no tool/label so the renderer falls back to the bare
1255
- // animated spinner. Any real activity arriving in the meantime cancels
1256
- // the timer.
1257
1430
  const SESSION_TITLE_STATUS_FALLBACK_MS = 1500;
1258
1431
  let sessionTitleStatusFallbackTimer = null;
1259
1432
 
@@ -1262,6 +1435,10 @@ function createRelay(opts) {
1262
1435
  const runId = activity && activity.runId ? activity.runId : null;
1263
1436
  const origin = activity && activity.origin ? activity.origin : null;
1264
1437
  const phase = activity && activity.phase ? activity.phase : null;
1438
+ agentTurnTracker.onActivity(
1439
+ (activity && activity.sessionKey) || sessionService.ensureSessionKey(),
1440
+ phase,
1441
+ );
1265
1442
 
1266
1443
  emitDebug(
1267
1444
  "app.timeline",
@@ -1324,6 +1501,20 @@ function createRelay(opts) {
1324
1501
  return snapshot;
1325
1502
  }
1326
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
+
1327
1518
  const appClientDisconnectHandlers = new Set();
1328
1519
  function onAppClientDisconnect(handler) {
1329
1520
  if (typeof handler !== "function") return () => {};
@@ -1348,6 +1539,7 @@ function createRelay(opts) {
1348
1539
  surfaceId: params && typeof params.surfaceId === "string" ? params.surfaceId : "",
1349
1540
  depth: Number.isFinite(params && params.depth) ? Math.floor(params.depth) : 1,
1350
1541
  spec: params && params.spec ? params.spec : null,
1542
+ marker: sanitizeGlassesMarker(params && params.marker),
1351
1543
  };
1352
1544
  server.broadcast(JSON.stringify(payload));
1353
1545
  emitDebug(
@@ -1367,9 +1559,7 @@ function createRelay(opts) {
1367
1559
  if (typeof patch.title === "string") cleanPatch.title = patch.title;
1368
1560
  if (typeof patch.body === "string") cleanPatch.body = patch.body;
1369
1561
  if (Array.isArray(patch.items)) {
1370
- // Items may be plain-string labels (list_surface / label-only) OR
1371
- // {label, body} objects (list_with_details detail-body ticks). Keep both
1372
- // shapes; drop anything malformed (no string, no string label).
1562
+
1373
1563
  cleanPatch.items = patch.items
1374
1564
  .map((i) => {
1375
1565
  if (typeof i === "string") return i;
@@ -1382,6 +1572,7 @@ function createRelay(opts) {
1382
1572
  })
1383
1573
  .filter((i) => i !== null);
1384
1574
  }
1575
+ const m = sanitizeGlassesMarker(patch.marker); if (m) cleanPatch.marker = m;
1385
1576
  const payload = {
1386
1577
  type: "glasses_ui_surface_update",
1387
1578
  sessionKey: params && typeof params.sessionKey === "string" ? params.sessionKey : null,
@@ -1542,8 +1733,9 @@ function createRelay(opts) {
1542
1733
  );
1543
1734
 
1544
1735
  return maybeSeedOcuClawSessionConfig(resolvedSessionKey).then(() => {
1545
- // Dispatch upstream first so local transcript work cannot delay first
1546
- // model tokens on large histories.
1736
+
1737
+ agentTurnTracker.markBusy(resolvedSessionKey);
1738
+
1547
1739
  const upstreamPromise = gatewayBridge.sendMessage(
1548
1740
  text,
1549
1741
  resolvedSessionKey,
@@ -1551,11 +1743,7 @@ function createRelay(opts) {
1551
1743
  {
1552
1744
  ...stableSendOptions(
1553
1745
  resolvedSessionKey,
1554
- // No synchronous OpenClaw sessionId is available at send time
1555
- // (resolveSessionCanonicalKey is async). Use the sessionKey as the
1556
- // snapshot's id; the sessionId-mismatch guard is therefore a no-op,
1557
- // and new-session safety rests on logical-session-end eviction
1558
- // (onNewSession / onNewChat / onDeleteSessions evict the snapshot).
1746
+
1559
1747
  resolvedSessionKey,
1560
1748
  clientDisplaySignals,
1561
1749
  ),
@@ -1657,6 +1845,107 @@ function createRelay(opts) {
1657
1845
  });
1658
1846
  }
1659
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
+
1660
1949
  function emitListenInterceptRecovery(params = {}) {
1661
1950
  const connectedAppClients = server ? server.getConnectedAppCount() : 0;
1662
1951
  if (!server || !handler) {
@@ -1687,9 +1976,6 @@ function createRelay(opts) {
1687
1976
  server.broadcast(handler.formatEvenAiListenIntercepted(sessionKey));
1688
1977
  }
1689
1978
 
1690
- // --- Downstream handler ---
1691
-
1692
- /** @type {ReturnType<typeof createRelayWorkerSupervisor>|null} */
1693
1979
  let server = null;
1694
1980
  let evenAiEndpoint = null;
1695
1981
  let evenAiRouter = null;
@@ -1713,12 +1999,6 @@ function createRelay(opts) {
1713
1999
  return { ok: true, enabled, persisted, persistedPath: liveUiTraceFlagPath };
1714
2000
  }
1715
2001
 
1716
- // Persist the current debug-store arm to debug-arm.json. Mirrors the
1717
- // applyTraceLogSet writeFileSync above (plain, non-atomic): a partial/corrupt
1718
- // write degrades to an empty arm on next boot — acceptable, the nothing-armed
1719
- // warning catches it. getSnapshot().enabled is already pruned of expired
1720
- // categories, so the persisted JSON never holds an expired entry. Never throws
1721
- // into the caller.
1722
2002
  function persistDebugArm() {
1723
2003
  if (!debugArmStatePath) return false;
1724
2004
  try {
@@ -1736,9 +2016,7 @@ function createRelay(opts) {
1736
2016
  if (!result.ok) {
1737
2017
  throw new Error(result.error || "debug-set failed");
1738
2018
  }
1739
- // Persist after every successful set — enable AND disable-to-empty — so the
1740
- // on-disk arm always tracks live state and a deliberately-cleared arm is not
1741
- // resurrected on the next restart.
2019
+
1742
2020
  persistDebugArm();
1743
2021
  emitDebug(
1744
2022
  "relay.protocol",
@@ -1764,15 +2042,7 @@ function createRelay(opts) {
1764
2042
  if (kind === "status") return statusRevision;
1765
2043
  return null;
1766
2044
  },
1767
- /**
1768
- * Forward a user message to the upstream OpenClaw agent.
1769
- *
1770
- * @param {string} id - Message ID
1771
- * @param {string} text - User message text
1772
- * @param {string|null} sessionKey - Session key
1773
- * @param {object|null} attachment - Optional image attachment payload
1774
- * @returns {Promise}
1775
- */
2045
+
1776
2046
  onSend(id, text, sessionKey, attachment, clientDisplaySignals) {
1777
2047
  return dispatchOcuClawUserSend({
1778
2048
  id,
@@ -1783,6 +2053,17 @@ function createRelay(opts) {
1783
2053
  source: "phone_ui",
1784
2054
  });
1785
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
+ },
1786
2067
  onGlassesUiResult(frame) {
1787
2068
  emitDebug(
1788
2069
  "glasses.lifecycle",
@@ -1873,19 +2154,89 @@ function createRelay(opts) {
1873
2154
  }
1874
2155
  });
1875
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
+ },
1876
2238
  operationRegistry: relayOperationRegistry,
1877
2239
 
1878
- /**
1879
- * Inject a fake assistant message into conversation state.
1880
- *
1881
- * The sender name is used as a temporary agent name prefix for
1882
- * this message. If no agent identity has been established yet,
1883
- * the sender becomes the default agent name going forward.
1884
- *
1885
- * @param {string} sender - Display name for the simulated message
1886
- * @param {string} text - Message text
1887
- * @returns {Array<{content: string, subPage: [number, number]|null}>}
1888
- */
1889
2240
  onSimulate(sender, text) {
1890
2241
  emitDebug(
1891
2242
  "relay.protocol",
@@ -1897,7 +2248,7 @@ function createRelay(opts) {
1897
2248
  textChars: typeof text === "string" ? text.length : 0,
1898
2249
  }),
1899
2250
  );
1900
- // Add with per-message name override (doesn't affect other messages' prefix)
2251
+
1901
2252
  conversationState.addMessage("assistant", [{ type: "text", text }], sender || "Simulator");
1902
2253
 
1903
2254
  const pages = conversationState.getPages();
@@ -2015,12 +2366,6 @@ function createRelay(opts) {
2015
2366
  });
2016
2367
  },
2017
2368
 
2018
- /**
2019
- * Clear conversation state, reset cached pages, and send /new to OpenClaw.
2020
- * Legacy support: delegates to newSession.
2021
- *
2022
- * @returns {Promise<Array>} Empty pages array
2023
- */
2024
2369
  onNewChat() {
2025
2370
  emitDebug(
2026
2371
  "relay.session",
@@ -2034,15 +2379,10 @@ function createRelay(opts) {
2034
2379
  }
2035
2380
  sessionService.invalidateSessionsCache();
2036
2381
  resetActivityStatusAdapter();
2037
- // Cancel THIS session's pending simulate-stream timers BEFORE clearing —
2038
- // a deferred addMessage firing after the clear repopulates the fresh chat
2039
- // (the 2026-05-15 canary-pollution mechanism).
2382
+
2040
2383
  clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
2041
2384
  conversationState.clear();
2042
- // Logical session end: this key is REUSED for a fresh conversation, so the
2043
- // Channel-1 snapshot must be dropped or the next send serves a stale prompt,
2044
- // and ALL other per-session state keyed to it (title + upstream name,
2045
- // toggles, distiller budget, first-user marker) must be cleared too.
2385
+
2046
2386
  const newChatSessionKey = sessionService.ensureSessionKey();
2047
2387
  stablePromptSnapshots.evict(newChatSessionKey);
2048
2388
  sessionService.clearLogicalSessionState(newChatSessionKey);
@@ -2052,12 +2392,7 @@ function createRelay(opts) {
2052
2392
  const pages = conversationState.getPages();
2053
2393
  cachePages(pages);
2054
2394
  if (upstreamRuntime && upstreamRuntime.isConnected()) {
2055
- // NOTE: onNewChat targets the hard-coded "main" key (legacy) without
2056
- // changing currentSessionKey, so it must NOT elicit a welcome turn here:
2057
- // the turn's events would carry "main" and be dropped by isCurrentSession()
2058
- // whenever the active session is an ocuclaw:* key. The welcome restore for
2059
- // the real glasses paths lives in newSession() (New) and onSlashCommand
2060
- // "/reset" (Reset). Unifying onNewChat onto newSession() is Phase-2 work.
2395
+
2061
2396
  gatewayBridge.sendMessage("/new", "main").catch((err) => {
2062
2397
  logger.error(`[relay] Failed to send /new: ${err.message}`);
2063
2398
  });
@@ -2083,16 +2418,10 @@ function createRelay(opts) {
2083
2418
  },
2084
2419
 
2085
2420
  async onNewSession() {
2086
- // Cancel pending simulate-stream timers scheduled under the outgoing key
2087
- // BEFORE the new key is minted — a deferred addMessage firing after the
2088
- // switch would repopulate the fresh session's shared conversation view.
2421
+
2089
2422
  clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
2090
2423
  const result = await sessionService.newSession();
2091
- // newSession() mints a FRESH key; defensively clear only the NEW key (it
2092
- // has no snapshot yet). Do NOT touch the previous key: that session stays
2093
- // resumable via onSwitchSession, and dropping its frozen snapshot would
2094
- // recompute — and churn — Channel 1 if the user switches back to it. The
2095
- // previous session's snapshot is released by delete or the TTL sweep.
2424
+
2096
2425
  if (result && typeof result.sessionKey === "string" && result.sessionKey.trim()) {
2097
2426
  stablePromptSnapshots.evict(result.sessionKey);
2098
2427
  sessionService.clearDisplayToggleStates(result.sessionKey);
@@ -2127,6 +2456,20 @@ function createRelay(opts) {
2127
2456
  : Promise.resolve({ skills: [], fetchedAtMs: Date.now(), stale: true });
2128
2457
  },
2129
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
+
2130
2473
  onGetProviderUsageSnapshot() {
2131
2474
  return upstreamRuntime
2132
2475
  ? upstreamRuntime.getProviderUsageSnapshot()
@@ -2167,6 +2510,32 @@ function createRelay(opts) {
2167
2510
  return result;
2168
2511
  },
2169
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
+
2170
2539
  onGetEvenAiSettings() {
2171
2540
  return evenAiSettingsStore.getSnapshot();
2172
2541
  },
@@ -2216,23 +2585,15 @@ function createRelay(opts) {
2216
2585
  );
2217
2586
  broadcastPages();
2218
2587
  }
2219
- // A user-typed /new or /reset is a logical session reset on the CURRENT
2220
- // key (distinct from an automatic CLI session reset, which keeps the same
2221
- // logical session and must survive). Drop the frozen Channel-1 snapshot so
2222
- // the next real message recomputes it for the fresh conversation; otherwise
2223
- // the old conversation's prompt + display start-state bleed into the new one.
2588
+
2224
2589
  if (command === "/new" || command === "/reset") {
2225
2590
  const resetKey = sessionService.ensureSessionKey();
2226
2591
  stablePromptSnapshots.evict(resetKey);
2227
- // Clear ALL per-session state keyed to the reused key (title + upstream
2228
- // name, toggles, distiller budget, first-user marker) so nothing from the
2229
- // old conversation bleeds into the fresh one.
2592
+
2230
2593
  sessionService.clearLogicalSessionState(resetKey);
2231
2594
  }
2232
2595
  if (upstreamRuntime && upstreamRuntime.isConnected()) {
2233
- // Bare /reset no longer elicits an agent turn on OpenClaw 2026.6.x
2234
- // (fast-reset). Append the greeting prompt so Reset gets the same
2235
- // welcome as New. Other slash commands forward verbatim.
2596
+
2236
2597
  const outboundCommand =
2237
2598
  command === "/reset"
2238
2599
  ? `/reset ${NEW_SESSION_GREETING_PROMPT}`
@@ -2245,9 +2606,6 @@ function createRelay(opts) {
2245
2606
  return Promise.resolve();
2246
2607
  },
2247
2608
 
2248
- /**
2249
- * @returns {boolean} Whether upstream is connected.
2250
- */
2251
2609
  isUpstreamConnected() {
2252
2610
  return true;
2253
2611
  },
@@ -2298,6 +2656,10 @@ function createRelay(opts) {
2298
2656
  return mintSonioxTemporaryKey(clientId, request);
2299
2657
  },
2300
2658
 
2659
+ onRequestCartesiaAccessToken(clientId, request) {
2660
+ return mintCartesiaAccessToken(clientId, request);
2661
+ },
2662
+
2301
2663
  onDebugSet(clientId, request) {
2302
2664
  return applyDebugSet(clientId, request);
2303
2665
  },
@@ -2545,14 +2907,7 @@ function createRelay(opts) {
2545
2907
  },
2546
2908
 
2547
2909
  onAutomationState(clientId, request) {
2548
- // Mirrors onReadinessProbe (above): identify the single connected app
2549
- // client via the readiness snapshot, then return a dispatch envelope
2550
- // that downstream-handler.handleAutomationState wraps into
2551
- // `automationStateRequest`. Without this callback wired, the handler
2552
- // returns null and the request is silently dropped at the relay —
2553
- // simctl/debugctl times out with no failure response, no trace event,
2554
- // no outbox drop. The lack of wiring was found 2026-05-28 while
2555
- // validating the streaming-thinking-emoji-demotion fix on the sim.
2910
+
2556
2911
  const now = Date.now();
2557
2912
  const requestId =
2558
2913
  (typeof request.requestId === "string" && request.requestId.trim()) ||
@@ -2581,11 +2936,7 @@ function createRelay(opts) {
2581
2936
  targetEntry && typeof targetEntry.clientId === "string"
2582
2937
  ? targetEntry.clientId
2583
2938
  : null;
2584
- // A connected app client that has never published a readiness snapshot
2585
- // cannot answer an automation state request; forwarding anyway would
2586
- // park the request in pendingAutomationStateRequests with no reply.
2587
- // Same predicate as the downstream readiness gate; this wired callback
2588
- // bypasses the normal dispatch path.
2939
+
2589
2940
  const readinessPublished =
2590
2941
  !!(
2591
2942
  targetEntry &&
@@ -2672,10 +3023,13 @@ function createRelay(opts) {
2672
3023
  },
2673
3024
  });
2674
3025
 
2675
- // --- Worker supervisor ---
2676
-
2677
3026
  const pluginVersionService = createPluginVersionService();
2678
3027
 
3028
+ const glassesBackpressureLatch = createGlassesBackpressureLatch({
3029
+ emitDebug: (event, severity, data) =>
3030
+ emitDebug("relay.health", event, severity, null, () => data || {}),
3031
+ });
3032
+
2679
3033
  server = createRelayWorkerSupervisor({
2680
3034
  pluginId: "ocuclaw",
2681
3035
  getPluginVersion: () => pluginVersionService.getPluginVersion(),
@@ -2686,6 +3040,7 @@ function createRelay(opts) {
2686
3040
  host: opts.host,
2687
3041
  port: opts.port,
2688
3042
  token: opts.token,
3043
+ onWorkerBackpressure: (message) => glassesBackpressureLatch.report(message),
2689
3044
  externalDebugToolsEnabled,
2690
3045
  evenAiRequestTimeoutMs: opts.evenAiRequestTimeoutMs,
2691
3046
  evenAiMaxBodyBytes: opts.evenAiMaxBodyBytes,
@@ -2730,8 +3085,6 @@ function createRelay(opts) {
2730
3085
  },
2731
3086
  });
2732
3087
 
2733
- // --- Helpers ---
2734
-
2735
3088
  function buildStatusObject(options = {}) {
2736
3089
  const includeDownstreamReadiness = options.includeDownstreamReadiness === true;
2737
3090
  const status = {
@@ -2779,9 +3132,6 @@ function createRelay(opts) {
2779
3132
  return cachedStatus;
2780
3133
  }
2781
3134
 
2782
- /**
2783
- * Recompute pages from conversation state, cache, and broadcast.
2784
- */
2785
3135
  function broadcastPages() {
2786
3136
  const pages = conversationState.getPages();
2787
3137
  const next = cachePages(pages);
@@ -2790,12 +3140,6 @@ function createRelay(opts) {
2790
3140
  }
2791
3141
  }
2792
3142
 
2793
- /**
2794
- * Fetch the latest sessions snapshot and broadcast it. Used after a session
2795
- * title changes so connected clients refresh the title in the main webui
2796
- * status row and Session Settings tab without waiting for a manual
2797
- * session-list open.
2798
- */
2799
3143
  function broadcastSessions() {
2800
3144
  sessionService
2801
3145
  .getSessions()
@@ -2813,10 +3157,6 @@ function createRelay(opts) {
2813
3157
  });
2814
3158
  }
2815
3159
 
2816
- /**
2817
- * Resolve the current Even AI sessions snapshot for unicast/broadcast.
2818
- * Mirrors the shape that `formatEvenAiSessions` expects.
2819
- */
2820
3160
  async function buildEvenAiSessionsSnapshot() {
2821
3161
  const dedicatedKey =
2822
3162
  evenAiRouter && typeof evenAiRouter.getDedicatedSessionKey === "function"
@@ -2881,9 +3221,6 @@ function createRelay(opts) {
2881
3221
  });
2882
3222
  }
2883
3223
 
2884
- /**
2885
- * Build, cache, and broadcast the current status.
2886
- */
2887
3224
  function broadcastStatus() {
2888
3225
  const next = cacheStatus(buildStatusObject());
2889
3226
  if (next !== null) {
@@ -2916,6 +3253,7 @@ function createRelay(opts) {
2916
3253
  broadcastStatus,
2917
3254
  broadcastActivity,
2918
3255
  broadcastProviderUsageSnapshot,
3256
+ broadcastAgentsCatalog,
2919
3257
  operationRegistry: relayOperationRegistry,
2920
3258
  getCurrentSessionModelConfigSnapshot() {
2921
3259
  return currentSessionModelConfigSnapshot;
@@ -2933,10 +3271,6 @@ function createRelay(opts) {
2933
3271
  fetchAgentAvatar: opts.fetchAgentAvatar,
2934
3272
  });
2935
3273
 
2936
- // Shared routing gate for session-scoped Even AI defaults (thinking seed,
2937
- // fast-mode patch): never touch active-routed sessions; always seed fresh
2938
- // background_new sessions; seed persistent background sessions only before
2939
- // their first turn exists.
2940
3274
  async function shouldSeedSessionScopedDefaultForRoute(route) {
2941
3275
  const routingMode =
2942
3276
  route && typeof route.routingMode === "string"
@@ -3045,6 +3379,29 @@ function createRelay(opts) {
3045
3379
  );
3046
3380
  return !!(result && result.status === "accepted");
3047
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
+ },
3048
3405
  onSessionActivated(route) {
3049
3406
  if (!route || !route.sessionChanged) {
3050
3407
  return;
@@ -3105,34 +3462,45 @@ function createRelay(opts) {
3105
3462
  return true;
3106
3463
  }
3107
3464
 
3108
- // --- Public API ---
3109
-
3110
3465
  relayApi = {
3111
- /**
3112
- * Emit a glasses-UI surface-lifecycle event on the permanent
3113
- * `glasses.lifecycle` debug category (nav reconcile + cron pause/resume/
3114
- * tick). Recorded only when the category is enabled via debug-set. Wired
3115
- * through the relay-service facade into the glasses-ui tool handler + cron
3116
- * engine. See docs/superpowers/findings/2026-05-30-glasses-ui-phase4-hardware.md.
3117
- */
3466
+
3118
3467
  emitGlassesUiLifecycle(event, severity, data) {
3119
3468
  emitDebug("glasses.lifecycle", event, severity, {}, () => data || {});
3120
3469
  },
3121
- /**
3122
- * Start the upstream OpenClaw connection.
3123
- * The downstream server is already listening from construction.
3124
- */
3470
+
3125
3471
  start() {
3126
- // 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
+
3127
3478
  if (!stablePromptSweepTimer) {
3128
3479
  stablePromptSweepTimer = setInterval(
3129
3480
  () => stablePromptSnapshots.sweep(),
3130
- 60 * 60 * 1000, // hourly
3481
+ 60 * 60 * 1000,
3131
3482
  );
3132
3483
  if (typeof stablePromptSweepTimer.unref === "function") {
3133
3484
  stablePromptSweepTimer.unref();
3134
3485
  }
3135
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
+ }
3136
3504
  const startGateway = () => Promise.resolve(gatewayBridge.start()).then(() => {
3137
3505
  prefetchSonioxModels("relay_start").catch((err) => {
3138
3506
  logger.warn(`[relay] Soniox models prefetch failed: ${err.message}`);
@@ -3147,17 +3515,20 @@ function createRelay(opts) {
3147
3515
  return startGateway();
3148
3516
  },
3149
3517
 
3150
- /**
3151
- * Stop the upstream connection and shut down the downstream server.
3152
- *
3153
- * @returns {Promise<void>}
3154
- */
3155
3518
  stop() {
3156
3519
  clearSimulateStreamTimers();
3520
+ if (bundleCacheSweepTimer) {
3521
+ clearInterval(bundleCacheSweepTimer);
3522
+ bundleCacheSweepTimer = null;
3523
+ }
3157
3524
  if (stablePromptSweepTimer) {
3158
3525
  clearInterval(stablePromptSweepTimer);
3159
3526
  stablePromptSweepTimer = null;
3160
3527
  }
3528
+ if (uploadCaptureArmingDisposer) {
3529
+ uploadCaptureArmingDisposer();
3530
+ uploadCaptureArmingDisposer = null;
3531
+ }
3161
3532
  if (evenAiEndpoint) {
3162
3533
  evenAiEndpoint.close();
3163
3534
  }
@@ -3184,7 +3555,6 @@ function createRelay(opts) {
3184
3555
 
3185
3556
  handleBufferedEvenAiHttpRequest,
3186
3557
 
3187
- /** The downstream server instance. */
3188
3558
  get server() {
3189
3559
  return server;
3190
3560
  },
@@ -3245,7 +3615,6 @@ function createRelay(opts) {
3245
3615
  return sessionService.getDisplayCurrentStates(sessionKey);
3246
3616
  },
3247
3617
 
3248
- // Accessors used by the session-title distiller sidecar.
3249
3618
  getSessionTitleRecord(sessionKey) {
3250
3619
  return sessionService.getSessionTitleRecord(sessionKey);
3251
3620
  },
@@ -3258,12 +3627,7 @@ function createRelay(opts) {
3258
3627
  getDistillerBudget() {
3259
3628
  return sessionService.getDistillerBudget();
3260
3629
  },
3261
- // Canonical-key cleanup for the distiller's throwaway upstream session.
3262
- // The native subagent deleteSession passes the bare key straight to
3263
- // sessions.delete, which the 2026.6.x gateway indexes under the canonical
3264
- // agent:<id>: form — the bare-key delete silently no-ops and the
3265
- // excerpt-bearing transcript survives. deleteSessions() resolves the
3266
- // canonical key via sessions.resolve first.
3630
+
3267
3631
  deleteDistillerSession(sessionKey) {
3268
3632
  return sessionService.deleteSessions("ocuclaw", [sessionKey]);
3269
3633
  },
@@ -3284,11 +3648,6 @@ function createRelay(opts) {
3284
3648
  return sessionService.peekSessionKey();
3285
3649
  },
3286
3650
 
3287
- /**
3288
- * Test/shutdown hook: resolves once the async first-user-message cache
3289
- * write has fully drained (no write in flight, no dirty mark pending) so
3290
- * the on-disk file reflects the latest in-memory map.
3291
- */
3292
3651
  flushFirstSentUserMessageCache() {
3293
3652
  return sessionService.flushFirstSentUserMessageCache();
3294
3653
  },
@@ -3305,20 +3664,10 @@ function createRelay(opts) {
3305
3664
  return result;
3306
3665
  },
3307
3666
 
3308
- /**
3309
- * Test-only: direct access to dispatchOcuClawUserSend so integration
3310
- * tests can drive per-turn signal plumbing without a live downstream
3311
- * WebSocket connection.
3312
- */
3313
3667
  _dispatchOcuClawUserSend(params) {
3314
3668
  return dispatchOcuClawUserSend(params || {});
3315
3669
  },
3316
3670
 
3317
- /**
3318
- * Test-only: run the logical-reset state clear (the same call the /new,
3319
- * /reset, and new-chat paths make) so integration tests can verify all
3320
- * per-session state is dropped for a reused session key.
3321
- */
3322
3671
  _clearLogicalSessionState(sessionKey) {
3323
3672
  sessionService.clearLogicalSessionState(sessionKey);
3324
3673
  },
@@ -3331,6 +3680,39 @@ function createRelay(opts) {
3331
3680
  sendGlassesUiSurfaceUpdate(params);
3332
3681
  },
3333
3682
 
3683
+ dispatchGlassesWake(params) {
3684
+ const sessionKey =
3685
+ params && typeof params.sessionKey === "string" && params.sessionKey
3686
+ ? params.sessionKey
3687
+ : sessionService.ensureSessionKey();
3688
+ const message = params && typeof params.message === "string" ? params.message : "";
3689
+ if (!message) {
3690
+ return Promise.reject(new Error("dispatchGlassesWake requires a message"));
3691
+ }
3692
+ const idempotencyKey =
3693
+ params && typeof params.idempotencyKey === "string" && params.idempotencyKey
3694
+ ? params.idempotencyKey
3695
+ : null;
3696
+ agentTurnTracker.markBusy(sessionKey);
3697
+ emitDebug(
3698
+ "relay.protocol",
3699
+ "glasses_wake_dispatch",
3700
+ "info",
3701
+ { sessionKey },
3702
+ () => ({
3703
+ idempotencyKey,
3704
+ messageChars: message.length,
3705
+ }),
3706
+ );
3707
+ const requestParams = { message, sessionKey };
3708
+ if (idempotencyKey) requestParams.idempotencyKey = idempotencyKey;
3709
+ return gatewayBridge.request("agent", requestParams, { expectFinal: false });
3710
+ },
3711
+
3712
+ isAgentTurnBusy(sessionKey) {
3713
+ return agentTurnTracker.isBusy(sessionKey);
3714
+ },
3715
+
3334
3716
  onGlassesUiResult(handler) {
3335
3717
  return onGlassesUiResult(handler);
3336
3718
  },
@@ -3351,6 +3733,10 @@ function createRelay(opts) {
3351
3733
  return server ? server.getConnectedAppCount() > 0 : false;
3352
3734
  },
3353
3735
 
3736
+ isGlassesSendBufferOverHighWater() {
3737
+ return glassesBackpressureLatch.isOverHighWater();
3738
+ },
3739
+
3354
3740
  onAppClientDisconnect(handler) {
3355
3741
  return onAppClientDisconnect(handler);
3356
3742
  },