ocuclaw 1.3.0 → 1.3.2

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 (44) hide show
  1. package/README.md +3 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +22 -0
  3. package/dist/config/runtime-config.js +24 -15
  4. package/dist/domain/debug-store.js +18 -0
  5. package/dist/domain/glasses-display-system-prompt.js +52 -0
  6. package/dist/domain/glasses-display-system-prompt.test.js +44 -0
  7. package/dist/domain/glasses-ui-system-prompt.js +6 -22
  8. package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
  9. package/dist/domain/prompt-channel-fragments.js +32 -0
  10. package/dist/domain/prompt-channel-fragments.test.js +70 -0
  11. package/dist/gateway/gateway-timing-ledger.js +15 -3
  12. package/dist/gateway/openclaw-client.js +80 -3
  13. package/dist/index.js +22 -0
  14. package/dist/runtime/channel-two-hook.js +36 -0
  15. package/dist/runtime/container-env.js +41 -0
  16. package/dist/runtime/display-toggle-states.js +98 -0
  17. package/dist/runtime/plugin-version-service.js +23 -0
  18. package/dist/runtime/register-session-title-distiller.js +100 -0
  19. package/dist/runtime/relay-core.js +307 -68
  20. package/dist/runtime/relay-service.js +120 -13
  21. package/dist/runtime/relay-worker-entry.js +26 -0
  22. package/dist/runtime/relay-worker-protocol.js +0 -4
  23. package/dist/runtime/relay-worker-supervisor.js +43 -79
  24. package/dist/runtime/relay-worker-transport.js +41 -0
  25. package/dist/runtime/session-service.js +159 -15
  26. package/dist/runtime/session-title-distiller-budget.js +36 -0
  27. package/dist/runtime/session-title-distiller-helpers.js +130 -0
  28. package/dist/runtime/session-title-distiller.js +354 -0
  29. package/dist/runtime/session-title-record.js +21 -0
  30. package/dist/runtime/stable-prompt-snapshot.js +119 -0
  31. package/dist/tools/glasses-ui-cron.js +9 -3
  32. package/dist/tools/glasses-ui-paint-floor.js +10 -3
  33. package/dist/tools/glasses-ui-recipes.js +13 -178
  34. package/dist/tools/glasses-ui-surfaces.js +8 -1
  35. package/dist/tools/glasses-ui-tool-description.test.js +16 -0
  36. package/dist/tools/glasses-ui-tool.js +98 -60
  37. package/dist/tools/session-title-tool.js +14 -76
  38. package/dist/tools/session-title-tool.test.js +53 -0
  39. package/dist/version.js +2 -2
  40. package/openclaw.plugin.json +9 -0
  41. package/package.json +6 -4
  42. package/skills/glasses-ui/SKILL.md +163 -0
  43. package/dist/runtime/downstream-server.js +0 -2057
  44. package/dist/runtime/plugin-update-service.js +0 -216
@@ -1,8 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import * as childProcess from "node:child_process";
4
3
  import { EventEmitter } from "node:events";
5
- import { createPluginUpdateService } from "./plugin-update-service.js";
4
+ import { createPluginVersionService } from "./plugin-version-service.js";
6
5
  import * as conversationStateModule from "../domain/conversation-state.js";
7
6
  import { createDebugStore } from "../domain/debug-store.js";
8
7
  import { summarizeGlassesUiContent } from "../domain/glasses-ui-content-summary.js";
@@ -10,6 +9,8 @@ import { composeReadabilitySystemPrompt } from "../domain/readability-system-pro
10
9
  import { composeNeuralEmojiReactorSystemPrompt } from "../domain/neural-emoji-reactor-system-prompt.js";
11
10
  import { composeNeuralPaceModulatorSystemPrompt } from "../domain/neural-pace-modulator-system-prompt.js";
12
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";
13
14
  import { createActivityStatusAdapter } from "../domain/activity-status-adapter.js";
14
15
  import { createEvenAiEndpoint } from "../even-ai/even-ai-endpoint.js";
15
16
  import { createEvenAiRouter } from "../even-ai/even-ai-router.js";
@@ -22,7 +23,10 @@ import { createOcuClawSettingsStore } from "./ocuclaw-settings-store.js";
22
23
  import { createRelayHealthMonitor } from "./relay-health-monitor.js";
23
24
  import { createRelayOperationRegistry } from "./relay-operation-registry.js";
24
25
  import { createRelayWorkerSupervisor } from "./relay-worker-supervisor.js";
25
- import { createSessionService } from "./session-service.js";
26
+ import {
27
+ createSessionService,
28
+ NEW_SESSION_GREETING_PROMPT,
29
+ } from "./session-service.js";
26
30
  import { createUpstreamRuntime } from "./upstream-runtime.js";
27
31
 
28
32
  const SONIOX_TEMP_KEY_URL = "https://api.soniox.com/v1/auth/temporary-api-key";
@@ -357,7 +361,9 @@ function createRelay(opts) {
357
361
  /** Relay-local deterministic simulate-stream run sequence counter. */
358
362
  let simulateStreamRunSeq = 0;
359
363
  /** Active timers for relay-local deterministic simulate-stream runs. */
360
- const simulateStreamTimers = new Set();
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();
361
367
 
362
368
  // --- Structured debug state ---
363
369
 
@@ -373,6 +379,31 @@ function createRelay(opts) {
373
379
  // log lines share an identical ts (downstream reconcilers dedupe on it).
374
380
  const debugNow =
375
381
  typeof opts.debugNow === "function" ? opts.debugNow : () => Date.now();
382
+
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
+ const debugArmStatePath =
393
+ typeof opts.stateDir === "string" && opts.stateDir
394
+ ? path.join(opts.stateDir, "debug-arm.json")
395
+ : null;
396
+ let initialDebugArm = [];
397
+ if (debugArmStatePath) {
398
+ try {
399
+ const parsed = JSON.parse(fs.readFileSync(debugArmStatePath, "utf8"));
400
+ if (parsed && Array.isArray(parsed.enabled)) {
401
+ initialDebugArm = parsed.enabled;
402
+ }
403
+ } catch {
404
+ initialDebugArm = [];
405
+ }
406
+ }
376
407
  const debugStore = createDebugStore({
377
408
  categories: debugCategories,
378
409
  capacity: opts.debugCapacity,
@@ -383,6 +414,7 @@ function createRelay(opts) {
383
414
  dumpMaxLimit: opts.debugDumpMaxLimit,
384
415
  now: debugNow,
385
416
  noisyPolicies: opts.debugNoisyPolicies,
417
+ initialEnabled: initialDebugArm,
386
418
  });
387
419
 
388
420
  // --- Live-interface trace-log flag (durable across restarts) ---
@@ -522,7 +554,7 @@ function createRelay(opts) {
522
554
  );
523
555
  }
524
556
 
525
- function scheduleSimulateStreamTimer(delayMs, callback) {
557
+ function scheduleSimulateStreamTimer(delayMs, callback, sessionKey) {
526
558
  const timer = setTimeout(() => {
527
559
  simulateStreamTimers.delete(timer);
528
560
  try {
@@ -531,12 +563,29 @@ function createRelay(opts) {
531
563
  logger.error(`[relay] simulate-stream timer failed: ${err.message}`);
532
564
  }
533
565
  }, delayMs);
534
- simulateStreamTimers.add(timer);
566
+ simulateStreamTimers.set(timer, typeof sessionKey === "string" ? sessionKey : null);
535
567
  return timer;
536
568
  }
537
569
 
570
+ function clearSimulateStreamTimersForSession(sessionKey) {
571
+ let cleared = 0;
572
+ for (const [timer, timerSessionKey] of simulateStreamTimers) {
573
+ if (timerSessionKey === sessionKey) {
574
+ clearTimeout(timer);
575
+ simulateStreamTimers.delete(timer);
576
+ cleared += 1;
577
+ }
578
+ }
579
+ if (cleared > 0) {
580
+ logger.info(
581
+ `[relay] cancelled ${cleared} pending simulate-stream timer(s) for session ${sessionKey}`,
582
+ );
583
+ }
584
+ return cleared;
585
+ }
586
+
538
587
  function clearSimulateStreamTimers() {
539
- for (const timer of simulateStreamTimers) {
588
+ for (const timer of simulateStreamTimers.keys()) {
540
589
  clearTimeout(timer);
541
590
  }
542
591
  simulateStreamTimers.clear();
@@ -895,37 +944,67 @@ function createRelay(opts) {
895
944
  systemPrompt: opts.ocuClawSystemPrompt,
896
945
  },
897
946
  });
898
-
899
- function currentOcuClawSendOptions(perTurnSignals) {
900
- const signals = perTurnSignals || {};
947
+ const stablePromptSnapshots = createStablePromptSnapshotStore({
948
+ stateDir: opts.stateDir,
949
+ emitDebug,
950
+ });
951
+ // Hourly TTL sweep for stale stable-prompt snapshots; started in start(),
952
+ // cleared in stop(). Declared here so both can see it.
953
+ let stablePromptSweepTimer = null;
954
+
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.
961
+ function computeStableChannelOne(startSignals) {
901
962
  const baseReadability = composeReadabilitySystemPrompt(
902
963
  ocuClawSettingsStore.getSnapshot().systemPrompt,
903
964
  );
904
- const validState = (raw) =>
905
- raw === "active" || raw === "recently-disabled" || raw === "inactive"
906
- ? raw
907
- : "inactive";
908
- const reactorState = validState(signals.neuralEmojiReactorState);
909
- const paceState = validState(signals.neuralPaceModulatorState);
910
- const reactor = composeNeuralEmojiReactorSystemPrompt({ state: reactorState });
911
- const pace = composeNeuralPaceModulatorSystemPrompt({ state: paceState });
912
- // Only include the glasses-UI nudge when a downstream app client is
913
- // connected. Keeps the prompt clean when the agent has nowhere to render
914
- // the tool's output, and keeps existing prompt-assembly tests stable
915
- // (they exercise the prompt without spinning up an app client).
916
- const hasAppClient =
917
- server &&
918
- typeof server.getConnectedAppCount === "function" &&
919
- server.getConnectedAppCount() > 0;
920
- const glassesUiNudge = hasAppClient ? composeGlassesUiNudgeSystemPrompt() : "";
965
+ const display = composeGlassesDisplaySystemPrompt({
966
+ emoji: startSignals.emoji,
967
+ pace: startSignals.pace,
968
+ });
969
+ const glassesPointer = composeGlassesUiNudgeSystemPrompt();
921
970
  const parts = [];
922
971
  if (baseReadability) parts.push(baseReadability);
923
- if (reactor) parts.push(reactor);
924
- if (pace) parts.push(pace);
925
- if (glassesUiNudge) parts.push(glassesUiNudge);
926
- return {
927
- extraSystemPrompt: parts.join("\n\n"),
928
- };
972
+ if (display) parts.push(display);
973
+ if (glassesPointer) parts.push(glassesPointer);
974
+ return parts.join("\n\n");
975
+ }
976
+
977
+ function stableSendOptions(resolvedSessionKey, sessionId, perTurnSignals) {
978
+ 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.
981
+ const startEmoji = signals.neuralEmojiReactorState === "active";
982
+ const startPace = signals.neuralPaceModulatorState === "active";
983
+ const extraSystemPrompt = stablePromptSnapshots.getOrCreate(
984
+ resolvedSessionKey,
985
+ sessionId,
986
+ () => computeStableChannelOne({ emoji: startEmoji, pace: startPace }),
987
+ );
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.
992
+ if (
993
+ stablePromptSnapshots.wouldChurn(
994
+ resolvedSessionKey,
995
+ sessionId,
996
+ computeStableChannelOne({ emoji: startEmoji, pace: startPace }),
997
+ )
998
+ ) {
999
+ emitDebug(
1000
+ "relay.session",
1001
+ "stable_prompt_churn_detected",
1002
+ "warn",
1003
+ { sessionKey: resolvedSessionKey },
1004
+ () => ({ sessionId: sessionId || null }),
1005
+ );
1006
+ }
1007
+ return { extraSystemPrompt };
929
1008
  }
930
1009
 
931
1010
  function buildOcuClawSendDiagnostic(params = {}) {
@@ -1437,6 +1516,10 @@ function createRelay(opts) {
1437
1516
  resolvedSessionKey,
1438
1517
  clientDisplaySignals.neuralSessionNamesEnabled !== false,
1439
1518
  );
1519
+ sessionService.recordDisplayToggleStates(resolvedSessionKey, {
1520
+ emoji: clientDisplaySignals.neuralEmojiReactorState === "active",
1521
+ pace: clientDisplaySignals.neuralPaceModulatorState === "active",
1522
+ });
1440
1523
  }
1441
1524
  const hasAttachment = !!attachment;
1442
1525
  const sendStartedAt = Date.now();
@@ -1466,7 +1549,16 @@ function createRelay(opts) {
1466
1549
  resolvedSessionKey,
1467
1550
  attachment,
1468
1551
  {
1469
- ...currentOcuClawSendOptions(clientDisplaySignals),
1552
+ ...stableSendOptions(
1553
+ 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).
1559
+ resolvedSessionKey,
1560
+ clientDisplaySignals,
1561
+ ),
1470
1562
  diagnostic: buildOcuClawSendDiagnostic({
1471
1563
  ...params,
1472
1564
  sessionKey: resolvedSessionKey,
@@ -1621,6 +1713,49 @@ function createRelay(opts) {
1621
1713
  return { ok: true, enabled, persisted, persistedPath: liveUiTraceFlagPath };
1622
1714
  }
1623
1715
 
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
+ function persistDebugArm() {
1723
+ if (!debugArmStatePath) return false;
1724
+ try {
1725
+ const enabled = debugStore.getSnapshot().enabled;
1726
+ fs.writeFileSync(debugArmStatePath, JSON.stringify({ enabled }) + "\n");
1727
+ return true;
1728
+ } catch (err) {
1729
+ logger.warn(`[relay] debug arm persist failed: ${err && err.message ? err.message : err}`);
1730
+ return false;
1731
+ }
1732
+ }
1733
+
1734
+ function applyDebugSet(clientId, request) {
1735
+ const result = debugStore.setCategories(request);
1736
+ if (!result.ok) {
1737
+ throw new Error(result.error || "debug-set failed");
1738
+ }
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.
1742
+ persistDebugArm();
1743
+ emitDebug(
1744
+ "relay.protocol",
1745
+ "debug_set",
1746
+ "info",
1747
+ { sessionKey: sessionService.ensureSessionKey() },
1748
+ () => ({
1749
+ clientId,
1750
+ enable: result.applied.enable,
1751
+ disable: result.applied.disable,
1752
+ ttlMs: result.ttlMs,
1753
+ enabledCount: result.enabled.length,
1754
+ }),
1755
+ );
1756
+ return result;
1757
+ }
1758
+
1624
1759
  const handler = createDownstreamHandler({
1625
1760
  logger,
1626
1761
  externalDebugToolsEnabled,
@@ -1697,6 +1832,14 @@ function createRelay(opts) {
1697
1832
  return upstreamRuntime.compactActiveSession(sessionKey);
1698
1833
  },
1699
1834
  onDeleteSessions(sessionKeys, kind, switchBeforeDelete) {
1835
+ if (Array.isArray(sessionKeys)) {
1836
+ for (const key of sessionKeys) {
1837
+ if (typeof key === "string" && key.trim()) {
1838
+ stablePromptSnapshots.evict(key);
1839
+ sessionService.clearDisplayToggleStates(key);
1840
+ }
1841
+ }
1842
+ }
1700
1843
  const action = switchBeforeDelete
1701
1844
  ? sessionService.switchAndDeleteSessions(kind, sessionKeys)
1702
1845
  : sessionService.deleteSessions(kind, sessionKeys);
@@ -1833,7 +1976,7 @@ function createRelay(opts) {
1833
1976
  totalChars: text.length,
1834
1977
  }),
1835
1978
  );
1836
- });
1979
+ }, sessionKey);
1837
1980
  }
1838
1981
 
1839
1982
  const completeDelayMs = startDelayMs + (chunkCount * chunkIntervalMs) + thinkingTailMs;
@@ -1864,7 +2007,7 @@ function createRelay(opts) {
1864
2007
  completeDelayMs,
1865
2008
  }),
1866
2009
  );
1867
- });
2010
+ }, sessionKey);
1868
2011
 
1869
2012
  return Promise.resolve({
1870
2013
  status: "accepted",
@@ -1891,13 +2034,30 @@ function createRelay(opts) {
1891
2034
  }
1892
2035
  sessionService.invalidateSessionsCache();
1893
2036
  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).
2040
+ clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
1894
2041
  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.
2046
+ const newChatSessionKey = sessionService.ensureSessionKey();
2047
+ stablePromptSnapshots.evict(newChatSessionKey);
2048
+ sessionService.clearLogicalSessionState(newChatSessionKey);
1895
2049
  conversationState.setAgentName(
1896
2050
  (upstreamRuntime ? upstreamRuntime.getAgentName() : null) || "Agent",
1897
2051
  );
1898
2052
  const pages = conversationState.getPages();
1899
2053
  cachePages(pages);
1900
2054
  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.
1901
2061
  gatewayBridge.sendMessage("/new", "main").catch((err) => {
1902
2062
  logger.error(`[relay] Failed to send /new: ${err.message}`);
1903
2063
  });
@@ -1923,7 +2083,20 @@ function createRelay(opts) {
1923
2083
  },
1924
2084
 
1925
2085
  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.
2089
+ clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
1926
2090
  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.
2096
+ if (result && typeof result.sessionKey === "string" && result.sessionKey.trim()) {
2097
+ stablePromptSnapshots.evict(result.sessionKey);
2098
+ sessionService.clearDisplayToggleStates(result.sessionKey);
2099
+ }
1927
2100
  clearCurrentSessionModelConfigSnapshot("new_session");
1928
2101
  if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
1929
2102
  upstreamRuntime.clearTyping("new_session");
@@ -2033,6 +2206,7 @@ function createRelay(opts) {
2033
2206
  if (command === "/reset") {
2034
2207
  sessionService.invalidateSessionsCache();
2035
2208
  resetActivityStatusAdapter();
2209
+ clearSimulateStreamTimersForSession(sessionService.ensureSessionKey());
2036
2210
  conversationState.clear();
2037
2211
  if (upstreamRuntime && typeof upstreamRuntime.clearTyping === "function") {
2038
2212
  upstreamRuntime.clearTyping("slash_reset");
@@ -2042,8 +2216,31 @@ function createRelay(opts) {
2042
2216
  );
2043
2217
  broadcastPages();
2044
2218
  }
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.
2224
+ if (command === "/new" || command === "/reset") {
2225
+ const resetKey = sessionService.ensureSessionKey();
2226
+ 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.
2230
+ sessionService.clearLogicalSessionState(resetKey);
2231
+ }
2045
2232
  if (upstreamRuntime && upstreamRuntime.isConnected()) {
2046
- return gatewayBridge.sendMessage(command, sessionService.ensureSessionKey());
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.
2236
+ const outboundCommand =
2237
+ command === "/reset"
2238
+ ? `/reset ${NEW_SESSION_GREETING_PROMPT}`
2239
+ : command;
2240
+ return gatewayBridge.sendMessage(
2241
+ outboundCommand,
2242
+ sessionService.ensureSessionKey(),
2243
+ );
2047
2244
  }
2048
2245
  return Promise.resolve();
2049
2246
  },
@@ -2102,26 +2299,7 @@ function createRelay(opts) {
2102
2299
  },
2103
2300
 
2104
2301
  onDebugSet(clientId, request) {
2105
- const result = debugStore.setCategories(request);
2106
- if (!result.ok) {
2107
- throw new Error(result.error || "debug-set failed");
2108
- }
2109
-
2110
- emitDebug(
2111
- "relay.protocol",
2112
- "debug_set",
2113
- "info",
2114
- { sessionKey: sessionService.ensureSessionKey() },
2115
- () => ({
2116
- clientId,
2117
- enable: result.applied.enable,
2118
- disable: result.applied.disable,
2119
- ttlMs: result.ttlMs,
2120
- enabledCount: result.enabled.length,
2121
- }),
2122
- );
2123
-
2124
- return result;
2302
+ return applyDebugSet(clientId, request);
2125
2303
  },
2126
2304
 
2127
2305
  onTraceLogSet(clientId, request) {
@@ -2496,18 +2674,12 @@ function createRelay(opts) {
2496
2674
 
2497
2675
  // --- Worker supervisor ---
2498
2676
 
2499
- const pluginUpdateService = createPluginUpdateService({
2500
- spawn: childProcess.spawn,
2501
- logger,
2502
- nowMs: () => Date.now(),
2503
- setTimeout: (fn, ms) => setTimeout(fn, ms),
2504
- clearTimeout: (handle) => clearTimeout(handle),
2505
- });
2677
+ const pluginVersionService = createPluginVersionService();
2506
2678
 
2507
2679
  server = createRelayWorkerSupervisor({
2508
2680
  pluginId: "ocuclaw",
2509
- getPluginVersion: () => pluginUpdateService.getPluginVersion(),
2510
- getRequiresClientVersion: () => pluginUpdateService.getRequiresClientVersion(),
2681
+ getPluginVersion: () => pluginVersionService.getPluginVersion(),
2682
+ getRequiresClientVersion: () => pluginVersionService.getRequiresClientVersion(),
2511
2683
  logger,
2512
2684
  handler,
2513
2685
  operationRegistry: relayOperationRegistry,
@@ -2515,8 +2687,6 @@ function createRelay(opts) {
2515
2687
  port: opts.port,
2516
2688
  token: opts.token,
2517
2689
  externalDebugToolsEnabled,
2518
- runPluginUpdate: () => pluginUpdateService.runPluginUpdate(),
2519
- runGatewayRestart: () => pluginUpdateService.runGatewayRestart(),
2520
2690
  evenAiRequestTimeoutMs: opts.evenAiRequestTimeoutMs,
2521
2691
  evenAiMaxBodyBytes: opts.evenAiMaxBodyBytes,
2522
2692
  evenAiMaxResponseBytes: opts.evenAiMaxResponseBytes,
@@ -2953,6 +3123,16 @@ function createRelay(opts) {
2953
3123
  * The downstream server is already listening from construction.
2954
3124
  */
2955
3125
  start() {
3126
+ // Bounded cleanup of stale stable-prompt snapshots (14-day TTL).
3127
+ if (!stablePromptSweepTimer) {
3128
+ stablePromptSweepTimer = setInterval(
3129
+ () => stablePromptSnapshots.sweep(),
3130
+ 60 * 60 * 1000, // hourly
3131
+ );
3132
+ if (typeof stablePromptSweepTimer.unref === "function") {
3133
+ stablePromptSweepTimer.unref();
3134
+ }
3135
+ }
2956
3136
  const startGateway = () => Promise.resolve(gatewayBridge.start()).then(() => {
2957
3137
  prefetchSonioxModels("relay_start").catch((err) => {
2958
3138
  logger.warn(`[relay] Soniox models prefetch failed: ${err.message}`);
@@ -2974,6 +3154,10 @@ function createRelay(opts) {
2974
3154
  */
2975
3155
  stop() {
2976
3156
  clearSimulateStreamTimers();
3157
+ if (stablePromptSweepTimer) {
3158
+ clearInterval(stablePromptSweepTimer);
3159
+ stablePromptSweepTimer = null;
3160
+ }
2977
3161
  if (evenAiEndpoint) {
2978
3162
  evenAiEndpoint.close();
2979
3163
  }
@@ -3019,6 +3203,9 @@ function createRelay(opts) {
3019
3203
  __onTraceLogSetForTest(clientId, request) {
3020
3204
  return applyTraceLogSet(clientId, request);
3021
3205
  },
3206
+ __onDebugSetForTest(clientId, request) {
3207
+ return applyDebugSet(clientId, request);
3208
+ },
3022
3209
 
3023
3210
  get operationRegistryForTest() {
3024
3211
  return relayOperationRegistry;
@@ -3050,6 +3237,49 @@ function createRelay(opts) {
3050
3237
  return sessionService.isSessionUserLocked(sessionKey);
3051
3238
  },
3052
3239
 
3240
+ getDisplayStartStates(sessionKey) {
3241
+ return sessionService.getDisplayStartStates(sessionKey);
3242
+ },
3243
+
3244
+ getDisplayCurrentStates(sessionKey) {
3245
+ return sessionService.getDisplayCurrentStates(sessionKey);
3246
+ },
3247
+
3248
+ // Accessors used by the session-title distiller sidecar.
3249
+ getSessionTitleRecord(sessionKey) {
3250
+ return sessionService.getSessionTitleRecord(sessionKey);
3251
+ },
3252
+ isEvenAiSessionKey(sessionKey) {
3253
+ return sessionService.isEvenAiSessionKey(sessionKey);
3254
+ },
3255
+ getRawMessages() {
3256
+ return conversationState.getRawMessages();
3257
+ },
3258
+ getDistillerBudget() {
3259
+ return sessionService.getDistillerBudget();
3260
+ },
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.
3267
+ deleteDistillerSession(sessionKey) {
3268
+ return sessionService.deleteSessions("ocuclaw", [sessionKey]);
3269
+ },
3270
+ getStateDir() {
3271
+ return opts.stateDir;
3272
+ },
3273
+ emitDebug(...args) {
3274
+ return emitDebug(...args);
3275
+ },
3276
+ gatewayRequest(method, params, requestOpts) {
3277
+ return gatewayBridge.request(method, params, requestOpts);
3278
+ },
3279
+ onGatewayEvent(eventName, listener) {
3280
+ return gatewayBridge.on(eventName, listener);
3281
+ },
3282
+
3053
3283
  peekSessionKey() {
3054
3284
  return sessionService.peekSessionKey();
3055
3285
  },
@@ -3084,6 +3314,15 @@ function createRelay(opts) {
3084
3314
  return dispatchOcuClawUserSend(params || {});
3085
3315
  },
3086
3316
 
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
+ _clearLogicalSessionState(sessionKey) {
3323
+ sessionService.clearLogicalSessionState(sessionKey);
3324
+ },
3325
+
3087
3326
  sendGlassesUiRender(params) {
3088
3327
  sendGlassesUiRender(params);
3089
3328
  },