ocuclaw 1.3.1 → 1.3.3

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 (40) hide show
  1. package/dist/config/runtime-config-session-title-model.test.js +22 -0
  2. package/dist/config/runtime-config.js +7 -1
  3. package/dist/domain/glasses-display-system-prompt.js +52 -0
  4. package/dist/domain/glasses-display-system-prompt.test.js +44 -0
  5. package/dist/domain/glasses-ui-system-prompt.js +6 -22
  6. package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
  7. package/dist/domain/prompt-channel-fragments.js +32 -0
  8. package/dist/domain/prompt-channel-fragments.test.js +70 -0
  9. package/dist/gateway/gateway-timing-ledger.js +15 -3
  10. package/dist/gateway/openclaw-client.js +80 -3
  11. package/dist/index.js +22 -0
  12. package/dist/runtime/channel-two-hook.js +36 -0
  13. package/dist/runtime/container-env.js +41 -0
  14. package/dist/runtime/display-toggle-states.js +98 -0
  15. package/dist/runtime/glasses-backpressure-latch.js +115 -0
  16. package/dist/runtime/register-session-title-distiller.js +100 -0
  17. package/dist/runtime/relay-core.js +284 -33
  18. package/dist/runtime/relay-service.js +152 -13
  19. package/dist/runtime/relay-worker-entry.js +26 -0
  20. package/dist/runtime/relay-worker-supervisor.js +51 -2
  21. package/dist/runtime/relay-worker-transport.js +51 -1
  22. package/dist/runtime/session-service.js +136 -12
  23. package/dist/runtime/session-title-distiller-budget.js +36 -0
  24. package/dist/runtime/session-title-distiller-helpers.js +130 -0
  25. package/dist/runtime/session-title-distiller.js +354 -0
  26. package/dist/runtime/session-title-record.js +21 -0
  27. package/dist/runtime/stable-prompt-snapshot.js +119 -0
  28. package/dist/tools/glasses-ui-cron.js +59 -3
  29. package/dist/tools/glasses-ui-paint-floor.js +33 -4
  30. package/dist/tools/glasses-ui-surfaces.js +369 -35
  31. package/dist/tools/glasses-ui-tool-description.test.js +16 -0
  32. package/dist/tools/glasses-ui-tool.js +662 -80
  33. package/dist/tools/glasses-ui-voicemail.js +299 -0
  34. package/dist/tools/glasses-ui-wake.js +262 -0
  35. package/dist/tools/session-title-tool.js +14 -76
  36. package/dist/tools/session-title-tool.test.js +53 -0
  37. package/dist/version.js +2 -2
  38. package/openclaw.plugin.json +9 -0
  39. package/package.json +4 -3
  40. package/skills/glasses-ui/SKILL.md +26 -3
@@ -9,6 +9,8 @@ import { composeReadabilitySystemPrompt } from "../domain/readability-system-pro
9
9
  import { composeNeuralEmojiReactorSystemPrompt } from "../domain/neural-emoji-reactor-system-prompt.js";
10
10
  import { composeNeuralPaceModulatorSystemPrompt } from "../domain/neural-pace-modulator-system-prompt.js";
11
11
  import { composeGlassesUiNudgeSystemPrompt } from "../domain/glasses-ui-system-prompt.js";
12
+ import { composeGlassesDisplaySystemPrompt } from "../domain/glasses-display-system-prompt.js";
13
+ import { createStablePromptSnapshotStore } from "./stable-prompt-snapshot.js";
12
14
  import { createActivityStatusAdapter } from "../domain/activity-status-adapter.js";
13
15
  import { createEvenAiEndpoint } from "../even-ai/even-ai-endpoint.js";
14
16
  import { createEvenAiRouter } from "../even-ai/even-ai-router.js";
@@ -16,9 +18,11 @@ import { createEvenAiRunWaiter } from "../even-ai/even-ai-run-waiter.js";
16
18
  import { createEvenAiSettingsStore } from "../even-ai/even-ai-settings-store.js";
17
19
  import { createPluginOpenclawClient } from "../gateway/openclaw-client.js";
18
20
  import { createPluginRpcGatewayBridge } from "../gateway/gateway-bridge.js";
21
+ import { createAgentTurnTracker } from "../tools/glasses-ui-wake.js";
19
22
  import { createDownstreamHandler } from "./downstream-handler.js";
20
23
  import { createOcuClawSettingsStore } from "./ocuclaw-settings-store.js";
21
24
  import { createRelayHealthMonitor } from "./relay-health-monitor.js";
25
+ import { createGlassesBackpressureLatch } from "./glasses-backpressure-latch.js";
22
26
  import { createRelayOperationRegistry } from "./relay-operation-registry.js";
23
27
  import { createRelayWorkerSupervisor } from "./relay-worker-supervisor.js";
24
28
  import {
@@ -27,6 +31,9 @@ import {
27
31
  } from "./session-service.js";
28
32
  import { createUpstreamRuntime } from "./upstream-runtime.js";
29
33
 
34
+ const GLASSES_UI_MARKERS = new Set(["listening", "parked", "inflight"]);
35
+ export function sanitizeGlassesMarker(v) { return GLASSES_UI_MARKERS.has(v) ? v : undefined; }
36
+
30
37
  const SONIOX_TEMP_KEY_URL = "https://api.soniox.com/v1/auth/temporary-api-key";
31
38
  const SONIOX_MODELS_URL = "https://api.soniox.com/v1/models";
32
39
  const DEFAULT_SONIOX_TEMP_KEY_EXPIRES_IN_SECONDS = 3600;
@@ -340,6 +347,12 @@ function createRelay(opts) {
340
347
  const activityStatusAdapter = createActivityStatusAdapter(
341
348
  opts.activityStatusAdapter,
342
349
  );
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).
355
+ const agentTurnTracker = createAgentTurnTracker();
343
356
  const sharedHttpServer = opts.httpServer || null;
344
357
 
345
358
  // --- Cached state ---
@@ -359,7 +372,9 @@ function createRelay(opts) {
359
372
  /** Relay-local deterministic simulate-stream run sequence counter. */
360
373
  let simulateStreamRunSeq = 0;
361
374
  /** Active timers for relay-local deterministic simulate-stream runs. */
362
- const simulateStreamTimers = new Set();
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();
363
378
 
364
379
  // --- Structured debug state ---
365
380
 
@@ -550,7 +565,7 @@ function createRelay(opts) {
550
565
  );
551
566
  }
552
567
 
553
- function scheduleSimulateStreamTimer(delayMs, callback) {
568
+ function scheduleSimulateStreamTimer(delayMs, callback, sessionKey) {
554
569
  const timer = setTimeout(() => {
555
570
  simulateStreamTimers.delete(timer);
556
571
  try {
@@ -559,12 +574,29 @@ function createRelay(opts) {
559
574
  logger.error(`[relay] simulate-stream timer failed: ${err.message}`);
560
575
  }
561
576
  }, delayMs);
562
- simulateStreamTimers.add(timer);
577
+ simulateStreamTimers.set(timer, typeof sessionKey === "string" ? sessionKey : null);
563
578
  return timer;
564
579
  }
565
580
 
581
+ function clearSimulateStreamTimersForSession(sessionKey) {
582
+ let cleared = 0;
583
+ for (const [timer, timerSessionKey] of simulateStreamTimers) {
584
+ if (timerSessionKey === sessionKey) {
585
+ clearTimeout(timer);
586
+ simulateStreamTimers.delete(timer);
587
+ cleared += 1;
588
+ }
589
+ }
590
+ if (cleared > 0) {
591
+ logger.info(
592
+ `[relay] cancelled ${cleared} pending simulate-stream timer(s) for session ${sessionKey}`,
593
+ );
594
+ }
595
+ return cleared;
596
+ }
597
+
566
598
  function clearSimulateStreamTimers() {
567
- for (const timer of simulateStreamTimers) {
599
+ for (const timer of simulateStreamTimers.keys()) {
568
600
  clearTimeout(timer);
569
601
  }
570
602
  simulateStreamTimers.clear();
@@ -923,37 +955,67 @@ function createRelay(opts) {
923
955
  systemPrompt: opts.ocuClawSystemPrompt,
924
956
  },
925
957
  });
926
-
927
- function currentOcuClawSendOptions(perTurnSignals) {
928
- const signals = perTurnSignals || {};
958
+ const stablePromptSnapshots = createStablePromptSnapshotStore({
959
+ stateDir: opts.stateDir,
960
+ emitDebug,
961
+ });
962
+ // Hourly TTL sweep for stale stable-prompt snapshots; started in start(),
963
+ // cleared in stop(). Declared here so both can see it.
964
+ let stablePromptSweepTimer = null;
965
+
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.
972
+ function computeStableChannelOne(startSignals) {
929
973
  const baseReadability = composeReadabilitySystemPrompt(
930
974
  ocuClawSettingsStore.getSnapshot().systemPrompt,
931
975
  );
932
- const validState = (raw) =>
933
- raw === "active" || raw === "recently-disabled" || raw === "inactive"
934
- ? raw
935
- : "inactive";
936
- const reactorState = validState(signals.neuralEmojiReactorState);
937
- const paceState = validState(signals.neuralPaceModulatorState);
938
- const reactor = composeNeuralEmojiReactorSystemPrompt({ state: reactorState });
939
- const pace = composeNeuralPaceModulatorSystemPrompt({ state: paceState });
940
- // Only include the glasses-UI nudge when a downstream app client is
941
- // connected. Keeps the prompt clean when the agent has nowhere to render
942
- // the tool's output, and keeps existing prompt-assembly tests stable
943
- // (they exercise the prompt without spinning up an app client).
944
- const hasAppClient =
945
- server &&
946
- typeof server.getConnectedAppCount === "function" &&
947
- server.getConnectedAppCount() > 0;
948
- const glassesUiNudge = hasAppClient ? composeGlassesUiNudgeSystemPrompt() : "";
976
+ const display = composeGlassesDisplaySystemPrompt({
977
+ emoji: startSignals.emoji,
978
+ pace: startSignals.pace,
979
+ });
980
+ const glassesPointer = composeGlassesUiNudgeSystemPrompt();
949
981
  const parts = [];
950
982
  if (baseReadability) parts.push(baseReadability);
951
- if (reactor) parts.push(reactor);
952
- if (pace) parts.push(pace);
953
- if (glassesUiNudge) parts.push(glassesUiNudge);
954
- return {
955
- extraSystemPrompt: parts.join("\n\n"),
956
- };
983
+ if (display) parts.push(display);
984
+ if (glassesPointer) parts.push(glassesPointer);
985
+ return parts.join("\n\n");
986
+ }
987
+
988
+ function stableSendOptions(resolvedSessionKey, sessionId, perTurnSignals) {
989
+ 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.
992
+ const startEmoji = signals.neuralEmojiReactorState === "active";
993
+ const startPace = signals.neuralPaceModulatorState === "active";
994
+ const extraSystemPrompt = stablePromptSnapshots.getOrCreate(
995
+ resolvedSessionKey,
996
+ sessionId,
997
+ () => computeStableChannelOne({ emoji: startEmoji, pace: startPace }),
998
+ );
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.
1003
+ if (
1004
+ stablePromptSnapshots.wouldChurn(
1005
+ resolvedSessionKey,
1006
+ sessionId,
1007
+ computeStableChannelOne({ emoji: startEmoji, pace: startPace }),
1008
+ )
1009
+ ) {
1010
+ emitDebug(
1011
+ "relay.session",
1012
+ "stable_prompt_churn_detected",
1013
+ "warn",
1014
+ { sessionKey: resolvedSessionKey },
1015
+ () => ({ sessionId: sessionId || null }),
1016
+ );
1017
+ }
1018
+ return { extraSystemPrompt };
957
1019
  }
958
1020
 
959
1021
  function buildOcuClawSendDiagnostic(params = {}) {
@@ -1211,6 +1273,10 @@ function createRelay(opts) {
1211
1273
  const runId = activity && activity.runId ? activity.runId : null;
1212
1274
  const origin = activity && activity.origin ? activity.origin : null;
1213
1275
  const phase = activity && activity.phase ? activity.phase : null;
1276
+ agentTurnTracker.onActivity(
1277
+ (activity && activity.sessionKey) || sessionService.ensureSessionKey(),
1278
+ phase,
1279
+ );
1214
1280
 
1215
1281
  emitDebug(
1216
1282
  "app.timeline",
@@ -1297,6 +1363,7 @@ function createRelay(opts) {
1297
1363
  surfaceId: params && typeof params.surfaceId === "string" ? params.surfaceId : "",
1298
1364
  depth: Number.isFinite(params && params.depth) ? Math.floor(params.depth) : 1,
1299
1365
  spec: params && params.spec ? params.spec : null,
1366
+ marker: sanitizeGlassesMarker(params && params.marker),
1300
1367
  };
1301
1368
  server.broadcast(JSON.stringify(payload));
1302
1369
  emitDebug(
@@ -1331,6 +1398,7 @@ function createRelay(opts) {
1331
1398
  })
1332
1399
  .filter((i) => i !== null);
1333
1400
  }
1401
+ const m = sanitizeGlassesMarker(patch.marker); if (m) cleanPatch.marker = m;
1334
1402
  const payload = {
1335
1403
  type: "glasses_ui_surface_update",
1336
1404
  sessionKey: params && typeof params.sessionKey === "string" ? params.sessionKey : null,
@@ -1465,6 +1533,10 @@ function createRelay(opts) {
1465
1533
  resolvedSessionKey,
1466
1534
  clientDisplaySignals.neuralSessionNamesEnabled !== false,
1467
1535
  );
1536
+ sessionService.recordDisplayToggleStates(resolvedSessionKey, {
1537
+ emoji: clientDisplaySignals.neuralEmojiReactorState === "active",
1538
+ pace: clientDisplaySignals.neuralPaceModulatorState === "active",
1539
+ });
1468
1540
  }
1469
1541
  const hasAttachment = !!attachment;
1470
1542
  const sendStartedAt = Date.now();
@@ -1487,6 +1559,9 @@ function createRelay(opts) {
1487
1559
  );
1488
1560
 
1489
1561
  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).
1564
+ agentTurnTracker.markBusy(resolvedSessionKey);
1490
1565
  // Dispatch upstream first so local transcript work cannot delay first
1491
1566
  // model tokens on large histories.
1492
1567
  const upstreamPromise = gatewayBridge.sendMessage(
@@ -1494,7 +1569,16 @@ function createRelay(opts) {
1494
1569
  resolvedSessionKey,
1495
1570
  attachment,
1496
1571
  {
1497
- ...currentOcuClawSendOptions(clientDisplaySignals),
1572
+ ...stableSendOptions(
1573
+ 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).
1579
+ resolvedSessionKey,
1580
+ clientDisplaySignals,
1581
+ ),
1498
1582
  diagnostic: buildOcuClawSendDiagnostic({
1499
1583
  ...params,
1500
1584
  sessionKey: resolvedSessionKey,
@@ -1768,6 +1852,14 @@ function createRelay(opts) {
1768
1852
  return upstreamRuntime.compactActiveSession(sessionKey);
1769
1853
  },
1770
1854
  onDeleteSessions(sessionKeys, kind, switchBeforeDelete) {
1855
+ if (Array.isArray(sessionKeys)) {
1856
+ for (const key of sessionKeys) {
1857
+ if (typeof key === "string" && key.trim()) {
1858
+ stablePromptSnapshots.evict(key);
1859
+ sessionService.clearDisplayToggleStates(key);
1860
+ }
1861
+ }
1862
+ }
1771
1863
  const action = switchBeforeDelete
1772
1864
  ? sessionService.switchAndDeleteSessions(kind, sessionKeys)
1773
1865
  : sessionService.deleteSessions(kind, sessionKeys);
@@ -1904,7 +1996,7 @@ function createRelay(opts) {
1904
1996
  totalChars: text.length,
1905
1997
  }),
1906
1998
  );
1907
- });
1999
+ }, sessionKey);
1908
2000
  }
1909
2001
 
1910
2002
  const completeDelayMs = startDelayMs + (chunkCount * chunkIntervalMs) + thinkingTailMs;
@@ -1935,7 +2027,7 @@ function createRelay(opts) {
1935
2027
  completeDelayMs,
1936
2028
  }),
1937
2029
  );
1938
- });
2030
+ }, sessionKey);
1939
2031
 
1940
2032
  return Promise.resolve({
1941
2033
  status: "accepted",
@@ -1962,7 +2054,18 @@ function createRelay(opts) {
1962
2054
  }
1963
2055
  sessionService.invalidateSessionsCache();
1964
2056
  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).
2060
+ clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
1965
2061
  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.
2066
+ const newChatSessionKey = sessionService.ensureSessionKey();
2067
+ stablePromptSnapshots.evict(newChatSessionKey);
2068
+ sessionService.clearLogicalSessionState(newChatSessionKey);
1966
2069
  conversationState.setAgentName(
1967
2070
  (upstreamRuntime ? upstreamRuntime.getAgentName() : null) || "Agent",
1968
2071
  );
@@ -2000,7 +2103,20 @@ function createRelay(opts) {
2000
2103
  },
2001
2104
 
2002
2105
  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.
2109
+ clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
2003
2110
  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.
2116
+ if (result && typeof result.sessionKey === "string" && result.sessionKey.trim()) {
2117
+ stablePromptSnapshots.evict(result.sessionKey);
2118
+ sessionService.clearDisplayToggleStates(result.sessionKey);
2119
+ }
2004
2120
  clearCurrentSessionModelConfigSnapshot("new_session");
2005
2121
  if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
2006
2122
  upstreamRuntime.clearTyping("new_session");
@@ -2110,6 +2226,7 @@ function createRelay(opts) {
2110
2226
  if (command === "/reset") {
2111
2227
  sessionService.invalidateSessionsCache();
2112
2228
  resetActivityStatusAdapter();
2229
+ clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
2113
2230
  conversationState.clear();
2114
2231
  if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
2115
2232
  upstreamRuntime.clearTyping("slash_reset");
@@ -2119,6 +2236,19 @@ function createRelay(opts) {
2119
2236
  );
2120
2237
  broadcastPages();
2121
2238
  }
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.
2244
+ if (command === "/new" || command === "/reset") {
2245
+ const resetKey = sessionService.ensureSessionKey();
2246
+ 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.
2250
+ sessionService.clearLogicalSessionState(resetKey);
2251
+ }
2122
2252
  if (upstreamRuntime && upstreamRuntime.isConnected()) {
2123
2253
  // Bare /reset no longer elicits an agent turn on OpenClaw 2026.6.x
2124
2254
  // (fast-reset). Append the greeting prompt so Reset gets the same
@@ -2566,6 +2696,14 @@ function createRelay(opts) {
2566
2696
 
2567
2697
  const pluginVersionService = createPluginVersionService();
2568
2698
 
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
+ const glassesBackpressureLatch = createGlassesBackpressureLatch({
2703
+ emitDebug: (event, severity, data) =>
2704
+ emitDebug("relay.health", event, severity, null, () => data || {}),
2705
+ });
2706
+
2569
2707
  server = createRelayWorkerSupervisor({
2570
2708
  pluginId: "ocuclaw",
2571
2709
  getPluginVersion: () => pluginVersionService.getPluginVersion(),
@@ -2576,6 +2714,7 @@ function createRelay(opts) {
2576
2714
  host: opts.host,
2577
2715
  port: opts.port,
2578
2716
  token: opts.token,
2717
+ onWorkerBackpressure: (message) => glassesBackpressureLatch.report(message),
2579
2718
  externalDebugToolsEnabled,
2580
2719
  evenAiRequestTimeoutMs: opts.evenAiRequestTimeoutMs,
2581
2720
  evenAiMaxBodyBytes: opts.evenAiMaxBodyBytes,
@@ -3013,6 +3152,16 @@ function createRelay(opts) {
3013
3152
  * The downstream server is already listening from construction.
3014
3153
  */
3015
3154
  start() {
3155
+ // Bounded cleanup of stale stable-prompt snapshots (14-day TTL).
3156
+ if (!stablePromptSweepTimer) {
3157
+ stablePromptSweepTimer = setInterval(
3158
+ () => stablePromptSnapshots.sweep(),
3159
+ 60 * 60 * 1000, // hourly
3160
+ );
3161
+ if (typeof stablePromptSweepTimer.unref === "function") {
3162
+ stablePromptSweepTimer.unref();
3163
+ }
3164
+ }
3016
3165
  const startGateway = () => Promise.resolve(gatewayBridge.start()).then(() => {
3017
3166
  prefetchSonioxModels("relay_start").catch((err) => {
3018
3167
  logger.warn(`[relay] Soniox models prefetch failed: ${err.message}`);
@@ -3034,6 +3183,10 @@ function createRelay(opts) {
3034
3183
  */
3035
3184
  stop() {
3036
3185
  clearSimulateStreamTimers();
3186
+ if (stablePromptSweepTimer) {
3187
+ clearInterval(stablePromptSweepTimer);
3188
+ stablePromptSweepTimer = null;
3189
+ }
3037
3190
  if (evenAiEndpoint) {
3038
3191
  evenAiEndpoint.close();
3039
3192
  }
@@ -3113,6 +3266,49 @@ function createRelay(opts) {
3113
3266
  return sessionService.isSessionUserLocked(sessionKey);
3114
3267
  },
3115
3268
 
3269
+ getDisplayStartStates(sessionKey) {
3270
+ return sessionService.getDisplayStartStates(sessionKey);
3271
+ },
3272
+
3273
+ getDisplayCurrentStates(sessionKey) {
3274
+ return sessionService.getDisplayCurrentStates(sessionKey);
3275
+ },
3276
+
3277
+ // Accessors used by the session-title distiller sidecar.
3278
+ getSessionTitleRecord(sessionKey) {
3279
+ return sessionService.getSessionTitleRecord(sessionKey);
3280
+ },
3281
+ isEvenAiSessionKey(sessionKey) {
3282
+ return sessionService.isEvenAiSessionKey(sessionKey);
3283
+ },
3284
+ getRawMessages() {
3285
+ return conversationState.getRawMessages();
3286
+ },
3287
+ getDistillerBudget() {
3288
+ return sessionService.getDistillerBudget();
3289
+ },
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.
3296
+ deleteDistillerSession(sessionKey) {
3297
+ return sessionService.deleteSessions("ocuclaw", [sessionKey]);
3298
+ },
3299
+ getStateDir() {
3300
+ return opts.stateDir;
3301
+ },
3302
+ emitDebug(...args) {
3303
+ return emitDebug(...args);
3304
+ },
3305
+ gatewayRequest(method, params, requestOpts) {
3306
+ return gatewayBridge.request(method, params, requestOpts);
3307
+ },
3308
+ onGatewayEvent(eventName, listener) {
3309
+ return gatewayBridge.on(eventName, listener);
3310
+ },
3311
+
3116
3312
  peekSessionKey() {
3117
3313
  return sessionService.peekSessionKey();
3118
3314
  },
@@ -3147,6 +3343,15 @@ function createRelay(opts) {
3147
3343
  return dispatchOcuClawUserSend(params || {});
3148
3344
  },
3149
3345
 
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
+ _clearLogicalSessionState(sessionKey) {
3352
+ sessionService.clearLogicalSessionState(sessionKey);
3353
+ },
3354
+
3150
3355
  sendGlassesUiRender(params) {
3151
3356
  sendGlassesUiRender(params);
3152
3357
  },
@@ -3155,6 +3360,48 @@ function createRelay(opts) {
3155
3360
  sendGlassesUiSurfaceUpdate(params);
3156
3361
  },
3157
3362
 
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
+ dispatchGlassesWake(params) {
3373
+ const sessionKey =
3374
+ params && typeof params.sessionKey === "string" && params.sessionKey
3375
+ ? params.sessionKey
3376
+ : sessionService.ensureSessionKey();
3377
+ const message = params && typeof params.message === "string" ? params.message : "";
3378
+ if (!message) {
3379
+ return Promise.reject(new Error("dispatchGlassesWake requires a message"));
3380
+ }
3381
+ const idempotencyKey =
3382
+ params && typeof params.idempotencyKey === "string" && params.idempotencyKey
3383
+ ? params.idempotencyKey
3384
+ : null;
3385
+ agentTurnTracker.markBusy(sessionKey);
3386
+ emitDebug(
3387
+ "relay.protocol",
3388
+ "glasses_wake_dispatch",
3389
+ "info",
3390
+ { sessionKey },
3391
+ () => ({
3392
+ idempotencyKey,
3393
+ messageChars: message.length,
3394
+ }),
3395
+ );
3396
+ const requestParams = { message, sessionKey };
3397
+ if (idempotencyKey) requestParams.idempotencyKey = idempotencyKey;
3398
+ return gatewayBridge.request("agent", requestParams, { expectFinal: false });
3399
+ },
3400
+
3401
+ isAgentTurnBusy(sessionKey) {
3402
+ return agentTurnTracker.isBusy(sessionKey);
3403
+ },
3404
+
3158
3405
  onGlassesUiResult(handler) {
3159
3406
  return onGlassesUiResult(handler);
3160
3407
  },
@@ -3175,6 +3422,10 @@ function createRelay(opts) {
3175
3422
  return server ? server.getConnectedAppCount() > 0 : false;
3176
3423
  },
3177
3424
 
3425
+ isGlassesSendBufferOverHighWater() {
3426
+ return glassesBackpressureLatch.isOverHighWater();
3427
+ },
3428
+
3178
3429
  onAppClientDisconnect(handler) {
3179
3430
  return onAppClientDisconnect(handler);
3180
3431
  },