ocuclaw 1.2.4 → 1.3.1

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