ocuclaw 0.1.0 → 1.3.0

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 (59) hide show
  1. package/README.md +63 -8
  2. package/dist/config/runtime-config.js +81 -3
  3. package/dist/domain/activity-status-adapter.js +138 -605
  4. package/dist/domain/activity-status-arbiter.js +109 -0
  5. package/dist/domain/activity-status-labels.js +906 -0
  6. package/dist/domain/code-span-regions.js +103 -0
  7. package/dist/domain/conversation-state.js +14 -1
  8. package/dist/domain/debug-store.js +41 -184
  9. package/dist/domain/glasses-ui-content-summary.js +62 -0
  10. package/dist/domain/glasses-ui-system-prompt.js +28 -0
  11. package/dist/domain/message-emoji-allowlist.js +16 -0
  12. package/dist/domain/message-emoji-filter.js +33 -55
  13. package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
  14. package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
  15. package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
  16. package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
  17. package/dist/domain/tagged-span-parser.js +121 -0
  18. package/dist/domain/tagged-span-strip.js +38 -0
  19. package/dist/even-ai/even-ai-endpoint.js +91 -0
  20. package/dist/even-ai/even-ai-run-waiter.js +14 -0
  21. package/dist/even-ai/even-ai-settings-store.js +14 -0
  22. package/dist/gateway/gateway-bridge.js +14 -2
  23. package/dist/gateway/gateway-timing-ledger.js +457 -0
  24. package/dist/gateway/openclaw-client.js +462 -38
  25. package/dist/index.js +28 -1
  26. package/dist/runtime/downstream-handler.js +909 -68
  27. package/dist/runtime/downstream-server.js +1004 -512
  28. package/dist/runtime/ocuclaw-settings-store.js +74 -31
  29. package/dist/runtime/plugin-update-service.js +216 -0
  30. package/dist/runtime/protocol-adapter.js +9 -0
  31. package/dist/runtime/provider-usage-select.js +168 -0
  32. package/dist/runtime/relay-client-nudge-controller.js +553 -0
  33. package/dist/runtime/relay-core.js +1357 -210
  34. package/dist/runtime/relay-health-monitor.js +172 -0
  35. package/dist/runtime/relay-operation-registry.js +263 -0
  36. package/dist/runtime/relay-service.js +201 -1
  37. package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
  38. package/dist/runtime/relay-worker-entry.js +32 -0
  39. package/dist/runtime/relay-worker-health.js +272 -0
  40. package/dist/runtime/relay-worker-protocol.js +285 -0
  41. package/dist/runtime/relay-worker-queue.js +202 -0
  42. package/dist/runtime/relay-worker-supervisor.js +1081 -0
  43. package/dist/runtime/relay-worker-transport.js +1051 -0
  44. package/dist/runtime/session-context-service.js +189 -0
  45. package/dist/runtime/session-service.js +656 -38
  46. package/dist/runtime/upstream-runtime.js +1167 -60
  47. package/dist/tools/device-info-tool.js +242 -0
  48. package/dist/tools/glasses-ui-cron.js +427 -0
  49. package/dist/tools/glasses-ui-descriptors.js +261 -0
  50. package/dist/tools/glasses-ui-limits.js +21 -0
  51. package/dist/tools/glasses-ui-paint-floor.js +99 -0
  52. package/dist/tools/glasses-ui-recipes.js +746 -0
  53. package/dist/tools/glasses-ui-surfaces.js +278 -0
  54. package/dist/tools/glasses-ui-template.js +182 -0
  55. package/dist/tools/glasses-ui-tool.js +1147 -0
  56. package/dist/tools/session-title-tool.js +209 -0
  57. package/dist/version.js +2 -0
  58. package/openclaw.plugin.json +163 -15
  59. package/package.json +12 -4
@@ -0,0 +1,1147 @@
1
+ // Glasses UI tool: lets the agent paint an interactive surface on the user's
2
+ // Even G2 glasses HUD instead of replying with text. Plain JSON Schema is used
3
+ // for the tool parameters because the plugin build is a regex-based emitter
4
+ // that has no access to typebox at runtime; OpenClaw consumes JSON Schema
5
+ // directly anyway.
6
+
7
+ import { validateTemplate } from "./glasses-ui-template.js";
8
+ import { createGlassesUiCronEngine } from "./glasses-ui-cron.js";
9
+ import {
10
+ executeShellRecipe,
11
+ executeHttpRecipe,
12
+ executeLlmRecipe,
13
+ executeSystemStatsRecipe,
14
+ } from "./glasses-ui-recipes.js";
15
+ import { createPendingRenderMap, createSurfaceStore, isTerminalOutcome } from "./glasses-ui-surfaces.js";
16
+ import { createPaintFloorCoalescer, DEFAULT_PAINT_FLOOR_MS } from "./glasses-ui-paint-floor.js";
17
+ import { GLASSES_UI_LIMITS } from "./glasses-ui-limits.js";
18
+ import {
19
+ getKindDescriptor,
20
+ listKindStrings,
21
+ buildOneOfBranches,
22
+ } from "./glasses-ui-descriptors.js";
23
+
24
+ // Re-exported so existing consumers/tests that import these from this module
25
+ // keep working after the extractions (spec §Changes A — extraction is behavior-
26
+ // preserving). Canonical homes: createPendingRenderMap/createSurfaceStore ->
27
+ // ./glasses-ui-surfaces.js, GLASSES_UI_LIMITS -> ./glasses-ui-limits.js. Kept as
28
+ // ONE bare `export {}` statement because the CJS emitter (scripts/build.mjs)
29
+ // strips only the first such statement — a second would survive into the .cjs
30
+ // as invalid syntax. createPendingRenderMap is the Phase-1 alias of the single
31
+ // createSurfaceStore (see glasses-ui-surfaces.ts).
32
+ export { createPendingRenderMap, createSurfaceStore, GLASSES_UI_LIMITS };
33
+
34
+ export const GLASSES_UI_REFRESH_LIMITS = {
35
+ intervalMsMin: { shell: 1000, http: 1000, "system-stats": 1000, "llm-cli": 60_000, "llm-api": 30_000 },
36
+ intervalMsMax: 3_600_000,
37
+ maxDurationMsMin: 10_000,
38
+ maxDurationMsMax: 7_200_000,
39
+ maxDurationMsDefault: 30 * 60 * 1000,
40
+ maxConsecutiveFailuresMin: 1,
41
+ maxConsecutiveFailuresMax: 100,
42
+ maxConsecutiveFailuresDefault: 5,
43
+ shellHttpTimeoutMsMin: 1000,
44
+ shellHttpTimeoutMsMax: 30_000,
45
+ shellHttpTimeoutMsDefault: 10_000,
46
+ llmTimeoutMsMin: 5000,
47
+ llmTimeoutMsMax: 60_000,
48
+ llmTimeoutMsDefault: 30_000,
49
+ outputCapBytesMin: 1024,
50
+ outputCapBytesMax: 1_048_576,
51
+ outputCapBytesDefault: 65_536,
52
+ maxOutputTokensMin: 16,
53
+ maxOutputTokensMax: 1000,
54
+ maxOutputTokensDefault: 200,
55
+ // Cap on a single template string (body or one items entry). The
56
+ // substituted OUTPUT is clamped to bodyMax/itemMax at runtime, but a huge
57
+ // template itself is wasted work — 4KB is generous for any HUD line.
58
+ templateMaxChars: 4096,
59
+ // L0' system-stats: bounds on the optional CPU-sample window (ms).
60
+ systemStatsWindowMsMin: 50,
61
+ systemStatsWindowMsMax: 1000,
62
+ };
63
+
64
+ const ON_ERROR_VALUES = new Set(["keep_last", "show_error", "stop"]);
65
+
66
+ function llmIntervalMinForBackend(backend) {
67
+ if (backend === "claude-cli") {
68
+ return GLASSES_UI_REFRESH_LIMITS.intervalMsMin["llm-cli"];
69
+ }
70
+ return GLASSES_UI_REFRESH_LIMITS.intervalMsMin["llm-api"];
71
+ }
72
+
73
+ // The effective per-tick interval floor is the larger of the tier minimum and
74
+ // the paint-floor coalescer's cadence (Spike D, 150ms) — no tick may schedule
75
+ // faster than the glass can paint. Today every tier min already exceeds 150ms,
76
+ // so this only guards the floor from ever relaxing below the coalescer cadence.
77
+ function effectiveIntervalFloorMs(tierMinMs) {
78
+ return Math.max(tierMinMs, DEFAULT_PAINT_FLOOR_MS);
79
+ }
80
+
81
+ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
82
+ if (refresh === undefined || refresh === null) return { ok: true, refresh: undefined };
83
+ if (typeof refresh !== "object" || Array.isArray(refresh)) {
84
+ return { ok: false, code: "refresh_invalid_recipe", message: "refresh must be an object" };
85
+ }
86
+ const cfg = glassesUiLiveCfg && typeof glassesUiLiveCfg === "object" ? glassesUiLiveCfg : {};
87
+ if (cfg.enabled === false) {
88
+ return { ok: false, code: "refresh_disabled", message: "glassesUiLive is disabled by operator config" };
89
+ }
90
+ const recipe = refresh.recipe;
91
+ if (!recipe || typeof recipe !== "object") {
92
+ return { ok: false, code: "refresh_invalid_recipe", message: "refresh.recipe is required" };
93
+ }
94
+ const kind = recipe.kind;
95
+ if (kind !== "shell" && kind !== "http" && kind !== "llm" && kind !== "system-stats") {
96
+ return { ok: false, code: "refresh_invalid_recipe", message: `recipe.kind must be shell/http/llm/system-stats, got ${JSON.stringify(kind)}` };
97
+ }
98
+ // Sanitize the recipe — clamp/reject agent-supplied timeoutMs / outputCapBytes
99
+ // / maxOutputTokens to declared bounds, copy known fields only. The returned
100
+ // `refresh.recipe` is this sanitized version, never the raw input — so the
101
+ // executors at run-time see vetted values.
102
+ const sanitizedRecipe = { kind };
103
+ const bounded = (raw, min, max) => {
104
+ if (!Number.isFinite(raw)) return null;
105
+ if (raw < min || raw > max) return undefined; // signal out-of-range
106
+ return Math.floor(raw);
107
+ };
108
+ if (kind === "shell") {
109
+ if (cfg.shellEnabled === false) return { ok: false, code: "refresh_disabled", message: "shell recipes disabled" };
110
+ if (typeof recipe.command !== "string" || !recipe.command.trim()) {
111
+ return { ok: false, code: "refresh_invalid_recipe", message: "shell recipe requires command (non-empty string)" };
112
+ }
113
+ sanitizedRecipe.command = recipe.command;
114
+ if (recipe.timeoutMs !== undefined) {
115
+ const v = bounded(recipe.timeoutMs, GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMin, GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMax);
116
+ if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `shell.timeoutMs ${recipe.timeoutMs} out of bounds [${GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMin}..${GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMax}]` };
117
+ if (v !== null) sanitizedRecipe.timeoutMs = v;
118
+ }
119
+ if (recipe.outputCapBytes !== undefined) {
120
+ const v = bounded(recipe.outputCapBytes, GLASSES_UI_REFRESH_LIMITS.outputCapBytesMin, GLASSES_UI_REFRESH_LIMITS.outputCapBytesMax);
121
+ if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `shell.outputCapBytes ${recipe.outputCapBytes} out of bounds` };
122
+ if (v !== null) sanitizedRecipe.outputCapBytes = v;
123
+ }
124
+ } else if (kind === "http") {
125
+ if (cfg.httpEnabled === false) return { ok: false, code: "refresh_disabled", message: "http recipes disabled" };
126
+ if (typeof recipe.url !== "string" || !recipe.url.trim()) {
127
+ return { ok: false, code: "refresh_invalid_recipe", message: "http recipe requires url (non-empty string)" };
128
+ }
129
+ sanitizedRecipe.url = recipe.url;
130
+ if (typeof recipe.method === "string") sanitizedRecipe.method = recipe.method;
131
+ if (recipe.headers && typeof recipe.headers === "object") sanitizedRecipe.headers = recipe.headers;
132
+ if (typeof recipe.body === "string") sanitizedRecipe.body = recipe.body;
133
+ if (typeof recipe.jsonPath === "string") sanitizedRecipe.jsonPath = recipe.jsonPath;
134
+ if (recipe.timeoutMs !== undefined) {
135
+ const v = bounded(recipe.timeoutMs, GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMin, GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMax);
136
+ if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `http.timeoutMs ${recipe.timeoutMs} out of bounds [${GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMin}..${GLASSES_UI_REFRESH_LIMITS.shellHttpTimeoutMsMax}]` };
137
+ if (v !== null) sanitizedRecipe.timeoutMs = v;
138
+ }
139
+ if (recipe.outputCapBytes !== undefined) {
140
+ const v = bounded(recipe.outputCapBytes, GLASSES_UI_REFRESH_LIMITS.outputCapBytesMin, GLASSES_UI_REFRESH_LIMITS.outputCapBytesMax);
141
+ if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `http.outputCapBytes ${recipe.outputCapBytes} out of bounds` };
142
+ if (v !== null) sanitizedRecipe.outputCapBytes = v;
143
+ }
144
+ } else if (kind === "llm") {
145
+ if (cfg.llmEnabled === false) return { ok: false, code: "refresh_disabled", message: "llm recipes disabled" };
146
+ if (typeof recipe.prompt !== "string" || !recipe.prompt.trim()) {
147
+ return { ok: false, code: "refresh_invalid_recipe", message: "llm recipe requires prompt (non-empty string)" };
148
+ }
149
+ if (typeof recipe.model === "string" && recipe.model.trim() && cfg.allowAgentModelOverride !== true) {
150
+ return { ok: false, code: "refresh_llm_model_override_denied", message: "agent model override denied by operator config" };
151
+ }
152
+ sanitizedRecipe.prompt = recipe.prompt;
153
+ if (typeof recipe.systemPrompt === "string") sanitizedRecipe.systemPrompt = recipe.systemPrompt;
154
+ if (typeof recipe.model === "string") sanitizedRecipe.model = recipe.model;
155
+ if (recipe.maxOutputTokens !== undefined) {
156
+ const v = bounded(recipe.maxOutputTokens, GLASSES_UI_REFRESH_LIMITS.maxOutputTokensMin, GLASSES_UI_REFRESH_LIMITS.maxOutputTokensMax);
157
+ if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `llm.maxOutputTokens ${recipe.maxOutputTokens} out of bounds` };
158
+ if (v !== null) sanitizedRecipe.maxOutputTokens = v;
159
+ }
160
+ } else if (kind === "system-stats") {
161
+ // Built-in tier: host RAM/CPU via the in-process structured reader. NOT gated
162
+ // by httpEnabled/shellEnabled/llmEnabled — it touches no network, no shell, no
163
+ // model. Only the master `enabled` switch (checked above) governs it. Do NOT
164
+ // add a cfg.*Enabled gate here (intentional — Phase 3 design).
165
+ if (recipe.sampleWindowMs !== undefined) {
166
+ const v = bounded(recipe.sampleWindowMs, GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMin, GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMax);
167
+ if (v === undefined) return { ok: false, code: "refresh_invalid_recipe", message: `system-stats.sampleWindowMs ${recipe.sampleWindowMs} out of bounds [${GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMin}..${GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMax}]` };
168
+ if (v !== null) sanitizedRecipe.sampleWindowMs = v;
169
+ }
170
+ }
171
+
172
+ // Interval bounds.
173
+ const intervalMs = refresh.intervalMs;
174
+ if (!Number.isFinite(intervalMs)) {
175
+ return { ok: false, code: "refresh_invalid_recipe", message: "refresh.intervalMs is required" };
176
+ }
177
+ const minForKind =
178
+ kind === "llm"
179
+ ? llmIntervalMinForBackend(cfg.tickBackend)
180
+ : GLASSES_UI_REFRESH_LIMITS.intervalMsMin[kind];
181
+ const minEffective = effectiveIntervalFloorMs(minForKind);
182
+ if (intervalMs < minEffective) {
183
+ return {
184
+ ok: false,
185
+ code: "refresh_interval_too_low",
186
+ message: `intervalMs ${intervalMs} below minimum ${minEffective} for ${kind}${kind === "llm" ? ` (${cfg.tickBackend})` : ""}`,
187
+ };
188
+ }
189
+ if (intervalMs > GLASSES_UI_REFRESH_LIMITS.intervalMsMax) {
190
+ return { ok: false, code: "refresh_interval_too_high", message: `intervalMs ${intervalMs} above max ${GLASSES_UI_REFRESH_LIMITS.intervalMsMax}` };
191
+ }
192
+
193
+ // Duration.
194
+ const maxDurationMs = Number.isFinite(refresh.maxDurationMs)
195
+ ? refresh.maxDurationMs
196
+ : GLASSES_UI_REFRESH_LIMITS.maxDurationMsDefault;
197
+ if (maxDurationMs < GLASSES_UI_REFRESH_LIMITS.maxDurationMsMin || maxDurationMs > GLASSES_UI_REFRESH_LIMITS.maxDurationMsMax) {
198
+ return { ok: false, code: "refresh_duration_too_high", message: `maxDurationMs ${maxDurationMs} out of bounds` };
199
+ }
200
+
201
+ // onError.
202
+ const onError = typeof refresh.onError === "string" ? refresh.onError : "keep_last";
203
+ if (!ON_ERROR_VALUES.has(onError)) {
204
+ return { ok: false, code: "refresh_invalid_recipe", message: `onError must be keep_last/show_error/stop` };
205
+ }
206
+
207
+ // Templates.
208
+ const targets = refresh.targets && typeof refresh.targets === "object" ? refresh.targets : {};
209
+ if (typeof targets.body === "string") {
210
+ if (targets.body.length > GLASSES_UI_REFRESH_LIMITS.templateMaxChars) {
211
+ return { ok: false, code: "refresh_template_invalid", message: `targets.body template exceeds ${GLASSES_UI_REFRESH_LIMITS.templateMaxChars} chars` };
212
+ }
213
+ const v = validateTemplate(targets.body);
214
+ if (!v.ok) return v;
215
+ }
216
+ if (Array.isArray(targets.items)) {
217
+ // Cap array length — only the first maxItems survive the runtime slice,
218
+ // so a 100k-entry array would burn CPU substituting templates that are
219
+ // immediately discarded. Reject (rather than truncate) so the agent gets
220
+ // clear feedback that it over-supplied.
221
+ if (targets.items.length > GLASSES_UI_LIMITS.maxItems) {
222
+ return {
223
+ ok: false,
224
+ code: "refresh_invalid_recipe",
225
+ message: `targets.items has ${targets.items.length} entries; max is ${GLASSES_UI_LIMITS.maxItems}`,
226
+ };
227
+ }
228
+ for (let i = 0; i < targets.items.length; i += 1) {
229
+ const item = targets.items[i];
230
+ if (typeof item === "string") {
231
+ if (item.length > GLASSES_UI_REFRESH_LIMITS.templateMaxChars) {
232
+ return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}] template exceeds ${GLASSES_UI_REFRESH_LIMITS.templateMaxChars} chars` };
233
+ }
234
+ const v = validateTemplate(item);
235
+ if (!v.ok) return v;
236
+ } else if (item && typeof item === "object" && !Array.isArray(item)) {
237
+ // {label, body?} per-item templates (list_with_details detail bodies).
238
+ if (typeof item.label !== "string") {
239
+ return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}].label must be a string template` };
240
+ }
241
+ for (const field of ["label", "body"]) {
242
+ const tpl = item[field];
243
+ if (tpl === undefined) continue;
244
+ if (typeof tpl !== "string") {
245
+ return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}].${field} must be a string template` };
246
+ }
247
+ if (tpl.length > GLASSES_UI_REFRESH_LIMITS.templateMaxChars) {
248
+ return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}].${field} template exceeds ${GLASSES_UI_REFRESH_LIMITS.templateMaxChars} chars` };
249
+ }
250
+ const v = validateTemplate(tpl);
251
+ if (!v.ok) return v;
252
+ }
253
+ } else {
254
+ return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}] must be a string or {label, body} template` };
255
+ }
256
+ }
257
+ }
258
+
259
+ return {
260
+ ok: true,
261
+ refresh: {
262
+ recipe: sanitizedRecipe,
263
+ intervalMs,
264
+ targets,
265
+ onError,
266
+ maxDurationMs,
267
+ maxConsecutiveFailures: Number.isFinite(refresh.maxConsecutiveFailures)
268
+ ? Math.max(1, Math.min(100, Math.floor(refresh.maxConsecutiveFailures)))
269
+ : GLASSES_UI_REFRESH_LIMITS.maxConsecutiveFailuresDefault,
270
+ },
271
+ };
272
+ }
273
+
274
+ const updateSchemaForToolParams = {
275
+ type: "string",
276
+ enum: ["patch", "replace", "push"],
277
+ description:
278
+ "How this render relates to the current surface. " +
279
+ "\"patch\": change some fields of the current screen (cron keeps ticking). " +
280
+ "\"replace\" (default): swap the whole current screen content (no back-target). " +
281
+ "\"push\": stack a new screen; the parent is retained and its cron pauses.",
282
+ };
283
+
284
+ const refreshSchemaForToolParams = {
285
+ type: "object",
286
+ description: "Optional periodic refresh policy; turns this surface into a live-updating one.",
287
+ required: ["recipe", "intervalMs"],
288
+ properties: {
289
+ intervalMs: { type: "integer", minimum: 1000, maximum: 3_600_000 },
290
+ maxDurationMs: { type: "integer", minimum: 10_000, maximum: 7_200_000 },
291
+ maxConsecutiveFailures: { type: "integer", minimum: 1, maximum: 100 },
292
+ onError: { type: "string", enum: ["keep_last", "show_error", "stop"] },
293
+ targets: {
294
+ type: "object",
295
+ properties: {
296
+ body: { type: "string" },
297
+ items: {
298
+ type: "array",
299
+ items: {
300
+ oneOf: [
301
+ { type: "string" },
302
+ { type: "object", required: ["label"], properties: { label: { type: "string" }, body: { type: "string" } } },
303
+ ],
304
+ },
305
+ },
306
+ },
307
+ },
308
+ recipe: {
309
+ oneOf: [
310
+ {
311
+ type: "object",
312
+ required: ["kind", "command"],
313
+ properties: {
314
+ kind: { const: "shell" },
315
+ command: { type: "string" },
316
+ timeoutMs: { type: "integer" },
317
+ outputCapBytes: { type: "integer" },
318
+ },
319
+ },
320
+ {
321
+ type: "object",
322
+ required: ["kind", "url"],
323
+ properties: {
324
+ kind: { const: "http" },
325
+ url: { type: "string" },
326
+ method: { type: "string", enum: ["GET", "POST"] },
327
+ headers: { type: "object" },
328
+ body: { type: "string" },
329
+ jsonPath: { type: "string" },
330
+ timeoutMs: { type: "integer" },
331
+ outputCapBytes: { type: "integer" },
332
+ },
333
+ },
334
+ {
335
+ type: "object",
336
+ required: ["kind", "prompt"],
337
+ properties: {
338
+ kind: { const: "llm" },
339
+ prompt: { type: "string" },
340
+ systemPrompt: { type: "string" },
341
+ model: { type: "string" },
342
+ maxOutputTokens: { type: "integer" },
343
+ },
344
+ },
345
+ {
346
+ type: "object",
347
+ required: ["kind"],
348
+ properties: {
349
+ kind: { const: "system-stats" },
350
+ sampleWindowMs: { type: "integer", minimum: 50, maximum: 1000 },
351
+ },
352
+ },
353
+ ],
354
+ },
355
+ },
356
+ };
357
+
358
+ // Top-level `properties` lists every field a valid spec may carry across all
359
+ // `kind`s. OpenClaw's Anthropic provider strips `oneOf` when building
360
+ // `input_schema` (it keeps only top-level `properties` + `required`), so
361
+ // without this flat union the model would see `properties: {}` and have to
362
+ // guess the shape from the tool description alone. Per-kind shape constraints
363
+ // are still enforced by `validateGlassesUiSpec` and the JSON Schema `oneOf`
364
+ // below for clients that honor it.
365
+ export const glassesUiParametersSchema = {
366
+ type: "object",
367
+ required: ["kind"],
368
+ properties: {
369
+ kind: {
370
+ type: "string",
371
+ // Enum derived from the descriptor registry (enum order). Adding a kind
372
+ // is one descriptor with no edit here (spec §Modularity).
373
+ enum: listKindStrings(),
374
+ description:
375
+ "Surface kind. Each kind expects a different items/body shape — see " +
376
+ "the tool description for examples.",
377
+ },
378
+ title: {
379
+ type: "string",
380
+ maxLength: GLASSES_UI_LIMITS.titleMax,
381
+ description: "Optional ≤64-char title shown at the top of the surface.",
382
+ },
383
+ body: {
384
+ type: "string",
385
+ maxLength: GLASSES_UI_LIMITS.bodyMax,
386
+ description:
387
+ "Required when kind=\"text_surface\". The ≤1000-char block of text to " +
388
+ "display. Ignored for the list kinds.",
389
+ },
390
+ items: {
391
+ type: "array",
392
+ maxItems: GLASSES_UI_LIMITS.maxItems,
393
+ description:
394
+ "Required when kind=\"list_surface\" or kind=\"list_with_details_surface\". " +
395
+ "For list_surface, an array of plain strings (≤64 chars each), e.g. " +
396
+ "[\"Monday\", \"Tuesday\"]. For list_with_details_surface, an array of " +
397
+ "{label, body?} objects (label ≤64 chars, body ≤200 chars), e.g. " +
398
+ "[{\"label\": \"Monday\", \"body\": \"Cloudy 14C, light rain pm\"}, " +
399
+ "{\"label\": \"Tuesday\", \"body\": \"Sunny 19C\"}]. Up to 20 items.",
400
+ },
401
+ // refresh must be top-level too — the Anthropic provider strips `oneOf`
402
+ // (see the block comment above), so a refresh entry that lives only in
403
+ // the oneOf branches is invisible on that path and the live-refresh
404
+ // feature becomes unreachable. The per-branch copies below stay for
405
+ // clients that honor oneOf.
406
+ refresh: refreshSchemaForToolParams,
407
+ // update is the render-vs-current-surface move (patch/replace/push,
408
+ // default replace). Top-level for the same Anthropic-strips-oneOf reason as
409
+ // refresh; mirrored into every oneOf branch below.
410
+ update: updateSchemaForToolParams,
411
+ },
412
+ // oneOf is assembled from the descriptor registry (one branch per kind, in
413
+ // enum order). Each branch's `refresh` slot — declared `undefined` in the
414
+ // descriptor's schemaBranch — is filled here with the shared refresh schema
415
+ // so the per-branch shape matches today's hand-written one (the tool owns
416
+ // refreshSchemaForToolParams; the descriptor only declares the slot). `update`
417
+ // is mirrored into every branch the same way.
418
+ oneOf: buildOneOfBranches().map((branch) => ({
419
+ ...branch,
420
+ properties: {
421
+ ...branch.properties,
422
+ refresh: refreshSchemaForToolParams,
423
+ update: updateSchemaForToolParams,
424
+ },
425
+ })),
426
+ };
427
+
428
+ export function validateGlassesUiSpec(input) {
429
+ if (!input || typeof input !== "object") {
430
+ return { ok: false, code: "invalid_kind", message: "spec must be an object" };
431
+ }
432
+ const obj = input;
433
+ // Dispatch by kind STRING through the descriptor registry. "No descriptor for
434
+ // kind" reproduces today's invalid_kind. Per-kind validation (incl. the
435
+ // shared title check, which each descriptor runs first) lives in the
436
+ // descriptor's validateSpec, so behavior is identical to the old switch.
437
+ const descriptor = getKindDescriptor(obj.kind);
438
+ if (!descriptor) {
439
+ return {
440
+ ok: false,
441
+ code: "invalid_kind",
442
+ message:
443
+ `kind must be "text_surface", "list_surface", or "list_with_details_surface"; ` +
444
+ `got ${JSON.stringify(obj.kind)}`,
445
+ };
446
+ }
447
+ return descriptor.validateSpec(obj);
448
+ }
449
+
450
+ import { randomUUID } from "node:crypto";
451
+
452
+ // Mirror of even-ai-model-hook's session classifier. Inlined to avoid a
453
+ // cross-file CJS dependency (even-ai-model-hook is ESM-only in dist).
454
+ // Keep these constants in sync with even-ai-model-hook.ts.
455
+ const EVEN_AI_THROWAWAY_SESSION_PREFIX = "ocuclaw:even-ai:";
456
+ const EVEN_AI_DEFAULT_DEDICATED_SESSION_KEY = "ocuclaw:even-ai";
457
+
458
+ function normalizeEvenAiSessionKey(value) {
459
+ if (typeof value !== "string") return "";
460
+ return value.trim().toLowerCase();
461
+ }
462
+
463
+ export function isEvenAiAgentSession(sessionKey, dedicatedSessionKey) {
464
+ const normalized = normalizeEvenAiSessionKey(sessionKey);
465
+ if (!normalized) return false;
466
+ if (
467
+ normalized === EVEN_AI_DEFAULT_DEDICATED_SESSION_KEY ||
468
+ normalized.startsWith(EVEN_AI_THROWAWAY_SESSION_PREFIX)
469
+ ) {
470
+ return true;
471
+ }
472
+ const normalizedDedicated = normalizeEvenAiSessionKey(dedicatedSessionKey);
473
+ return !!normalizedDedicated && normalized === normalizedDedicated;
474
+ }
475
+
476
+ // Default timeout when no per-call or per-handler timeout is provided.
477
+ // Bounds the orphan-tool_use corruption window (see Family 2 in the OpenClaw
478
+ // task-runs zombies / orphan tool_use memory): if a render_glasses_ui call
479
+ // stays unresolved this long, the plugin returns { result: "timeout" } so
480
+ // the agent's runtime persists a matching tool_result and the next turn's
481
+ // session replay won't 400 on an unmatched tool_use block.
482
+ export const DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS = 30 * 60 * 1000;
483
+
484
+ export function createGlassesUiToolHandler(deps) {
485
+ // The single live surface store is constructed below (after the cron engine,
486
+ // which it delegates pause/resume/stop to). There is never a separate pending
487
+ // map alongside it (spec §Core model — one store).
488
+ // Short-lived per-surface capture used to read the cron's merged outcome
489
+ // (ticks{}, lastBody, lastItems, ...extra) when runDynamicUi stops the
490
+ // cron after a user dismissal (pending already resolved with a user-only
491
+ // outcome that lacks ticks). Cleared immediately after stop returns.
492
+ const capturedCronOutcome = new Map();
493
+ const newSurfaceId =
494
+ deps && typeof deps.newSurfaceId === "function"
495
+ ? deps.newSurfaceId
496
+ : () => `ui-${randomUUID().slice(0, 8)}`;
497
+
498
+ // Permanent glasses.lifecycle observability (nav reconcile + cron pause/
499
+ // resume/tick). No-op when the dep is absent (tests) or the debug category is
500
+ // disabled. See docs/superpowers/findings/2026-05-30-glasses-ui-phase4-hardware.md.
501
+ const emitLifecycle =
502
+ typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
503
+
504
+ // Resolve the handler-wide default timeout. Per-call timeouts may still
505
+ // override this via params.timeoutMs.
506
+ function resolveHandlerTimeoutMs() {
507
+ if (!deps || deps.timeoutMs === undefined) return DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
508
+ if (typeof deps.timeoutMs === "function") {
509
+ const v = deps.timeoutMs();
510
+ return Number.isFinite(v) ? v : DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
511
+ }
512
+ return Number.isFinite(deps.timeoutMs) ? deps.timeoutMs : DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
513
+ }
514
+
515
+ // The single plugin->glass send chokepoint (Spike D). EVERY send — the
516
+ // initial render frame and every cron surface_update — routes through this
517
+ // trailing-edge coalescer so bursts collapse to ≤1 frame per 150ms and shed
518
+ // under BLE backpressure. A { __render } sentinel is a full container
519
+ // rebuild; a plain field patch is a surface_update.
520
+ const paintFloor = createPaintFloorCoalescer({
521
+ // Tests may inject paintFloorMs: 0 to disable coalescing (every enqueue is
522
+ // a leading-edge send) so synchronous send-ordering assertions hold;
523
+ // production uses the 150ms Spike-D cadence.
524
+ paintFloorMs: Number.isFinite(deps.paintFloorMs) ? deps.paintFloorMs : DEFAULT_PAINT_FLOOR_MS,
525
+ send: ({ surfaceId, sessionKey, patch }) => {
526
+ if (patch && patch.__render) {
527
+ deps.relay.sendGlassesUiRender({ sessionKey, surfaceId, depth: patch.__depth, spec: patch.__spec });
528
+ } else {
529
+ deps.relay.sendGlassesUiSurfaceUpdate({ sessionKey, surfaceId, patch });
530
+ }
531
+ },
532
+ isUnderBackpressure: typeof deps.isUnderBackpressure === "function" ? deps.isUnderBackpressure : () => false,
533
+ });
534
+
535
+ const cronEngine = createGlassesUiCronEngine({
536
+ emitLifecycle,
537
+ monotonicNowMs: () => performance.now(),
538
+ executeRecipe: async (recipe, ctx) => {
539
+ if (recipe.kind === "shell") return executeShellRecipe(recipe);
540
+ if (recipe.kind === "http") return executeHttpRecipe(recipe);
541
+ if (recipe.kind === "system-stats") return executeSystemStatsRecipe(recipe);
542
+ if (recipe.kind === "llm") return executeLlmRecipe(recipe, ctx);
543
+ return { error: `unknown recipe kind: ${recipe.kind}` };
544
+ },
545
+ glassesUiLimits: GLASSES_UI_LIMITS,
546
+ sendSurfaceUpdate: (params) => paintFloor.enqueue({ surfaceId: params.surfaceId, sessionKey: params.sessionKey, patch: params.patch }),
547
+ resolveLlmCtx: (state) => {
548
+ const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
549
+ const agentModel =
550
+ typeof state.recipe.model === "string" && state.recipe.model.trim() && cfg.allowAgentModelOverride === true
551
+ ? state.recipe.model.trim()
552
+ : null;
553
+ const model = agentModel || cfg.tickModel || "";
554
+ const maxOutputTokens = Number.isFinite(state.recipe.maxOutputTokens)
555
+ ? Math.min(state.recipe.maxOutputTokens, cfg.tickMaxOutputTokens || 200)
556
+ : (cfg.tickMaxOutputTokens || 200);
557
+ return {
558
+ backend: cfg.tickBackend || "claude-cli",
559
+ model,
560
+ baseUrl: cfg.tickApiBaseUrl || "",
561
+ apiKey: deps.resolveLlmApiKey ? deps.resolveLlmApiKey(model) : "",
562
+ maxOutputTokens,
563
+ previousBody: state.lastBody || "",
564
+ };
565
+ },
566
+ });
567
+
568
+ // The SINGLE live surface store (spec §Core model). Constructed after the
569
+ // cron engine so it can delegate pause/resume/stop; id minting reuses the
570
+ // handler's minter. Replaces the Phase-1 pending map — never a second store.
571
+ const surfaceStore = createSurfaceStore({
572
+ pauseCron: (id) => cronEngine.pause(id),
573
+ resumeCron: (id) => cronEngine.resume(id),
574
+ // stopCron fires on every surface teardown (replace swap, popBack child,
575
+ // exit, drain). Dispose the paint-floor coalescer entry too so an armed
576
+ // trailing flush can't paint a stale surface_update onto the now-visible
577
+ // parent/chat after a back/pop, and the per-surface coalescer state doesn't
578
+ // leak across a long push/replace session. (pauseCron on push does NOT
579
+ // dispose — the parent resumes.)
580
+ stopCron: (id) => {
581
+ cronEngine.stop(id, { result: "preempted" });
582
+ paintFloor.dispose(id);
583
+ },
584
+ mintSurfaceId: newSurfaceId,
585
+ });
586
+
587
+ deps.relay.onGlassesUiResult((msg) => {
588
+ if (!msg || typeof msg.surfaceId !== "string" || !msg.outcome) return;
589
+ const terminal = isTerminalOutcome(msg.outcome);
590
+ if (terminal && cronEngine.isActive(msg.surfaceId)) {
591
+ // Terminal with a live cron: stop the cron, merging its tick stats into
592
+ // the outcome via capturedCronOutcome, then settle the in-flight call (or
593
+ // queue the terminal so the next render discards-for-exit).
594
+ let merged = msg.outcome;
595
+ capturedCronOutcome.set(msg.surfaceId, (cronOutcome) => { merged = cronOutcome; });
596
+ cronEngine.stop(msg.surfaceId, msg.outcome);
597
+ capturedCronOutcome.delete(msg.surfaceId);
598
+ if (!surfaceStore.resolve(msg.surfaceId, merged)) {
599
+ surfaceStore.queueEvent(msg.surfaceId, merged);
600
+ }
601
+ return;
602
+ }
603
+ // Nonterminal (selected/back): settle the call if one is pending; otherwise
604
+ // the surface is in visible_awaiting_agent — queue last-wins so the agent's
605
+ // next render delivers the latest event (spec §Tool-call accounting). A
606
+ // terminal with no live cron also lands here (queue → next render tears down).
607
+ if (!surfaceStore.resolve(msg.surfaceId, msg.outcome)) {
608
+ surfaceStore.queueEvent(msg.surfaceId, msg.outcome);
609
+ }
610
+ });
611
+
612
+ async function runDynamicUi(params) {
613
+ const validation = validateGlassesUiSpec(params.spec);
614
+ if (!validation.ok) {
615
+ emitLifecycle("render_rejected", "warn", {
616
+ surfaceId: params && typeof params.surfaceId === "string" ? params.surfaceId : null,
617
+ code: validation.code || "invalid_spec",
618
+ reason: validation.error || validation.message || "spec validation failed",
619
+ });
620
+ const err = new Error(`${validation.code}: ${validation.message}`);
621
+ err.code = validation.code;
622
+ throw err;
623
+ }
624
+ const sessionKey =
625
+ typeof params.sessionKey === "string" && params.sessionKey.trim()
626
+ ? params.sessionKey.trim()
627
+ : "main";
628
+ if (typeof deps.isSessionConnected === "function" && !deps.isSessionConnected(sessionKey)) {
629
+ const err = new Error(
630
+ "glasses_not_connected: no Even glasses client connected for this session",
631
+ );
632
+ err.code = "glasses_not_connected";
633
+ throw err;
634
+ }
635
+
636
+ let refreshValidated;
637
+ if (params.spec && params.spec.refresh !== undefined) {
638
+ const glassesUiLiveCfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : { enabled: true };
639
+ const v = validateRefreshSpec(params.spec.refresh, glassesUiLiveCfg);
640
+ if (!v.ok) {
641
+ const err = new Error(`${v.code}: ${v.message}`);
642
+ err.code = v.code;
643
+ throw err;
644
+ }
645
+ refreshValidated = v.refresh;
646
+ }
647
+
648
+ const depth = Number.isFinite(params.depth) ? Math.max(1, Math.floor(params.depth)) : 1;
649
+ const update =
650
+ params.spec && (params.spec.update === "patch" || params.spec.update === "push")
651
+ ? params.spec.update
652
+ : "replace";
653
+ // The plugin owns surfaceIds (spec §Core model). applyRender derives the
654
+ // target from the session's current top: patch/replace reuse the top id
655
+ // (re-attach in place), push mints a child + pauses the parent cron, the
656
+ // first render mints a root. This is the single place a surfaceId is bound.
657
+ const applied = surfaceStore.applyRender(sessionKey, {
658
+ update,
659
+ kind: validation.spec.kind,
660
+ });
661
+ const surfaceId = applied.surfaceId;
662
+ const promise = surfaceStore.register(sessionKey, surfaceId, { kind: validation.spec.kind });
663
+ // Re-attach flush (last-wins queue / latched exit): only for a patch/replace
664
+ // onto an already-attached surface (the visible_awaiting_agent window can
665
+ // only exist on a surface that previously resolved a call). A fresh
666
+ // root/push has an empty queue and stays visible_pending. onReattached
667
+ // delivers any queued nonterminal against the call we just established, or —
668
+ // if an exit was latched — returns "discarded_for_exit" so this render is
669
+ // dropped and the surface tears down (spec §Tool-call accounting). This is
670
+ // move-independent: replace (the schema default) carries the prior entry's
671
+ // latched exit / queued event forward (Task 8 makeEntry).
672
+ if (applied.mode === "patch" || applied.mode === "replace") {
673
+ const reattach = surfaceStore.onReattached(surfaceId);
674
+ if (reattach === "discarded_for_exit") {
675
+ // onReattached already resolved THIS render's pending call with the
676
+ // latched terminal outcome (so `promise` is settled). Tear down instead
677
+ // of painting the discarded render.
678
+ if (cronEngine.isActive(surfaceId)) cronEngine.stop(surfaceId, { result: "dismissed" });
679
+ surfaceStore.exit(sessionKey);
680
+ return promise;
681
+ }
682
+ }
683
+
684
+ // Initial render uses the agent's seed (instant). Routed through the
685
+ // paint-floor coalescer as a leading-edge render sentinel so it shares the
686
+ // single send chokepoint (a render supersedes any queued field patch for
687
+ // this surface; see glasses-ui-paint-floor mergePatch).
688
+ paintFloor.enqueue({
689
+ surfaceId,
690
+ sessionKey,
691
+ patch: { __render: true, __depth: depth, __spec: validation.spec },
692
+ });
693
+
694
+ // Live-refresh path: kick off the cron in parallel. A `patch` onto an
695
+ // already-ticking surface leaves its cron alone (spec: "cron keeps
696
+ // ticking"); every other move (replace/push/root) starts a cron for the new
697
+ // content when this render carries a refresh.
698
+ if (refreshValidated && !(update === "patch" && cronEngine.isActive(surfaceId))) {
699
+ // Pre-warm the LLM API key cache so tick 1 doesn't see an empty key
700
+ // and fail the smoke test. For non-LLM recipes this is a no-op.
701
+ // For the claude-cli backend the resolved key is "" and not consumed
702
+ // (the CLI auths via its own login), so the prewarm is a harmless
703
+ // no-op there.
704
+ if (refreshValidated.recipe.kind === "llm" && typeof deps.prewarmLlmApiKey === "function") {
705
+ const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
706
+ const agentModel =
707
+ typeof refreshValidated.recipe.model === "string" &&
708
+ refreshValidated.recipe.model.trim() &&
709
+ cfg.allowAgentModelOverride === true
710
+ ? refreshValidated.recipe.model.trim()
711
+ : null;
712
+ const prewarmModel = agentModel || cfg.tickModel || "";
713
+ if (prewarmModel) {
714
+ try {
715
+ await deps.prewarmLlmApiKey(prewarmModel);
716
+ } catch (_) {
717
+ // Cache stays empty; tick 1 will fail and the cron resolves
718
+ // recipe_failed with a useful error from the backend.
719
+ }
720
+ }
721
+ }
722
+ cronEngine.start({
723
+ surfaceId,
724
+ sessionKey,
725
+ refresh: refreshValidated,
726
+ seedBody: validation.spec.body,
727
+ seedItems: validation.spec.items
728
+ ? validation.spec.items.map((it) =>
729
+ typeof it === "string"
730
+ ? it
731
+ : (it && typeof it.label === "string"
732
+ ? (typeof it.body === "string" ? { label: it.label, body: it.body } : { label: it.label })
733
+ : ""),
734
+ )
735
+ : undefined,
736
+ onResolve: (cronOutcome) => {
737
+ // The cron-produced outcome (with ticks + lastBody) becomes the tool
738
+ // result. capturedCronOutcome lets the terminal-via-user path read
739
+ // the merged outcome. Only the cron's OWN terminal outcomes resolve
740
+ // the pending call here (recipe_failed / timeout / external stop);
741
+ // user-action results resolve via onGlassesUiResult above.
742
+ const capture = capturedCronOutcome.get(surfaceId);
743
+ if (capture) capture(cronOutcome);
744
+ if (isTerminalOutcome(cronOutcome)) {
745
+ // Settle the in-flight call; if none is pending (the surface is in
746
+ // visible_awaiting_agent after a nonterminal user action and the
747
+ // cron then hit its own terminal — recipe_failed / maxDuration
748
+ // timeout), QUEUE the terminal so the agent's next render's
749
+ // onReattached returns discarded_for_exit and tears the surface
750
+ // down. Without this fallback the dead-cron surface would persist.
751
+ // Symmetric with the onGlassesUiResult terminal path.
752
+ if (!surfaceStore.resolve(surfaceId, cronOutcome)) {
753
+ surfaceStore.queueEvent(surfaceId, cronOutcome);
754
+ }
755
+ }
756
+ },
757
+ });
758
+ }
759
+
760
+ // Bound the wait via the existing timeout knob — disabled if cron is
761
+ // active (cron has its own maxDurationMs cap and the user explicitly
762
+ // owns the surface lifetime through dismiss).
763
+ const timeoutMs = Number.isFinite(params.timeoutMs)
764
+ ? params.timeoutMs
765
+ : resolveHandlerTimeoutMs();
766
+ if (!refreshValidated && Number.isFinite(timeoutMs) && timeoutMs > 0) {
767
+ const setTimeoutFn =
768
+ deps && typeof deps.setTimeout === "function" ? deps.setTimeout : setTimeout;
769
+ const clearTimeoutFn =
770
+ deps && typeof deps.clearTimeout === "function" ? deps.clearTimeout : clearTimeout;
771
+ const handle = setTimeoutFn(() => {
772
+ // Resolves only if the entry is still pending; surfaceStore.resolve is a
773
+ // no-op when the client already produced a real outcome. timeout is
774
+ // terminal, so it also moves the surface to `exiting`.
775
+ surfaceStore.resolve(surfaceId, { result: "timeout", timeout_ms: timeoutMs });
776
+ }, timeoutMs);
777
+ return promise.then((outcome) => {
778
+ clearTimeoutFn(handle);
779
+ return outcome;
780
+ });
781
+ }
782
+ // Decoupled lifecycle: the call resolves on the user's action (via
783
+ // onGlassesUiResult) or the cron's terminal outcome (via onResolve). A
784
+ // nonterminal selected/back resolves the call WITHOUT stopping the cron —
785
+ // the surface (and its cron) persists until a terminal or a drain. So the
786
+ // old "stop the cron after any resolved outcome" tail is gone.
787
+ return promise;
788
+ }
789
+
790
+ // Per-session last-seen depth, used only to distinguish push (depth up) from
791
+ // pop (depth down) on the client nav-event. The surfaceIds + pause/resume
792
+ // live in surfaceStore (the single source of truth) — there is NO second
793
+ // stack here. On push the parent cron was already paused by applyRender
794
+ // during the agent's push render, so push is idempotent here; on pop we drive
795
+ // surfaceStore.popBack, which stops the child cron and staleness-resumes the
796
+ // parent (Spike B: the plugin owns the resume target).
797
+ const navDepthBySession = new Map(); // sessionKey -> lastSeenDepth
798
+
799
+ function handleNavEvent(sessionKey, ev) {
800
+ const newDepth = Number.isFinite(ev.depth) ? Math.max(1, Math.floor(ev.depth)) : 1;
801
+ const lastDepth = navDepthBySession.get(sessionKey) || surfaceStore.stackDepth(sessionKey) || 1;
802
+ const storeDepthBefore = surfaceStore.stackDepth(sessionKey);
803
+ let popCount = 0;
804
+ let resumedParent = null;
805
+ if (newDepth < lastDepth) {
806
+ // Pop(s): the client popped locally. Reconcile the store to the reported
807
+ // depth — each popBack stops the child cron + resumes the new parent.
808
+ let guard = 0;
809
+ while (surfaceStore.stackDepth(sessionKey) > newDepth && guard < 64) {
810
+ resumedParent = surfaceStore.popBack(sessionKey);
811
+ popCount += 1;
812
+ guard += 1;
813
+ }
814
+ }
815
+ // Push (newDepth > lastDepth) is already reflected in the store by the
816
+ // agent's push render (applyRender), so it is intentionally a no-op here.
817
+ emitLifecycle("nav_reconcile", "debug", {
818
+ sessionKey,
819
+ evSurfaceId: ev.surfaceId,
820
+ evDepth: ev.depth,
821
+ newDepth,
822
+ lastDepth,
823
+ storeDepthBefore,
824
+ popCount,
825
+ resumedParent,
826
+ });
827
+ navDepthBySession.set(sessionKey, newDepth);
828
+ }
829
+
830
+ return {
831
+ runDynamicUi,
832
+ handleNavEvent,
833
+ drainSession(sessionKey, outcome) {
834
+ cronEngine.stopAllForSession(sessionKey, outcome);
835
+ // Resolve pending + delete entries FIRST (so pending calls settle with
836
+ // `outcome`), THEN clear the per-session stack. exit() deletes entries
837
+ // without resolving, so it must NOT run before the drain or the pending
838
+ // promises would hang.
839
+ const reaped = surfaceStore.drainSession(sessionKey, outcome);
840
+ surfaceStore.exit(sessionKey); // clear the stack (crons already stopped)
841
+ navDepthBySession.delete(sessionKey); // drop stale nav depth (stack is now 0)
842
+ return reaped;
843
+ },
844
+ drainAll(outcome) {
845
+ cronEngine.stopAll(outcome);
846
+ const reaped = surfaceStore.drainAll(outcome);
847
+ for (const sessionKey of surfaceStore.sessionKeys()) {
848
+ surfaceStore.exit(sessionKey);
849
+ }
850
+ navDepthBySession.clear();
851
+ return reaped;
852
+ },
853
+ isCronActive(surfaceId) {
854
+ return cronEngine.isActive(surfaceId);
855
+ },
856
+ isCronPaused(surfaceId) {
857
+ const st = cronEngine._debugState(surfaceId);
858
+ return !!(st && st.paused);
859
+ },
860
+ surfaceStackDepth(sessionKey) {
861
+ return surfaceStore.stackDepth(sessionKey);
862
+ },
863
+ sessionForSurface(surfaceId) {
864
+ return surfaceStore.sessionForSurface(surfaceId);
865
+ },
866
+ };
867
+ }
868
+
869
+ export const GLASSES_UI_TOOL_DESCRIPTION = [
870
+ "Render a dynamic interface on the user's Even G2 glasses HUD instead of",
871
+ "replying with text. Three surface kinds:",
872
+ " text_surface — one formatted read-only block (≤1000 chars).",
873
+ " list_surface — a short pickable list, label-only (≤20 × 64 chars).",
874
+ " list_with_details_surface — a pickable list where each item also carries a",
875
+ " short detail body (≤200 chars) shown as the user",
876
+ " scrolls; use when options need a 1-2 sentence",
877
+ " compare-before-choosing detail.",
878
+ "The call blocks until the user selects, dismisses, or backs out. result is one",
879
+ "of: selected, back, dismissed, timeout, recipe_failed, glasses_disconnected.",
880
+ "",
881
+ "Optional params:",
882
+ " refresh — make the surface self-update on a timer (e.g. live host stats via",
883
+ " the built-in system-stats tier). The plugin runs a recipe and",
884
+ " patches the surface in place until the user exits.",
885
+ " update — how this render relates to the current surface: \"patch\" (edit",
886
+ " fields; cron keeps ticking), \"replace\" (default; swap content in",
887
+ " place, no back-target), \"push\" (stack a child screen; the parent",
888
+ " is retained and its cron pauses, resuming on back).",
889
+ "",
890
+ "Before authoring any refreshing/live surface, per-item detail list, or",
891
+ "multi-screen flow, load the \"glasses-ui\" skill — it is the authoring source",
892
+ "of truth: the capability-tier ladder (system-stats host metrics, http data),",
893
+ "picking the lowest tier, recipe recon, the patch/replace/push moves and",
894
+ "exit-to-chat policy, the {{path|filter}} template + per-item {label,body}",
895
+ "reference, and worked examples (including a live system-stats",
896
+ "list_with_details surface). Keep this description lean; depth lives in the skill.",
897
+ ].join("\n");
898
+
899
+ // Shared per-session depth counter. OpenClaw loads the plugin's register(api)
900
+ // in multiple isolated contexts (gateway startup, per-agent-run tool discovery)
901
+ // and each call to registerGlassesUiTool would otherwise close over its own
902
+ // Map. execute() runs in the per-run context's closure while api.on("agent_end",
903
+ // ...) fires from an earlier global-context closure, so reset-on-end would miss
904
+ // the live counter. Stashing the map on globalThis under a stable Symbol gives
905
+ // every load context the same Map to read and mutate.
906
+ const DEPTH_MAP_SYMBOL = Symbol.for("ocuclaw.glasses-ui.depthBySession");
907
+ function getSharedDepthMap() {
908
+ let m = globalThis[DEPTH_MAP_SYMBOL];
909
+ if (!(m instanceof Map)) {
910
+ m = new Map();
911
+ globalThis[DEPTH_MAP_SYMBOL] = m;
912
+ }
913
+ return m;
914
+ }
915
+
916
+ export function registerGlassesUiTool(api, service) {
917
+ if (!api || typeof api.registerTool !== "function") {
918
+ throw new Error("registerGlassesUiTool requires api.registerTool");
919
+ }
920
+ if (!service) {
921
+ throw new Error("registerGlassesUiTool requires the OcuClaw relay service");
922
+ }
923
+
924
+ const depthBySession = getSharedDepthMap();
925
+
926
+ function nextDepth(sessionKey) {
927
+ const prev = depthBySession.get(sessionKey) || 0;
928
+ const next = prev + 1;
929
+ depthBySession.set(sessionKey, next);
930
+ return next;
931
+ }
932
+
933
+ function resetDepth(sessionKey) {
934
+ if (sessionKey) {
935
+ depthBySession.delete(sessionKey);
936
+ } else {
937
+ depthBySession.clear();
938
+ }
939
+ }
940
+
941
+ async function resolveLlmApiKey(modelRef) {
942
+ if (!modelRef) return "";
943
+ try {
944
+ if (
945
+ api.runtime &&
946
+ api.runtime.modelAuth &&
947
+ typeof api.runtime.modelAuth.getApiKeyForModel === "function"
948
+ ) {
949
+ const cfg = api.config;
950
+ const key = await api.runtime.modelAuth.getApiKeyForModel({ model: modelRef, cfg });
951
+ return typeof key === "string" ? key : "";
952
+ }
953
+ } catch (_) {
954
+ /* fall through */
955
+ }
956
+ return "";
957
+ }
958
+
959
+ // Inline resolution: the cron engine asks for the API key once per tick
960
+ // for the current model. We cache the last-resolved model→key pair across
961
+ // ticks since model rarely changes within a cron. runDynamicUi awaits
962
+ // prewarmLlmApiKey before starting the cron, so tick 1 sees the resolved
963
+ // key. resolveLlmApiKeySync is only called after the prewarm has populated
964
+ // the cache; if it ever runs uncached it returns "" and the backend
965
+ // reports a useful error (cron resolves recipe_failed via the breaker).
966
+ let lastModel = null;
967
+ let lastKey = "";
968
+ async function prewarmLlmApiKey(modelRef) {
969
+ if (!modelRef || modelRef === lastModel) return;
970
+ const key = await resolveLlmApiKey(modelRef);
971
+ lastModel = modelRef;
972
+ lastKey = key;
973
+ }
974
+ function resolveLlmApiKeySync(modelRef) {
975
+ if (modelRef === lastModel) return lastKey;
976
+ // Fallback: prewarm wasn't called (or model changed mid-cron). Kick
977
+ // off async resolution but return empty for this tick.
978
+ resolveLlmApiKey(modelRef).then((key) => {
979
+ lastModel = modelRef;
980
+ lastKey = key;
981
+ });
982
+ return "";
983
+ }
984
+
985
+ const handler = createGlassesUiToolHandler({
986
+ relay: {
987
+ sendGlassesUiRender: (msg) => service.sendGlassesUiRender(msg),
988
+ sendGlassesUiSurfaceUpdate: (msg) => service.sendGlassesUiSurfaceUpdate(msg),
989
+ onGlassesUiResult: (cb) => service.onGlassesUiResult(cb),
990
+ },
991
+ emitLifecycle: (event, severity, data) => {
992
+ try {
993
+ if (service && typeof service.emitGlassesUiLifecycle === "function") {
994
+ service.emitGlassesUiLifecycle(event, severity, data);
995
+ }
996
+ } catch (_) {
997
+ // observability must never break the tool path
998
+ }
999
+ },
1000
+ getGlassesUiLiveConfig: () => {
1001
+ try {
1002
+ const cfg = service.getRuntimeConfig && service.getRuntimeConfig();
1003
+ return cfg && cfg.glassesUiLive ? cfg.glassesUiLive : { enabled: false };
1004
+ } catch (_) {
1005
+ return { enabled: false };
1006
+ }
1007
+ },
1008
+ resolveLlmApiKey: resolveLlmApiKeySync,
1009
+ prewarmLlmApiKey,
1010
+ timeoutMs: () => {
1011
+ // Live-read so config hot-reloads (`openclawctl config set …`) take
1012
+ // effect on the next render without a gateway restart. A non-finite
1013
+ // or <=0 value disables the timeout (infinite wait — the pre-2026-05-23
1014
+ // behaviour, kept available as an escape hatch).
1015
+ try {
1016
+ const cfg = service.getRuntimeConfig && service.getRuntimeConfig();
1017
+ const v = cfg && cfg.renderGlassesUiTimeoutMs;
1018
+ return Number.isFinite(v) ? v : DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
1019
+ } catch (_) {
1020
+ return DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
1021
+ }
1022
+ },
1023
+ isSessionConnected: () => {
1024
+ // Consult the shared relay singleton (works across plugin-load
1025
+ // contexts) for any connected downstream client. Per-session
1026
+ // connection tracking would need deeper plumbing; the global check
1027
+ // catches the common failure mode where the tool is invoked with no
1028
+ // glasses client at all, which is what produced the indefinite hangs
1029
+ // before this gate existed.
1030
+ if (typeof service.hasConnectedAppClient === "function") {
1031
+ return service.hasConnectedAppClient();
1032
+ }
1033
+ return false;
1034
+ },
1035
+ isUnderBackpressure: () => {
1036
+ // The paint-floor coalescer sheds the trailing send while the relay/BLE
1037
+ // send buffer is over its high-water mark (Spike D — there is no
1038
+ // glass-side paint-ack, so transport pressure is the only signal). The
1039
+ // signal source is relay-health-monitor's send-buffer high-water; until
1040
+ // relay-service surfaces it as isGlassesSendBufferOverHighWater this
1041
+ // returns false (safe default — no shedding). Completing this query is
1042
+ // part of the BLE-backpressure hardening validated on hardware (Task 20).
1043
+ try {
1044
+ return typeof service.isGlassesSendBufferOverHighWater === "function"
1045
+ ? service.isGlassesSendBufferOverHighWater()
1046
+ : false;
1047
+ } catch (_) {
1048
+ return false;
1049
+ }
1050
+ },
1051
+ });
1052
+
1053
+ // Wire glasses-disconnect to stop any active crons for the affected
1054
+ // session. drainSession is also called by agent_end below; this path is
1055
+ // distinct because a disconnect may happen mid-run without an agent_end.
1056
+ if (typeof service.onAppClientDisconnect === "function") {
1057
+ service.onAppClientDisconnect(({ sessionKey }) => {
1058
+ const target = sessionKey || null;
1059
+ if (target) {
1060
+ handler.drainSession(target, { result: "glasses_disconnected" });
1061
+ } else {
1062
+ handler.drainAll({ result: "glasses_disconnected" });
1063
+ }
1064
+ });
1065
+ }
1066
+
1067
+ // Reconcile client nav-events with the SINGLE store stack (Task 16). On pop
1068
+ // the client reports the surfaceId now back on top + the post-pop depth; the
1069
+ // store knows it. The relay frame carries no sessionKey, so resolve it from
1070
+ // the surface's store entry (sessionForSurface), falling back to "main".
1071
+ if (typeof service.onGlassesUiNavEvent === "function") {
1072
+ service.onGlassesUiNavEvent((ev) => {
1073
+ const sessionKey = handler.sessionForSurface(ev.surfaceId) || "main";
1074
+ handler.handleNavEvent(sessionKey, ev);
1075
+ });
1076
+ }
1077
+
1078
+ function resolveDedicatedEvenAiSessionKey() {
1079
+ try {
1080
+ return service?.getRuntimeConfig?.()?.evenAiDedicatedSessionKey || null;
1081
+ } catch (_) {
1082
+ return null;
1083
+ }
1084
+ }
1085
+
1086
+ api.registerTool(
1087
+ (ctx) => {
1088
+ // Hide the tool from Even AI quick-action runs (dedicated or throwaway
1089
+ // sessions). Those runs' responses go back to the Even Realities native
1090
+ // app — not the OcuClaw chat surface — so a glasses popup wouldn't be
1091
+ // reachable for the user. The tool stays visible to:
1092
+ // • the normal OcuClaw chat path (ocuclaw:<timestamp> sessions)
1093
+ // • the Even AI listen-mode path (which intercepts the HTTP
1094
+ // request and routes it through the active OcuClaw session, so
1095
+ // the sessionKey is an ocuclaw:<timestamp>, not ocuclaw:even-ai*).
1096
+ const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : "";
1097
+ if (isEvenAiAgentSession(sessionKey, resolveDedicatedEvenAiSessionKey())) {
1098
+ return null;
1099
+ }
1100
+ const factorySessionKey = sessionKey || null;
1101
+ return {
1102
+ name: "render_glasses_ui",
1103
+ description: GLASSES_UI_TOOL_DESCRIPTION,
1104
+ parameters: glassesUiParametersSchema,
1105
+ async execute(_toolCallId, params) {
1106
+ const resolvedSessionKey = factorySessionKey || "main";
1107
+ const depth = nextDepth(resolvedSessionKey);
1108
+ try {
1109
+ const outcome = await handler.runDynamicUi({
1110
+ sessionKey: resolvedSessionKey,
1111
+ depth,
1112
+ spec: params,
1113
+ });
1114
+ return {
1115
+ content: [{ type: "text", text: JSON.stringify(outcome) }],
1116
+ };
1117
+ } catch (err) {
1118
+ const prev = depthBySession.get(resolvedSessionKey) || 0;
1119
+ depthBySession.set(resolvedSessionKey, Math.max(0, prev - 1));
1120
+ throw err;
1121
+ }
1122
+ },
1123
+ };
1124
+ },
1125
+ { name: "render_glasses_ui" },
1126
+ );
1127
+
1128
+ if (typeof api.on === "function") {
1129
+ api.on("agent_end", (_event, ctx) => {
1130
+ const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
1131
+ // Drain any still-pending render for this session before clearing the
1132
+ // depth counter. Normal-completion runs will have already resolved
1133
+ // their tool call; this branch is the safety net for runs torn down
1134
+ // externally (timeout, abort) so the in-memory map doesn't leak
1135
+ // across runs.
1136
+ if (sessionKey) {
1137
+ handler.drainSession(sessionKey, { result: "preempted" });
1138
+ }
1139
+ resetDepth(sessionKey);
1140
+ });
1141
+ }
1142
+
1143
+ return function dispose() {
1144
+ handler.drainAll({ result: "preempted" });
1145
+ depthBySession.clear();
1146
+ };
1147
+ }