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
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createRuntimeConfig } from "./runtime-config.ts";
4
+
5
+ // Minimal valid config: relayToken (pluginConfig) + a gateway auth token
6
+ // (openclawConfig) — createRuntimeConfig throws without a resolvable
7
+ // gatewayUrl/gatewayToken (gatewayUrl defaults to ws://127.0.0.1:18789).
8
+ const base = { relayToken: "tok" };
9
+ const openclawConfig = { gateway: { auth: { token: "gw" } } };
10
+
11
+ test("absent sessionTitleModel → undefined/empty (zero-config path)", () => {
12
+ const cfg = createRuntimeConfig({ pluginConfig: { ...base }, openclawConfig });
13
+ assert.ok(!cfg.sessionTitleModel);
14
+ });
15
+
16
+ test("sessionTitleModel string is carried through", () => {
17
+ const cfg = createRuntimeConfig({
18
+ pluginConfig: { ...base, sessionTitleModel: "openai/gpt-5-mini" },
19
+ openclawConfig,
20
+ });
21
+ assert.equal(cfg.sessionTitleModel, "openai/gpt-5-mini");
22
+ });
@@ -145,7 +145,12 @@ function resolveGlassesUiLive(value) {
145
145
  // plugins.entries.ocuclaw.config.glassesUiLive.httpEnabled = true to
146
146
  // enable. The dispatcher protection still applies once enabled.
147
147
  httpEnabled: parseBool(raw.httpEnabled, false),
148
- llmEnabled: parseBool(raw.llmEnabled, true),
148
+ // llm ticks are agent-influenced model calls on a schedule (token spend +
149
+ // hallucinated-display risk); the tier is deliberately untaught in the
150
+ // skill, and an enabled-but-undocumented capability is the worst of both.
151
+ // Operator opt-in like httpEnabled (user decision 2026-06-10); the L1/L2
152
+ // agent tier is the sanctioned path to reasoning surfaces when it lands.
153
+ llmEnabled: parseBool(raw.llmEnabled, false),
149
154
  maxConcurrentSurfacesPerHost: parseIntOrDefault(raw.maxConcurrentSurfacesPerHost, 4),
150
155
  };
151
156
  }
@@ -234,6 +239,7 @@ export function createRuntimeConfig(opts = {}) {
234
239
  evenAiDedicatedSessionKey: parseEvenAiDedicatedSessionKey(
235
240
  pluginConfig.evenAiDedicatedSessionKey,
236
241
  ),
242
+ sessionTitleModel: pickString(pluginConfig.sessionTitleModel),
237
243
  renderGlassesUiTimeoutMs: parseIntOrDefault(
238
244
  pluginConfig.renderGlassesUiTimeoutMs,
239
245
  30 * 60 * 1000,
@@ -0,0 +1,52 @@
1
+ import { MESSAGE_EMOJI_ALLOWLIST } from "./message-emoji-allowlist.js";
2
+
3
+ const ALLOWLIST_LINES = (() => {
4
+ const rows = [];
5
+ for (let i = 0; i < MESSAGE_EMOJI_ALLOWLIST.length; i += 20) {
6
+ rows.push(MESSAGE_EMOJI_ALLOWLIST.slice(i, i + 20).join(" "));
7
+ }
8
+ return rows.map((r) => ` ${r}`).join("\n");
9
+ })();
10
+
11
+ const INTRO =
12
+ "Your replies render on the user's Even G2 glasses HUD. You can wrap short\n" +
13
+ "phrases with invisible tags that shape how they display — only the wrapped\n" +
14
+ "words are shown, never the tags:";
15
+
16
+ const EMOJI_TAG_LINES =
17
+ " <emoji:X>phrase</emoji> — flashes a small status emoji above the message\n" +
18
+ " while the phrase reveals. X must be copied\n" +
19
+ " verbatim from the allowed list below.";
20
+
21
+ const PACE_TAG_LINES =
22
+ " <dwell>phrase</dwell> — reveals the phrase slower; lets a line land.\n" +
23
+ " <skim>phrase</skim> — reveals the phrase faster; rushes past a recap.";
24
+
25
+ const SHARED_RULES =
26
+ "Most messages need NO tags. Use one only where it adds real warmth, surprise,\n" +
27
+ "care, playfulness, or pacing for a single short phrase. Never tag every\n" +
28
+ "sentence. Always close a tag you open; don't nest a tag inside itself; tags\n" +
29
+ "may combine on the same phrase.";
30
+
31
+ const ALLOWLIST_BLOCK =
32
+ "Allowed emoji (copy exactly one per span):\n" + ALLOWLIST_LINES;
33
+
34
+ /**
35
+ * Compose the Channel-1 consolidated display-tags block.
36
+ * @param {{emoji: boolean, pace: boolean}} opts - features ENABLED AT SESSION START
37
+ * @returns {string} the block, or "" when neither feature is enabled
38
+ */
39
+ export function composeGlassesDisplaySystemPrompt(opts) {
40
+ const emoji = !!(opts && opts.emoji);
41
+ const pace = !!(opts && opts.pace);
42
+ if (!emoji && !pace) return "";
43
+
44
+ const tagLines = [];
45
+ if (emoji) tagLines.push(EMOJI_TAG_LINES);
46
+ if (pace) tagLines.push(PACE_TAG_LINES);
47
+
48
+ const parts = [INTRO, tagLines.join("\n"), SHARED_RULES];
49
+ if (emoji) parts.push(ALLOWLIST_BLOCK);
50
+
51
+ return `<glasses_display>\n${parts.join("\n\n")}\n</glasses_display>`;
52
+ }
@@ -0,0 +1,44 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { composeGlassesDisplaySystemPrompt } from "./glasses-display-system-prompt.ts";
4
+ import { MESSAGE_EMOJI_ALLOWLIST } from "./message-emoji-allowlist.ts";
5
+
6
+ test("neither feature enabled → empty string", () => {
7
+ assert.equal(
8
+ composeGlassesDisplaySystemPrompt({ emoji: false, pace: false }),
9
+ "",
10
+ );
11
+ });
12
+
13
+ test("emoji-only includes emoji tag + full allowlist, omits pace tags", () => {
14
+ const out = composeGlassesDisplaySystemPrompt({ emoji: true, pace: false });
15
+ assert.match(out, /<emoji:X>/);
16
+ assert.doesNotMatch(out, /<dwell>/);
17
+ assert.doesNotMatch(out, /<skim>/);
18
+ for (const e of MESSAGE_EMOJI_ALLOWLIST) assert.ok(out.includes(e), `missing ${e}`);
19
+ assert.match(out, /^<glasses_display>/);
20
+ assert.match(out, /<\/glasses_display>$/);
21
+ });
22
+
23
+ test("pace-only includes dwell/skim, omits emoji + allowlist", () => {
24
+ const out = composeGlassesDisplaySystemPrompt({ emoji: false, pace: true });
25
+ assert.match(out, /<dwell>/);
26
+ assert.match(out, /<skim>/);
27
+ assert.doesNotMatch(out, /<emoji:X>/);
28
+ // allowlist must not leak when emoji is off
29
+ assert.ok(!out.includes(MESSAGE_EMOJI_ALLOWLIST[0]));
30
+ });
31
+
32
+ test("both enabled includes all three tags and the allowlist once", () => {
33
+ const out = composeGlassesDisplaySystemPrompt({ emoji: true, pace: true });
34
+ assert.match(out, /<emoji:X>/);
35
+ assert.match(out, /<dwell>/);
36
+ assert.match(out, /<skim>/);
37
+ assert.equal(out.match(/<glasses_display>/g).length, 1);
38
+ });
39
+
40
+ test("output is deterministic (same inputs → identical bytes)", () => {
41
+ const a = composeGlassesDisplaySystemPrompt({ emoji: true, pace: true });
42
+ const b = composeGlassesDisplaySystemPrompt({ emoji: true, pace: true });
43
+ assert.equal(a, b);
44
+ });
@@ -1,26 +1,10 @@
1
1
  export const GLASSES_UI_NUDGE_SYSTEM_PROMPT = [
2
- "On the user's Even G2 glasses, prefer calling the render_glasses_ui tool over",
3
- "a multi-line text reply when the answer is a short list of choices the user",
4
- "could pick from (use list_surface), a list where each option has a short",
5
- "detail the user needs to read before choosing (use list_with_details_surface,",
6
- "with a one-to-two-sentence body per item), or a single formatted block they",
7
- "should read (use text_surface). The user can double-tap to back up one popup; if a",
8
- "tool call returns { result: \"back\" }, they want to revise their previous",
9
- "answer — re-render the previous step or pivot.",
10
- "",
11
- "After the tool call resolves, your NEXT output decides what the glasses show",
12
- "next:",
13
- " • another render_glasses_ui call → replaces the current surface (use this",
14
- " for a drill-down or follow-up step in the flow);",
15
- " • a short text reply → the chat screen takes over and the surface",
16
- " disappears, so the user sees your text instead of the now-stale list;",
17
- " • silent run-end (no further output) → the surface lingers on glass until",
18
- " the user dismisses; only do this if you intentionally want the user to",
19
- " keep interacting with the same surface.",
20
- "After result \"selected\", default to either a follow-up render (next step in",
21
- "the flow) or a brief one-line text ack confirming the choice; avoid ending",
22
- "the run silently unless the rendered surface is still the right thing to",
23
- "look at.",
2
+ "When an answer is a short set of pickable choices or one formatted block,",
3
+ "prefer the render_glasses_ui tool over a long text reply see its",
4
+ "description for when and how.",
5
+ "If render_glasses_ui is not in your current tool list, search your",
6
+ "available/deferred tools for it (it surfaces under the openclaw namespace)",
7
+ "before falling back to a text reply.",
24
8
  ].join(" ");
25
9
 
26
10
  export function composeGlassesUiNudgeSystemPrompt() {
@@ -0,0 +1,13 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { composeGlassesUiNudgeSystemPrompt } from "./glasses-ui-system-prompt.ts";
4
+
5
+ test("pointer is short and references the tool + 'see its description'", () => {
6
+ const out = composeGlassesUiNudgeSystemPrompt();
7
+ assert.match(out, /render_glasses_ui/);
8
+ assert.match(out, /description/i);
9
+ // ef8a821e carry: the deferred-tool search hint rides in the pointer so
10
+ // Codex-harness runs (tool not in the initial list) still find the tool.
11
+ assert.match(out, /available\/deferred tools/);
12
+ assert.ok(out.length < 420, `pointer should stay lean, got ${out.length}`);
13
+ });
@@ -0,0 +1,32 @@
1
+ const EMOJI_STOP =
2
+ "The emoji reactor is off for the rest of this session — do not use " +
3
+ "<emoji:X>…</emoji> spans, even if earlier replies did.";
4
+ const PACE_STOP =
5
+ "The pace modulator is off for the rest of this session — do not use " +
6
+ "<dwell>…</dwell> or <skim>…</skim> spans, even if earlier replies did.";
7
+ const RENDER_GATE =
8
+ "No glasses display is connected right now; do not call render_glasses_ui.";
9
+
10
+ /**
11
+ * Compose the Channel-2 (before_prompt_build → appendSystemContext) fragment.
12
+ * Returns undefined when nothing needs saying (the common case).
13
+ *
14
+ * @param {{startEnabled:{emoji:boolean,pace:boolean},
15
+ * currentEnabled:{emoji:boolean,pace:boolean},
16
+ * glassesConnected:boolean}} input
17
+ * @returns {string|undefined}
18
+ */
19
+ export function composeChannelTwoFragment(input) {
20
+ const start = (input && input.startEnabled) || { emoji: false, pace: false };
21
+ const current = (input && input.currentEnabled) || { emoji: false, pace: false };
22
+ const glassesConnected = !!(input && input.glassesConnected);
23
+
24
+ const parts = [];
25
+ // Stop-notice only when a feature was ENABLED at start and is now OFF.
26
+ if (start.emoji && !current.emoji) parts.push(EMOJI_STOP);
27
+ if (start.pace && !current.pace) parts.push(PACE_STOP);
28
+ if (!glassesConnected) parts.push(RENDER_GATE);
29
+
30
+ if (parts.length === 0) return undefined;
31
+ return parts.join("\n\n");
32
+ }
@@ -0,0 +1,70 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { composeChannelTwoFragment } from "./prompt-channel-fragments.ts";
4
+
5
+ const ENABLED_BOTH = { emoji: true, pace: true };
6
+
7
+ test("no transitions, glasses connected → undefined (no injection)", () => {
8
+ assert.equal(
9
+ composeChannelTwoFragment({
10
+ startEnabled: ENABLED_BOTH,
11
+ currentEnabled: ENABLED_BOTH,
12
+ glassesConnected: true,
13
+ }),
14
+ undefined,
15
+ );
16
+ });
17
+
18
+ test("emoji disabled mid-session → tiny stop-notice mentioning emoji only", () => {
19
+ const out = composeChannelTwoFragment({
20
+ startEnabled: ENABLED_BOTH,
21
+ currentEnabled: { emoji: false, pace: true },
22
+ glassesConnected: true,
23
+ });
24
+ assert.match(out, /emoji/i);
25
+ assert.doesNotMatch(out, /dwell|skim|pace/i);
26
+ assert.ok(out.length < 200, "stop-notice must stay tiny");
27
+ });
28
+
29
+ test("pace disabled mid-session → tiny stop-notice mentioning pace only", () => {
30
+ const out = composeChannelTwoFragment({
31
+ startEnabled: ENABLED_BOTH,
32
+ currentEnabled: { emoji: true, pace: false },
33
+ glassesConnected: true,
34
+ });
35
+ assert.match(out, /dwell|skim|pace/i);
36
+ assert.doesNotMatch(out, /<emoji/i);
37
+ });
38
+
39
+ test("enabling a feature that was OFF at start does NOT inject (lands next session)", () => {
40
+ assert.equal(
41
+ composeChannelTwoFragment({
42
+ startEnabled: { emoji: false, pace: false },
43
+ currentEnabled: { emoji: true, pace: true },
44
+ glassesConnected: true,
45
+ }),
46
+ undefined,
47
+ );
48
+ });
49
+
50
+ test("glasses disconnected → render gate fragment", () => {
51
+ const out = composeChannelTwoFragment({
52
+ startEnabled: ENABLED_BOTH,
53
+ currentEnabled: ENABLED_BOTH,
54
+ glassesConnected: false,
55
+ });
56
+ assert.match(out, /render_glasses_ui/);
57
+ assert.match(out, /not connected|no glasses/i);
58
+ });
59
+
60
+ test("multiple fragments join with a blank line and stay small", () => {
61
+ const out = composeChannelTwoFragment({
62
+ startEnabled: ENABLED_BOTH,
63
+ currentEnabled: { emoji: false, pace: false },
64
+ glassesConnected: false,
65
+ });
66
+ assert.match(out, /emoji/i);
67
+ assert.match(out, /dwell|skim|pace/i);
68
+ assert.match(out, /render_glasses_ui/);
69
+ assert.ok(out.length < 400);
70
+ });
@@ -160,6 +160,18 @@ export function createGatewayTimingLedger(opts = {}) {
160
160
  clearTimer(ref);
161
161
  }
162
162
 
163
+ // Diagnostic timers must never keep the host process alive: a bare
164
+ // 120s ttlTimer kept `node tests/relay.test.js` idling ~120s after the
165
+ // suite finished. Injected fake timers (numeric ids) have no unref —
166
+ // guard for it.
167
+ function armDiagnosticTimer(fn, delayMs) {
168
+ const timer = setTimer(fn, delayMs);
169
+ if (timer && typeof timer.unref === "function") {
170
+ timer.unref();
171
+ }
172
+ return timer;
173
+ }
174
+
163
175
  function requestContext(request) {
164
176
  return {
165
177
  requestId: request.requestId,
@@ -183,7 +195,7 @@ export function createGatewayTimingLedger(opts = {}) {
183
195
  function scheduleRequestSlowTimer(request) {
184
196
  const thresholdMs =
185
197
  request.method === "agent" ? AGENT_ACK_SLOW_MS : GATEWAY_REQUEST_SLOW_MS;
186
- request.slowTimer = setTimer(() => {
198
+ request.slowTimer = armDiagnosticTimer(() => {
187
199
  if (request.slowEmitted) return;
188
200
  request.slowEmitted = true;
189
201
  const elapsedMs = Math.max(0, nowMs() - request.sentAtMs);
@@ -213,7 +225,7 @@ export function createGatewayTimingLedger(opts = {}) {
213
225
  }
214
226
 
215
227
  function scheduleAcceptedRunTimers(run) {
216
- run.lifecycleWaitTimer = setTimer(() => {
228
+ run.lifecycleWaitTimer = armDiagnosticTimer(() => {
217
229
  if (run.lifecycleSlowEmitted || run.lifecycleStartedAtMs != null) return;
218
230
  run.lifecycleSlowEmitted = true;
219
231
  const ackToLifecycleMs = Math.max(0, nowMs() - run.acceptedAtMs);
@@ -238,7 +250,7 @@ export function createGatewayTimingLedger(opts = {}) {
238
250
  `[openclaw-timing] slow accepted-run runId=${run.runId} messageId=${run.messageId || "none"} ackToLifecycleMs=${ackToLifecycleMs} pendingRequests=${requests.size}`,
239
251
  );
240
252
  }, AGENT_LIFECYCLE_WAIT_SLOW_MS);
241
- run.ttlTimer = setTimer(() => {
253
+ run.ttlTimer = armDiagnosticTimer(() => {
242
254
  const current = acceptedRuns.get(run.runId);
243
255
  if (current !== run) return;
244
256
  clearTimerRef(run.lifecycleWaitTimer);
@@ -791,6 +791,17 @@ class OpenClawClient extends EventEmitter {
791
791
  this._connectNonce = null;
792
792
  this._connectSent = false;
793
793
  this._connectTimer = null;
794
+ // --- Socket-generation handshake gate (1008 reconnect-storm fix) ---
795
+ // Monotonic counter bumped on every _connect(); _handshakeGeneration is the
796
+ // watermark of the latest generation whose connect handshake has RESOLVED.
797
+ // A fresh socket is automatically "handshake-incomplete" (its generation is
798
+ // strictly greater than the watermark) with no extra reset bookkeeping —
799
+ // mirrors the existing _activeRunGeneration idiom. request() refuses any
800
+ // non-connect frame on a real ws socket whose generation has not yet
801
+ // handshaked, which is what enforces the gateway's "first frame must be
802
+ // connect" invariant across reconnect churn.
803
+ this._socketGeneration = 0;
804
+ this._handshakeGeneration = -1;
794
805
  this._tickIntervalMs = 30000;
795
806
  this._deviceToken = null; // cached from hello-ok
796
807
 
@@ -887,9 +898,33 @@ class OpenClawClient extends EventEmitter {
887
898
  * acks (status: "accepted") and resolve only on the final response.
888
899
  */
889
900
  request(method, params, opts) {
890
- if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
901
+ // Capture the socket + generation at ENTRY so a reconnect that reassigns
902
+ // this._ws between entry and the synchronous send can never land this frame
903
+ // on a successor socket (kills the successor-socket race variant).
904
+ const ws = this._ws;
905
+ const gen = this._socketGeneration;
906
+ if (!ws || ws !== this._ws || ws.readyState !== WebSocket.OPEN) {
907
+ // PRESERVE this exact literal — relay.test.js asserts it verbatim for the
908
+ // no-usable-socket case. The handshake gate below uses a DISTINCT message.
891
909
  return Promise.reject(new Error("gateway not connected"));
892
910
  }
911
+ // Per-socket handshake gate: a non-connect frame must not be the first frame
912
+ // on a freshly-OPEN-but-unregistered socket (gateway emits 1008). connect
913
+ // BYPASSES the gate (it IS the first allowed frame). The gate only applies
914
+ // to real `ws.WebSocket` sockets — the unit harnesses inject plain-object
915
+ // fakes ({ readyState: 1, send }) which are intentionally treated as
916
+ // already-handshaken so their request("agent"/"ping"/"chat.history") still
917
+ // sends with no harness changes.
918
+ if (
919
+ method !== "connect" &&
920
+ ws instanceof WebSocket &&
921
+ this._handshakeGeneration !== gen
922
+ ) {
923
+ const err = new Error("gateway handshake in flight");
924
+ err.code = "handshake_pending";
925
+ err.retryable = true;
926
+ return Promise.reject(err);
927
+ }
893
928
  const id = crypto.randomUUID();
894
929
  const frame = { type: "req", id, method, params };
895
930
  const expectFinal = opts && opts.expectFinal === true;
@@ -925,7 +960,7 @@ class OpenClawClient extends EventEmitter {
925
960
  diagnostic,
926
961
  });
927
962
  this.emit("protocol", { direction: "out", frame });
928
- this._ws.send(raw);
963
+ ws.send(raw);
929
964
  return promise;
930
965
  }
931
966
 
@@ -1053,6 +1088,16 @@ class OpenClawClient extends EventEmitter {
1053
1088
  this._ws = null;
1054
1089
  }
1055
1090
 
1091
+ // Clear a stale 750ms connect-fallback timer left armed by a prior
1092
+ // generation that opened then closed before it fired. _connect() resets
1093
+ // _connectSent=false below, so a stale timer firing here would re-enter
1094
+ // _sendConnect() on the NEW socket — harmless (still a connect frame, never
1095
+ // a 1008), but it can produce a redundant connect. Clearing closes the edge.
1096
+ if (this._connectTimer) {
1097
+ clearTimeout(this._connectTimer);
1098
+ this._connectTimer = null;
1099
+ }
1100
+
1056
1101
  const url = this._gatewayUrl;
1057
1102
  this.emit("status", "connecting");
1058
1103
  this._logger.info(`[openclaw] Connecting to ${url}`);
@@ -1060,6 +1105,13 @@ class OpenClawClient extends EventEmitter {
1060
1105
  this._connectNonce = null;
1061
1106
  this._connectSent = false;
1062
1107
 
1108
+ // Bump the socket generation BEFORE constructing the new socket. The
1109
+ // handshake watermark (_handshakeGeneration) intentionally STAYS at the
1110
+ // prior value, so the about-to-be-created socket is automatically
1111
+ // handshake-incomplete (its generation > watermark) until its own connect
1112
+ // resolves. No watermark reset is needed.
1113
+ this._socketGeneration += 1;
1114
+
1063
1115
  // Reset per-connection state
1064
1116
  this._timingLedger.clear("connect_reset");
1065
1117
  this._lastSeq = null;
@@ -1550,7 +1602,16 @@ class OpenClawClient extends EventEmitter {
1550
1602
 
1551
1603
  const poll = () => {
1552
1604
  this._pollHistoryActivity().catch((err) => {
1553
- if (!err || !err.message || !/gateway not connected/i.test(err.message)) {
1605
+ // Suppress benign transient rejections during reconnect churn: the
1606
+ // no-socket "gateway not connected" case AND the new handshake-gate
1607
+ // reject (handshake_pending / "gateway handshake in flight"), so the
1608
+ // 1008 fix does not emit spurious poll-failed warnings in its own window.
1609
+ const benignTransient =
1610
+ !!err &&
1611
+ (err.code === "handshake_pending" ||
1612
+ (!!err.message &&
1613
+ /gateway (not connected|handshake in flight)/i.test(err.message)));
1614
+ if (!benignTransient) {
1554
1615
  this._logger.warn(
1555
1616
  `[openclaw] Thinking-summary poll failed: ${
1556
1617
  err && err.message ? err.message : String(err)
@@ -1774,6 +1835,12 @@ class OpenClawClient extends EventEmitter {
1774
1835
 
1775
1836
  this._logger.info("[openclaw] Sending connect request...");
1776
1837
 
1838
+ // Capture the generation this connect is being sent for. The resolve below
1839
+ // must only mark THIS generation handshaken — if a disconnect+reconnect
1840
+ // bumped _socketGeneration before the connect resolved, this resolve is
1841
+ // stale and must NOT open the gate for the new in-flight socket.
1842
+ const connectGeneration = this._socketGeneration;
1843
+
1777
1844
  this.request("connect", params)
1778
1845
  .then((helloOk) => {
1779
1846
  this._logger.info(
@@ -1806,6 +1873,16 @@ class OpenClawClient extends EventEmitter {
1806
1873
  this._logger.info("[openclaw] Device token cached");
1807
1874
  }
1808
1875
 
1876
+ // Open the handshake gate for THIS socket's generation BEFORE emitting
1877
+ // "connected" (so refreshUpstreamBootstrap, fired on the connected event,
1878
+ // sees an open gate). Guard against a stale resolve: if a reconnect
1879
+ // already superseded this socket, _socketGeneration has advanced past
1880
+ // connectGeneration and we must NOT mark the new in-flight socket
1881
+ // handshaken — that would re-admit the very 1008 race this fix closes.
1882
+ if (connectGeneration === this._socketGeneration) {
1883
+ this._handshakeGeneration = connectGeneration;
1884
+ }
1885
+
1809
1886
  this.emit("connected", {
1810
1887
  protocol: helloOk.protocol,
1811
1888
  tickIntervalMs: this._tickIntervalMs,
package/dist/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { createOcuClawRelayService } from "./runtime/relay-service.js";
2
2
  import { createEvenAiModelHook } from "./even-ai/even-ai-model-hook.js";
3
+ import { createChannelTwoHook } from "./runtime/channel-two-hook.js";
3
4
  import { registerGlassesUiTool } from "./tools/glasses-ui-tool.js";
4
5
  import { registerSessionTitleTool } from "./tools/session-title-tool.js";
5
6
  import { registerDeviceInfoTool } from "./tools/device-info-tool.js";
7
+ import { registerSessionTitleDistiller } from "./runtime/register-session-title-distiller.js";
6
8
 
7
9
  export default function register(api) {
8
10
  if (!api || typeof api.registerService !== "function") {
@@ -27,14 +29,27 @@ export default function register(api) {
27
29
  },
28
30
  }),
29
31
  );
32
+ api.on(
33
+ "before_prompt_build",
34
+ createChannelTwoHook(
35
+ {
36
+ getDisplayStartStates: (k) => service.getDisplayStartStates(k),
37
+ getDisplayCurrentStates: (k) => service.getDisplayCurrentStates(k),
38
+ hasConnectedAppClient: () => service.hasConnectedAppClient(),
39
+ },
40
+ { emitDebug: (...a) => service.emitDebug(...a) },
41
+ ),
42
+ );
30
43
  }
31
44
 
32
45
  let glassesUiDispose = null;
33
46
  let deviceInfoDispose = null;
47
+ let distillerDispose = null;
34
48
  if (typeof api.registerTool === "function") {
35
49
  glassesUiDispose = registerGlassesUiTool(api, service);
36
50
  registerSessionTitleTool(api, service);
37
51
  deviceInfoDispose = registerDeviceInfoTool(api, service);
52
+ distillerDispose = registerSessionTitleDistiller(api, service);
38
53
  }
39
54
 
40
55
  api.registerService({
@@ -59,6 +74,13 @@ export default function register(api) {
59
74
  /* ignore: dispose is a best-effort cleanup */
60
75
  }
61
76
  }
77
+ if (typeof distillerDispose === "function") {
78
+ try {
79
+ distillerDispose();
80
+ } catch (_) {
81
+ /* ignore: dispose is a best-effort cleanup */
82
+ }
83
+ }
62
84
  return service.stop({ logger: ctx && ctx.logger });
63
85
  },
64
86
  });
@@ -0,0 +1,36 @@
1
+ import { composeChannelTwoFragment } from "../domain/prompt-channel-fragments.js";
2
+
3
+ /**
4
+ * Build a before_prompt_build hook that injects the Channel-2 fragment.
5
+ * @param {{getDisplayStartStates:Function, getDisplayCurrentStates:Function,
6
+ * hasConnectedAppClient:Function}} service
7
+ */
8
+ export function createChannelTwoHook(service, opts = {}) {
9
+ const emitDebug = typeof opts.emitDebug === "function" ? opts.emitDebug : () => {};
10
+ return function channelTwoBeforePromptBuild(_event, ctx) {
11
+ const sessionKey =
12
+ ctx && typeof ctx.sessionKey === "string" && ctx.sessionKey.trim()
13
+ ? ctx.sessionKey
14
+ : null;
15
+ if (!sessionKey) return undefined;
16
+ try {
17
+ const fragment = composeChannelTwoFragment({
18
+ startEnabled: service.getDisplayStartStates(sessionKey),
19
+ currentEnabled: service.getDisplayCurrentStates(sessionKey),
20
+ glassesConnected:
21
+ typeof service.hasConnectedAppClient === "function"
22
+ ? service.hasConnectedAppClient()
23
+ : true,
24
+ });
25
+ if (!fragment) return undefined;
26
+ emitDebug("relay.session", "channel_two_fragment_injected", "debug",
27
+ { sessionKey }, () => ({ chars: fragment.length }));
28
+ return { appendSystemContext: fragment };
29
+ } catch (_err) {
30
+ // Defensive: any state-read failure yields no injection.
31
+ return undefined;
32
+ }
33
+ };
34
+ }
35
+
36
+ export default createChannelTwoHook;
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs";
2
+
3
+ // Marker files written by the container runtimes themselves: Docker creates
4
+ // /.dockerenv, Podman creates /run/.containerenv. Kubernetes-style runtimes
5
+ // leave neither — detection there would need heuristics too fragile to ship.
6
+ const CONTAINER_MARKER_PATHS = ["/.dockerenv", "/run/.containerenv"];
7
+
8
+ export function isLoopbackBindAddress(address) {
9
+ const normalized = typeof address === "string" ? address.trim().toLowerCase() : "";
10
+ if (!normalized) return false;
11
+ return (
12
+ normalized === "localhost" ||
13
+ normalized === "::1" ||
14
+ normalized.startsWith("127.")
15
+ );
16
+ }
17
+
18
+ export function isContainerEnvironment(deps = {}) {
19
+ const existsSync = typeof deps.existsSync === "function" ? deps.existsSync : fs.existsSync;
20
+ const markerPaths = Array.isArray(deps.markerPaths) ? deps.markerPaths : CONTAINER_MARKER_PATHS;
21
+ for (const markerPath of markerPaths) {
22
+ try {
23
+ if (existsSync(markerPath)) return true;
24
+ } catch {
25
+ // No filesystem signal beats a startup failure: treat unreadable markers
26
+ // as "not a container" and keep the relay booting.
27
+ }
28
+ }
29
+ return false;
30
+ }
31
+
32
+ export function composeContainerLoopbackWarning(wsBind, wsPort) {
33
+ return (
34
+ `[ocuclaw] relay is bound to ${wsBind} inside a container — if the OcuClaw app cannot connect, this is likely why. ` +
35
+ `Connections from outside the container arrive via its network interface, which a loopback bind does not listen on, ` +
36
+ `so the relay is unreachable even though it reports healthy. ` +
37
+ `(Containers run with --network host are unaffected and can ignore this warning.) ` +
38
+ `Fix: openclaw config set plugins.entries.ocuclaw.config.wsBind "0.0.0.0" ` +
39
+ `and publish the relay port to the host loopback only (-p 127.0.0.1:${wsPort}:${wsPort}), then restart the gateway.`
40
+ );
41
+ }