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.
- package/README.md +3 -1
- package/dist/config/runtime-config-session-title-model.test.js +22 -0
- package/dist/config/runtime-config.js +24 -15
- package/dist/domain/debug-store.js +18 -0
- package/dist/domain/glasses-display-system-prompt.js +52 -0
- package/dist/domain/glasses-display-system-prompt.test.js +44 -0
- package/dist/domain/glasses-ui-system-prompt.js +6 -22
- package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
- package/dist/domain/prompt-channel-fragments.js +32 -0
- package/dist/domain/prompt-channel-fragments.test.js +70 -0
- package/dist/gateway/gateway-timing-ledger.js +15 -3
- package/dist/gateway/openclaw-client.js +80 -3
- package/dist/index.js +22 -0
- package/dist/runtime/channel-two-hook.js +36 -0
- package/dist/runtime/container-env.js +41 -0
- package/dist/runtime/display-toggle-states.js +98 -0
- package/dist/runtime/plugin-version-service.js +23 -0
- package/dist/runtime/register-session-title-distiller.js +100 -0
- package/dist/runtime/relay-core.js +307 -68
- package/dist/runtime/relay-service.js +120 -13
- package/dist/runtime/relay-worker-entry.js +26 -0
- package/dist/runtime/relay-worker-protocol.js +0 -4
- package/dist/runtime/relay-worker-supervisor.js +43 -79
- package/dist/runtime/relay-worker-transport.js +41 -0
- package/dist/runtime/session-service.js +159 -15
- package/dist/runtime/session-title-distiller-budget.js +36 -0
- package/dist/runtime/session-title-distiller-helpers.js +130 -0
- package/dist/runtime/session-title-distiller.js +354 -0
- package/dist/runtime/session-title-record.js +21 -0
- package/dist/runtime/stable-prompt-snapshot.js +119 -0
- package/dist/tools/glasses-ui-cron.js +9 -3
- package/dist/tools/glasses-ui-paint-floor.js +10 -3
- package/dist/tools/glasses-ui-recipes.js +13 -178
- package/dist/tools/glasses-ui-surfaces.js +8 -1
- package/dist/tools/glasses-ui-tool-description.test.js +16 -0
- package/dist/tools/glasses-ui-tool.js +98 -60
- package/dist/tools/session-title-tool.js +14 -76
- package/dist/tools/session-title-tool.test.js +53 -0
- package/dist/version.js +2 -2
- package/openclaw.plugin.json +9 -0
- package/package.json +6 -4
- package/skills/glasses-ui/SKILL.md +163 -0
- package/dist/runtime/downstream-server.js +0 -2057
- package/dist/runtime/plugin-update-service.js +0 -216
package/README.md
CHANGED
|
@@ -50,7 +50,9 @@ Advanced optional settings:
|
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
52
|
openclaw config set plugins.entries.ocuclaw.config.wsBind "127.0.0.1"
|
|
53
|
-
|
|
53
|
+
# wsPort default is 9000; on Windows that port is often reserved by WinNAT, so the
|
|
54
|
+
# setup assistant uses 47800. Pick any free port in 30000-49151 if you override it.
|
|
55
|
+
openclaw config set plugins.entries.ocuclaw.config.wsPort 47800 --strict-json
|
|
54
56
|
openclaw config set plugins.entries.ocuclaw.config.sessionLimit 10 --strict-json
|
|
55
57
|
openclaw config set plugins.entries.ocuclaw.config.externalDebugToolsEnabled true --strict-json
|
|
56
58
|
```
|
|
@@ -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
|
+
});
|
|
@@ -97,29 +97,36 @@ function resolveDebugNoisyPolicies(pluginValue, envValue) {
|
|
|
97
97
|
return parseJsonOrUndefined(envValue, "debugNoisyPolicies");
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
// Supported live-refresh LLM backends
|
|
101
|
-
// Codex
|
|
102
|
-
// could exfil ~/.aws/credentials,
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
100
|
+
// Supported live-refresh LLM backends — both are HTTP API backends. The two
|
|
101
|
+
// CLI-spawn backends were removed: codex-cli because Codex's read-only sandbox
|
|
102
|
+
// still permits filesystem reads (an agent prompt could exfil ~/.aws/credentials,
|
|
103
|
+
// ~/.ssh/*, etc. via stdout into the glasses surface), and claude-cli to remove
|
|
104
|
+
// the plugin's last child_process spawn so the OpenClaw installer's static
|
|
105
|
+
// dangerous-code scanner passes without --dangerously-force-unsafe-install. (The
|
|
106
|
+
// scanner can't see that --tools "" made the CLI tool-less; it just sees a spawn.
|
|
107
|
+
// This is not an exploit claim about claude-cli — it removes spawn surface and
|
|
108
|
+
// clears the static block.) The proper agentic tier — delegating ticks to the
|
|
109
|
+
// native OpenClaw runtime instead of spawning a CLI — is tracked separately (the
|
|
110
|
+
// glasses-ui L1/L2 delegation redesign) and blocked on request-scoped
|
|
111
|
+
// api.runtime.subagent.run. Operators who want Claude/Codex point an *-api
|
|
112
|
+
// backend at the provider endpoint (key resolved via the host modelAuth).
|
|
106
113
|
const GLASSES_UI_LIVE_BACKENDS = new Set([
|
|
107
|
-
"claude-cli",
|
|
108
114
|
"anthropic-api",
|
|
109
115
|
"openai-compat",
|
|
110
116
|
]);
|
|
111
117
|
|
|
112
118
|
const GLASSES_UI_LIVE_DEFAULT_MODEL = {
|
|
113
|
-
"claude-cli": "claude-haiku-4-5-20251001",
|
|
114
119
|
"anthropic-api": "anthropic/claude-haiku-4-5-20251001",
|
|
115
120
|
"openai-compat": "gpt-4o-mini",
|
|
116
121
|
};
|
|
117
122
|
|
|
118
123
|
function resolveGlassesUiLive(value) {
|
|
119
124
|
const raw = isObject(value) ? value : {};
|
|
125
|
+
// Unknown/removed backends (incl. a stale tickBackend: "claude-cli" or
|
|
126
|
+
// "codex-cli" from an operator's pre-removal config) coerce to this default.
|
|
120
127
|
const tickBackend = GLASSES_UI_LIVE_BACKENDS.has(raw.tickBackend)
|
|
121
128
|
? raw.tickBackend
|
|
122
|
-
: "
|
|
129
|
+
: "anthropic-api";
|
|
123
130
|
const tickModel = pickString(raw.tickModel) || GLASSES_UI_LIVE_DEFAULT_MODEL[tickBackend];
|
|
124
131
|
const tickApiBaseUrl = pickString(raw.tickApiBaseUrl) || "https://api.openai.com";
|
|
125
132
|
return {
|
|
@@ -129,20 +136,21 @@ function resolveGlassesUiLive(value) {
|
|
|
129
136
|
tickApiBaseUrl,
|
|
130
137
|
allowAgentModelOverride: parseBool(raw.allowAgentModelOverride, false),
|
|
131
138
|
tickMaxOutputTokens: parseIntOrDefault(raw.tickMaxOutputTokens, 200),
|
|
132
|
-
// Shell recipes run agent-supplied commands on a schedule as the plugin
|
|
133
|
-
// user. Default to disabled — operators must explicitly opt in via
|
|
134
|
-
// plugins.entries.ocuclaw.config.glassesUiLive.shellEnabled = true.
|
|
135
|
-
shellEnabled: parseBool(raw.shellEnabled, false),
|
|
136
139
|
// http recipes issue agent-influenced outbound network requests on a
|
|
137
140
|
// schedule. The recipe executor blocks loopback/RFC1918/link-local
|
|
138
141
|
// destinations and resolves hostnames through an SSRF-safe dispatcher,
|
|
139
142
|
// but the capability — "the plugin's gateway host can fetch arbitrary
|
|
140
143
|
// public URLs the agent chooses" — is itself worth an operator opt-in.
|
|
141
|
-
//
|
|
144
|
+
// Default to disabled; set
|
|
142
145
|
// plugins.entries.ocuclaw.config.glassesUiLive.httpEnabled = true to
|
|
143
146
|
// enable. The dispatcher protection still applies once enabled.
|
|
144
147
|
httpEnabled: parseBool(raw.httpEnabled, false),
|
|
145
|
-
|
|
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),
|
|
146
154
|
maxConcurrentSurfacesPerHost: parseIntOrDefault(raw.maxConcurrentSurfacesPerHost, 4),
|
|
147
155
|
};
|
|
148
156
|
}
|
|
@@ -231,6 +239,7 @@ export function createRuntimeConfig(opts = {}) {
|
|
|
231
239
|
evenAiDedicatedSessionKey: parseEvenAiDedicatedSessionKey(
|
|
232
240
|
pluginConfig.evenAiDedicatedSessionKey,
|
|
233
241
|
),
|
|
242
|
+
sessionTitleModel: pickString(pluginConfig.sessionTitleModel),
|
|
234
243
|
renderGlassesUiTimeoutMs: parseIntOrDefault(
|
|
235
244
|
pluginConfig.renderGlassesUiTimeoutMs,
|
|
236
245
|
30 * 60 * 1000,
|
|
@@ -157,6 +157,24 @@ function createDebugStore(opts) {
|
|
|
157
157
|
/** @type {Map<string, number>} */
|
|
158
158
|
const enabledUntil = new Map();
|
|
159
159
|
|
|
160
|
+
// Rehydrate the arm from a persisted snapshot. relay-core reads debug-arm.json
|
|
161
|
+
// at construction and passes it here as options.initialEnabled, whose entries are
|
|
162
|
+
// the exact shape getSnapshot().enabled emits. That snapshot is already pruned of
|
|
163
|
+
// expired categories (getEnabledCategories -> pruneExpired), so the `> seedNow`
|
|
164
|
+
// re-check below is defensive belt-and-suspenders. Expired/unknown categories are
|
|
165
|
+
// SILENTLY skipped (do not log or warn on skip). Restoring the ORIGINAL absolute
|
|
166
|
+
// expiresAtMs makes a relay restart transparent without refreshing the TTL window.
|
|
167
|
+
if (Array.isArray(options.initialEnabled)) {
|
|
168
|
+
const seedNow = nowFn();
|
|
169
|
+
for (const entry of options.initialEnabled) {
|
|
170
|
+
if (!entry || typeof entry.cat !== "string") continue;
|
|
171
|
+
if (!categories.has(entry.cat)) continue;
|
|
172
|
+
const expiresAtMs = Number(entry.expiresAtMs);
|
|
173
|
+
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= seedNow) continue;
|
|
174
|
+
enabledUntil.set(entry.cat, Math.floor(expiresAtMs));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
160
178
|
/** @type {Map<string, number>} */
|
|
161
179
|
const noisyCounters = new Map();
|
|
162
180
|
|
|
@@ -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
|
-
"
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|