ocuclaw 1.3.2 → 1.3.4

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 (84) hide show
  1. package/README.md +29 -1
  2. package/dist/config/runtime-config-session-title-model.test.js +0 -3
  3. package/dist/config/runtime-config.js +22 -33
  4. package/dist/domain/activity-status-adapter.js +0 -7
  5. package/dist/domain/activity-status-arbiter.js +3 -27
  6. package/dist/domain/activity-status-labels.js +8 -38
  7. package/dist/domain/code-span-regions.js +4 -24
  8. package/dist/domain/constant-time-equal.js +9 -0
  9. package/dist/domain/constant-time-equal.test.js +28 -0
  10. package/dist/domain/conversation-state.js +27 -138
  11. package/dist/domain/debug-bundle-cache.js +52 -0
  12. package/dist/domain/debug-bundle-format.js +60 -0
  13. package/dist/domain/debug-bundle-preview.js +123 -0
  14. package/dist/domain/debug-bundle-redaction.js +182 -0
  15. package/dist/domain/debug-bundle-save.js +11 -0
  16. package/dist/domain/debug-bundle-zip.js +15 -0
  17. package/dist/domain/debug-bundle.js +97 -0
  18. package/dist/domain/debug-store.js +6 -17
  19. package/dist/domain/debug-upload-preset.js +27 -0
  20. package/dist/domain/glasses-display-system-prompt.js +0 -5
  21. package/dist/domain/glasses-display-system-prompt.test.js +1 -1
  22. package/dist/domain/glasses-ui-content-summary.js +0 -6
  23. package/dist/domain/glasses-ui-system-prompt.test.js +1 -2
  24. package/dist/domain/message-emoji-allowlist.js +0 -7
  25. package/dist/domain/message-emoji-filter.js +3 -9
  26. package/dist/domain/neural-emoji-reactor-tag-config.js +3 -3
  27. package/dist/domain/prompt-channel-fragments.js +1 -10
  28. package/dist/domain/tagged-span-parser.js +3 -26
  29. package/dist/domain/tagged-span-strip.js +0 -7
  30. package/dist/even-ai/even-ai-endpoint.js +77 -24
  31. package/dist/even-ai/even-ai-run-waiter.js +0 -1
  32. package/dist/even-ai/even-ai-settings-store.js +11 -0
  33. package/dist/gateway/gateway-bridge.js +8 -9
  34. package/dist/gateway/gateway-timing-ledger.js +8 -6
  35. package/dist/gateway/openclaw-client.js +97 -297
  36. package/dist/gateway/sanitize-connect-reason.js +10 -0
  37. package/dist/gateway/sanitize-connect-reason.test.js +34 -0
  38. package/dist/index.js +3 -3
  39. package/dist/runtime/channel-two-hook.js +1 -6
  40. package/dist/runtime/container-env.js +1 -5
  41. package/dist/runtime/debug-bundle-handler.js +159 -0
  42. package/dist/runtime/display-toggle-states.js +6 -17
  43. package/dist/runtime/downstream-handler.js +682 -508
  44. package/dist/runtime/glasses-backpressure-latch.js +93 -0
  45. package/dist/runtime/ocuclaw-settings-store.js +10 -1
  46. package/dist/runtime/openclaw-host-version.js +5 -0
  47. package/dist/runtime/plugin-version-service.js +13 -6
  48. package/dist/runtime/provider-usage-select.js +0 -6
  49. package/dist/runtime/register-session-title-distiller.js +14 -16
  50. package/dist/runtime/relay-core.js +657 -271
  51. package/dist/runtime/relay-service.js +40 -36
  52. package/dist/runtime/relay-worker-approval-replay-cache.js +1 -1
  53. package/dist/runtime/relay-worker-entry.js +1 -2
  54. package/dist/runtime/relay-worker-health.js +2 -10
  55. package/dist/runtime/relay-worker-protocol.js +6 -1
  56. package/dist/runtime/relay-worker-supervisor.js +109 -39
  57. package/dist/runtime/relay-worker-transport.js +157 -15
  58. package/dist/runtime/session-context-service.js +5 -45
  59. package/dist/runtime/session-service.js +157 -175
  60. package/dist/runtime/session-title-distiller-budget.js +1 -5
  61. package/dist/runtime/session-title-distiller-helpers.js +14 -24
  62. package/dist/runtime/session-title-distiller.js +109 -122
  63. package/dist/runtime/session-title-record.js +0 -6
  64. package/dist/runtime/stable-prompt-snapshot.js +3 -14
  65. package/dist/runtime/upstream-runtime.js +600 -103
  66. package/dist/tools/device-info-tool.js +4 -21
  67. package/dist/tools/glasses-ui-cron.js +58 -63
  68. package/dist/tools/glasses-ui-descriptors.js +4 -33
  69. package/dist/tools/glasses-ui-limits.js +0 -13
  70. package/dist/tools/glasses-ui-paint-floor.js +22 -34
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +295 -100
  73. package/dist/tools/glasses-ui-template.js +7 -22
  74. package/dist/tools/glasses-ui-tool-description.test.js +2 -2
  75. package/dist/tools/glasses-ui-tool.js +475 -331
  76. package/dist/tools/glasses-ui-voicemail.js +242 -0
  77. package/dist/tools/glasses-ui-wake.js +195 -0
  78. package/dist/tools/session-title-tool.js +2 -7
  79. package/dist/tools/session-title-tool.test.js +1 -1
  80. package/dist/version.js +3 -2
  81. package/openclaw.plugin.json +60 -13
  82. package/package.json +3 -2
  83. package/skills/glasses-ui/SKILL.md +19 -3
  84. package/dist/runtime/protocol-adapter.js +0 -387
@@ -1,17 +1,15 @@
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
1
  import { validateTemplate } from "./glasses-ui-template.js";
8
2
  import { createGlassesUiCronEngine } from "./glasses-ui-cron.js";
9
3
  import {
10
4
  executeHttpRecipe,
11
5
  executeLlmRecipe,
12
6
  executeSystemStatsRecipe,
7
+ normalizeHttpAllowHosts,
8
+ isHttpHostAllowed,
13
9
  } from "./glasses-ui-recipes.js";
14
- import { createPendingRenderMap, createSurfaceStore, isTerminalOutcome } from "./glasses-ui-surfaces.js";
10
+ import { createPendingRenderMap, createSurfaceStore, isTerminalOutcome, normalizeGlassesSessionKey } from "./glasses-ui-surfaces.js";
11
+ import { createGlassesWakeController } from "./glasses-ui-wake.js";
12
+ import { createGlassesVoicemail } from "./glasses-ui-voicemail.js";
15
13
  import { createPaintFloorCoalescer, DEFAULT_PAINT_FLOOR_MS } from "./glasses-ui-paint-floor.js";
16
14
  import { GLASSES_UI_LIMITS } from "./glasses-ui-limits.js";
17
15
  import {
@@ -20,14 +18,6 @@ import {
20
18
  buildOneOfBranches,
21
19
  } from "./glasses-ui-descriptors.js";
22
20
 
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
21
  export { createPendingRenderMap, createSurfaceStore, GLASSES_UI_LIMITS };
32
22
 
33
23
  export const GLASSES_UI_REFRESH_LIMITS = {
@@ -51,21 +41,15 @@ export const GLASSES_UI_REFRESH_LIMITS = {
51
41
  maxOutputTokensMin: 16,
52
42
  maxOutputTokensMax: 1000,
53
43
  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.
44
+
57
45
  templateMaxChars: 4096,
58
- // L0' system-stats: bounds on the optional CPU-sample window (ms).
46
+
59
47
  systemStatsWindowMsMin: 50,
60
48
  systemStatsWindowMsMax: 1000,
61
49
  };
62
50
 
63
51
  const ON_ERROR_VALUES = new Set(["keep_last", "show_error", "stop"]);
64
52
 
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
53
  function effectiveIntervalFloorMs(tierMinMs) {
70
54
  return Math.max(tierMinMs, DEFAULT_PAINT_FLOOR_MS);
71
55
  }
@@ -87,14 +71,11 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
87
71
  if (kind !== "http" && kind !== "llm" && kind !== "system-stats") {
88
72
  return { ok: false, code: "refresh_invalid_recipe", message: `recipe.kind must be http/llm/system-stats, got ${JSON.stringify(kind)}` };
89
73
  }
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.
74
+
94
75
  const sanitizedRecipe = { kind };
95
76
  const bounded = (raw, min, max) => {
96
77
  if (!Number.isFinite(raw)) return null;
97
- if (raw < min || raw > max) return undefined; // signal out-of-range
78
+ if (raw < min || raw > max) return undefined;
98
79
  return Math.floor(raw);
99
80
  };
100
81
  if (kind === "http") {
@@ -102,6 +83,13 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
102
83
  if (typeof recipe.url !== "string" || !recipe.url.trim()) {
103
84
  return { ok: false, code: "refresh_invalid_recipe", message: "http recipe requires url (non-empty string)" };
104
85
  }
86
+
87
+ const allowHosts = normalizeHttpAllowHosts(cfg.httpAllowHosts);
88
+ let recipeHost = "";
89
+ try { recipeHost = new URL(recipe.url).hostname; } catch (_) {}
90
+ if (!isHttpHostAllowed(recipeHost, allowHosts)) {
91
+ return { ok: false, code: "refresh_host_not_allowed", message: `http recipe host not in allowlist: ${recipeHost || recipe.url.trim()}` };
92
+ }
105
93
  sanitizedRecipe.url = recipe.url;
106
94
  if (typeof recipe.method === "string") sanitizedRecipe.method = recipe.method;
107
95
  if (recipe.headers && typeof recipe.headers === "object") sanitizedRecipe.headers = recipe.headers;
@@ -134,10 +122,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
134
122
  if (v !== null) sanitizedRecipe.maxOutputTokens = v;
135
123
  }
136
124
  } 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).
125
+
141
126
  if (recipe.sampleWindowMs !== undefined) {
142
127
  const v = bounded(recipe.sampleWindowMs, GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMin, GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMax);
143
128
  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}]` };
@@ -145,7 +130,6 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
145
130
  }
146
131
  }
147
132
 
148
- // Interval bounds.
149
133
  const intervalMs = refresh.intervalMs;
150
134
  if (!Number.isFinite(intervalMs)) {
151
135
  return { ok: false, code: "refresh_invalid_recipe", message: "refresh.intervalMs is required" };
@@ -166,7 +150,6 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
166
150
  return { ok: false, code: "refresh_interval_too_high", message: `intervalMs ${intervalMs} above max ${GLASSES_UI_REFRESH_LIMITS.intervalMsMax}` };
167
151
  }
168
152
 
169
- // Duration.
170
153
  const maxDurationMs = Number.isFinite(refresh.maxDurationMs)
171
154
  ? refresh.maxDurationMs
172
155
  : GLASSES_UI_REFRESH_LIMITS.maxDurationMsDefault;
@@ -174,13 +157,11 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
174
157
  return { ok: false, code: "refresh_duration_too_high", message: `maxDurationMs ${maxDurationMs} out of bounds` };
175
158
  }
176
159
 
177
- // onError.
178
160
  const onError = typeof refresh.onError === "string" ? refresh.onError : "keep_last";
179
161
  if (!ON_ERROR_VALUES.has(onError)) {
180
162
  return { ok: false, code: "refresh_invalid_recipe", message: `onError must be keep_last/show_error/stop` };
181
163
  }
182
164
 
183
- // Templates.
184
165
  const targets = refresh.targets && typeof refresh.targets === "object" ? refresh.targets : {};
185
166
  if (typeof targets.body === "string") {
186
167
  if (targets.body.length > GLASSES_UI_REFRESH_LIMITS.templateMaxChars) {
@@ -190,10 +171,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
190
171
  if (!v.ok) return v;
191
172
  }
192
173
  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.
174
+
197
175
  if (targets.items.length > GLASSES_UI_LIMITS.maxItems) {
198
176
  return {
199
177
  ok: false,
@@ -210,7 +188,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
210
188
  const v = validateTemplate(item);
211
189
  if (!v.ok) return v;
212
190
  } else if (item && typeof item === "object" && !Array.isArray(item)) {
213
- // {label, body?} per-item templates (list_with_details detail bodies).
191
+
214
192
  if (typeof item.label !== "string") {
215
193
  return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}].label must be a string template` };
216
194
  }
@@ -257,6 +235,73 @@ const updateSchemaForToolParams = {
257
235
  "\"push\": stack a new screen; the parent is retained and its cron pauses.",
258
236
  };
259
237
 
238
+ const timeoutMsSchemaForToolParams = {
239
+ type: "integer",
240
+ minimum: 1000,
241
+ maximum: 600_000,
242
+ description:
243
+ "Optional one-shot interaction window for THIS call, in ms (default 90000, " +
244
+ "max 600000). Pass 300000-600000 when expecting the user to read or decide; " +
245
+ "omit for fire-and-forget. Never renewed automatically — re-render to listen again.",
246
+ };
247
+
248
+ const staleAfterMsSchemaForToolParams = {
249
+ type: "integer",
250
+ minimum: 1000,
251
+ maximum: 86_400_000,
252
+ description:
253
+ "Optional per-render staleness window, in ms. A tap parked longer than this " +
254
+ "is still delivered but annotated stale:true — treat a stale actuating tap " +
255
+ "as a re-confirm prompt, never an action. Default absent (no annotation).",
256
+ };
257
+
258
+ export const GLASSES_UI_WINDOW_LIMITS = {
259
+ timeoutMsMin: timeoutMsSchemaForToolParams.minimum,
260
+ timeoutMsMax: timeoutMsSchemaForToolParams.maximum,
261
+ staleAfterMsMin: staleAfterMsSchemaForToolParams.minimum,
262
+ staleAfterMsMax: staleAfterMsSchemaForToolParams.maximum,
263
+ };
264
+
265
+ function validateWindowFields(spec) {
266
+ const out = { ok: true, timeoutMs: undefined, staleAfterMs: undefined };
267
+ if (spec && spec.timeoutMs !== undefined) {
268
+ const v = spec.timeoutMs;
269
+ if (
270
+ !Number.isFinite(v) ||
271
+ v < GLASSES_UI_WINDOW_LIMITS.timeoutMsMin ||
272
+ v > GLASSES_UI_WINDOW_LIMITS.timeoutMsMax
273
+ ) {
274
+ return {
275
+ ok: false,
276
+ code: "timeout_ms_out_of_bounds",
277
+ message:
278
+ `timeoutMs ${JSON.stringify(v)} out of bounds ` +
279
+ `[${GLASSES_UI_WINDOW_LIMITS.timeoutMsMin}..${GLASSES_UI_WINDOW_LIMITS.timeoutMsMax}]; ` +
280
+ "pass 300000-600000 when expecting the user to read or decide, omit for fire-and-forget",
281
+ };
282
+ }
283
+ out.timeoutMs = Math.floor(v);
284
+ }
285
+ if (spec && spec.staleAfterMs !== undefined) {
286
+ const v = spec.staleAfterMs;
287
+ if (
288
+ !Number.isFinite(v) ||
289
+ v < GLASSES_UI_WINDOW_LIMITS.staleAfterMsMin ||
290
+ v > GLASSES_UI_WINDOW_LIMITS.staleAfterMsMax
291
+ ) {
292
+ return {
293
+ ok: false,
294
+ code: "stale_after_ms_out_of_bounds",
295
+ message:
296
+ `staleAfterMs ${JSON.stringify(v)} out of bounds ` +
297
+ `[${GLASSES_UI_WINDOW_LIMITS.staleAfterMsMin}..${GLASSES_UI_WINDOW_LIMITS.staleAfterMsMax}]`,
298
+ };
299
+ }
300
+ out.staleAfterMs = Math.floor(v);
301
+ }
302
+ return out;
303
+ }
304
+
260
305
  const refreshSchemaForToolParams = {
261
306
  type: "object",
262
307
  description: "Optional periodic refresh policy; turns this surface into a live-updating one.",
@@ -321,21 +366,13 @@ const refreshSchemaForToolParams = {
321
366
  },
322
367
  };
323
368
 
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
369
  export const glassesUiParametersSchema = {
332
370
  type: "object",
333
371
  required: ["kind"],
334
372
  properties: {
335
373
  kind: {
336
374
  type: "string",
337
- // Enum derived from the descriptor registry (enum order). Adding a kind
338
- // is one descriptor with no edit here (spec §Modularity).
375
+
339
376
  enum: listKindStrings(),
340
377
  description:
341
378
  "Surface kind. Each kind expects a different items/body shape — see " +
@@ -364,29 +401,23 @@ export const glassesUiParametersSchema = {
364
401
  "[{\"label\": \"Monday\", \"body\": \"Cloudy 14C, light rain pm\"}, " +
365
402
  "{\"label\": \"Tuesday\", \"body\": \"Sunny 19C\"}]. Up to 20 items.",
366
403
  },
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.
404
+
372
405
  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.
406
+
376
407
  update: updateSchemaForToolParams,
408
+
409
+ timeoutMs: timeoutMsSchemaForToolParams,
410
+ staleAfterMs: staleAfterMsSchemaForToolParams,
377
411
  },
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.
412
+
384
413
  oneOf: buildOneOfBranches().map((branch) => ({
385
414
  ...branch,
386
415
  properties: {
387
416
  ...branch.properties,
388
417
  refresh: refreshSchemaForToolParams,
389
418
  update: updateSchemaForToolParams,
419
+ timeoutMs: timeoutMsSchemaForToolParams,
420
+ staleAfterMs: staleAfterMsSchemaForToolParams,
390
421
  },
391
422
  })),
392
423
  };
@@ -396,10 +427,7 @@ export function validateGlassesUiSpec(input) {
396
427
  return { ok: false, code: "invalid_kind", message: "spec must be an object" };
397
428
  }
398
429
  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.
430
+
403
431
  const descriptor = getKindDescriptor(obj.kind);
404
432
  if (!descriptor) {
405
433
  return {
@@ -415,9 +443,6 @@ export function validateGlassesUiSpec(input) {
415
443
 
416
444
  import { randomUUID } from "node:crypto";
417
445
 
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
446
  const EVEN_AI_THROWAWAY_SESSION_PREFIX = "ocuclaw:even-ai:";
422
447
  const EVEN_AI_DEFAULT_DEDICATED_SESSION_KEY = "ocuclaw:even-ai";
423
448
 
@@ -439,36 +464,59 @@ export function isEvenAiAgentSession(sessionKey, dedicatedSessionKey) {
439
464
  return !!normalizedDedicated && normalized === normalizedDedicated;
440
465
  }
441
466
 
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
467
  export const DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS = 30 * 60 * 1000;
449
468
 
469
+ export const GATEWAY_DYNAMIC_TOOL_DEFAULT_TIMEOUT_MS = 90_000;
470
+
471
+ const WINDOW_EXPIRED_HINT =
472
+ "The listen window closed; the surface is still live on glass and keeps " +
473
+ "updating. New taps park - re-render this surface (e.g. update:\"patch\") " +
474
+ "to collect them in this run, or end your turn and they ride the next one.";
475
+
450
476
  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.
477
+
458
478
  const capturedCronOutcome = new Map();
479
+
480
+ const TITLE_BUDGET_PX = 540;
481
+
482
+ function clipBreadcrumb(s, reserveText = "") {
483
+ if (typeof s !== "string" || s.length === 0) return s;
484
+ const charBudget = Math.floor((TITLE_BUDGET_PX - reserveText.length * 20) / 20);
485
+ if (charBudget <= 0) return "";
486
+ if (s.length <= charBudget) return s;
487
+ const segments = s.split(" › ");
488
+
489
+ while (segments.length > 1 && segments.join(" › ").length > charBudget) {
490
+ segments.shift();
491
+ }
492
+ const joined = segments.join(" › ");
493
+ if (joined.length <= charBudget) return joined;
494
+
495
+ return joined.slice(0, charBudget);
496
+ }
497
+
498
+ function emitMarker(sessionKey, surfaceId) {
499
+ if (!surfaceId) return;
500
+ if (surfaceStore.topSurfaceId(sessionKey) !== surfaceId) return;
501
+ const marker = surfaceStore.markerFor(surfaceId);
502
+ if (!marker) return;
503
+ paintFloor.enqueue({ surfaceId, sessionKey, patch: { marker } });
504
+ }
505
+
459
506
  const newSurfaceId =
460
507
  deps && typeof deps.newSurfaceId === "function"
461
508
  ? deps.newSurfaceId
462
509
  : () => `ui-${randomUUID().slice(0, 8)}`;
463
510
 
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 =
511
+ const storeId =
512
+ typeof deps.storeId === "string" && deps.storeId
513
+ ? deps.storeId
514
+ : `st-${Math.random().toString(36).slice(2, 8)}`;
515
+ const baseEmitLifecycle =
468
516
  typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
517
+ const emitLifecycle = (event, severity, data) =>
518
+ baseEmitLifecycle(event, severity, { storeId, ...(data || {}) });
469
519
 
470
- // Resolve the handler-wide default timeout. Per-call timeouts may still
471
- // override this via params.timeoutMs.
472
520
  function resolveHandlerTimeoutMs() {
473
521
  if (!deps || deps.timeoutMs === undefined) return DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
474
522
  if (typeof deps.timeoutMs === "function") {
@@ -478,19 +526,12 @@ export function createGlassesUiToolHandler(deps) {
478
526
  return Number.isFinite(deps.timeoutMs) ? deps.timeoutMs : DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
479
527
  }
480
528
 
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
529
  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.
530
+
490
531
  paintFloorMs: Number.isFinite(deps.paintFloorMs) ? deps.paintFloorMs : DEFAULT_PAINT_FLOOR_MS,
491
532
  send: ({ surfaceId, sessionKey, patch }) => {
492
533
  if (patch && patch.__render) {
493
- deps.relay.sendGlassesUiRender({ sessionKey, surfaceId, depth: patch.__depth, spec: patch.__spec });
534
+ deps.relay.sendGlassesUiRender({ sessionKey, surfaceId, depth: patch.__depth, spec: patch.__spec, marker: patch.__marker });
494
535
  } else {
495
536
  deps.relay.sendGlassesUiSurfaceUpdate({ sessionKey, surfaceId, patch });
496
537
  }
@@ -502,7 +543,11 @@ export function createGlassesUiToolHandler(deps) {
502
543
  emitLifecycle,
503
544
  monotonicNowMs: () => performance.now(),
504
545
  executeRecipe: async (recipe, ctx) => {
505
- if (recipe.kind === "http") return executeHttpRecipe(recipe);
546
+ if (recipe.kind === "http") {
547
+
548
+ const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
549
+ return executeHttpRecipe(recipe, { allowHosts: normalizeHttpAllowHosts(cfg.httpAllowHosts) });
550
+ }
506
551
  if (recipe.kind === "system-stats") return executeSystemStatsRecipe(recipe);
507
552
  if (recipe.kind === "llm") return executeLlmRecipe(recipe, ctx);
508
553
  return { error: `unknown recipe kind: ${recipe.kind}` };
@@ -530,18 +575,14 @@ export function createGlassesUiToolHandler(deps) {
530
575
  },
531
576
  });
532
577
 
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
578
  const surfaceStore = createSurfaceStore({
579
+ storeId,
580
+ emitLifecycle,
581
+
582
+ now: typeof deps.now === "function" ? deps.now : undefined,
537
583
  pauseCron: (id) => cronEngine.pause(id),
538
584
  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.)
585
+
545
586
  stopCron: (id, opts) => {
546
587
  cronEngine.stop(id, { result: "preempted" }, opts);
547
588
  paintFloor.dispose(id);
@@ -549,28 +590,65 @@ export function createGlassesUiToolHandler(deps) {
549
590
  mintSurfaceId: newSurfaceId,
550
591
  });
551
592
 
593
+ const wakeController = createGlassesWakeController({
594
+ dispatchWake: typeof deps.dispatchWake === "function" ? deps.dispatchWake : null,
595
+ isAgentTurnBusy: typeof deps.isAgentTurnBusy === "function" ? deps.isAgentTurnBusy : () => false,
596
+ emitLifecycle,
597
+ now: typeof deps.now === "function" ? deps.now : Date.now,
598
+ wakeCooldownMs: deps.wakeCooldownMs,
599
+ });
600
+
601
+ const voicemail = createGlassesVoicemail({
602
+ now: typeof deps.now === "function" ? deps.now : Date.now,
603
+ ttlMs: deps.voicemailTtlMs,
604
+ drainWakeOutbox: () => wakeController.drainWakeOutbox(),
605
+ drainDeadLetter: (sessionKey) => surfaceStore.drainDeadLetter(sessionKey),
606
+ emitLifecycle,
607
+ });
608
+
552
609
  deps.relay.onGlassesUiResult((msg) => {
553
610
  if (!msg || typeof msg.surfaceId !== "string" || !msg.outcome) return;
554
- const terminal = isTerminalOutcome(msg.outcome);
611
+
612
+ const outcome = {
613
+ ...msg.outcome,
614
+ origin: typeof msg.outcome.origin === "string" ? msg.outcome.origin : "gesture",
615
+ actor: typeof msg.outcome.actor === "string" ? msg.outcome.actor : "wearer",
616
+ };
617
+ const terminal = isTerminalOutcome(outcome);
555
618
  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;
619
+
620
+ let merged = outcome;
560
621
  capturedCronOutcome.set(msg.surfaceId, (cronOutcome) => { merged = cronOutcome; });
561
- cronEngine.stop(msg.surfaceId, msg.outcome);
622
+ cronEngine.stop(msg.surfaceId, outcome);
562
623
  capturedCronOutcome.delete(msg.surfaceId);
563
624
  if (!surfaceStore.resolve(msg.surfaceId, merged)) {
564
625
  surfaceStore.queueEvent(msg.surfaceId, merged);
565
626
  }
566
627
  return;
567
628
  }
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);
629
+
630
+ const sessionKey = surfaceStore.sessionForSurface(msg.surfaceId);
631
+ if (surfaceStore.resolve(msg.surfaceId, outcome)) {
632
+
633
+ emitMarker(sessionKey, msg.surfaceId);
634
+ } else {
635
+ const receipt = surfaceStore.queueEvent(msg.surfaceId, outcome, {
636
+ origin: outcome.origin,
637
+ actor: outcome.actor,
638
+ });
639
+
640
+ if (receipt && !receipt.kind) {
641
+ wakeController.onParkedGesture({
642
+ sessionKey: surfaceStore.sessionForSurface(msg.surfaceId),
643
+ surfaceUuid: receipt.surfaceUuid,
644
+ eventId: receipt.eventId,
645
+ result: outcome.result,
646
+ itemIndex: outcome.selected_index,
647
+ origin: outcome.origin,
648
+ });
649
+ }
650
+
651
+ emitMarker(sessionKey, msg.surfaceId);
574
652
  }
575
653
  });
576
654
 
@@ -586,10 +664,12 @@ export function createGlassesUiToolHandler(deps) {
586
664
  err.code = validation.code;
587
665
  throw err;
588
666
  }
589
- const sessionKey =
667
+
668
+ const sessionKey = normalizeGlassesSessionKey(
590
669
  typeof params.sessionKey === "string" && params.sessionKey.trim()
591
670
  ? params.sessionKey.trim()
592
- : "main";
671
+ : "main",
672
+ );
593
673
  if (typeof deps.isSessionConnected === "function" && !deps.isSessionConnected(sessionKey)) {
594
674
  const err = new Error(
595
675
  "glasses_not_connected: no Even glasses client connected for this session",
@@ -610,24 +690,24 @@ export function createGlassesUiToolHandler(deps) {
610
690
  refreshValidated = v.refresh;
611
691
  }
612
692
 
613
- // params.depth is the execute-level RUN-CALL ordinal (resets at agent_end).
614
- // It is used ONLY as the "first render of this run" signal for stale-stack
615
- // reaping below — it must NEVER reach the wire. The wire depth is derived
616
- // from the store's true stack depth after applyRender (B6: ordinals never
617
- // decrement on Back, so they drift past entry counts and break both the
618
- // plugin pop reconciliation and the client's clear-vs-append decision).
693
+ const windowFields = validateWindowFields(params.spec);
694
+ if (!windowFields.ok) {
695
+ emitLifecycle("render_rejected", "warn", {
696
+ surfaceId: null,
697
+ code: windowFields.code,
698
+ reason: windowFields.message,
699
+ });
700
+ const err = new Error(`${windowFields.code}: ${windowFields.message}`);
701
+ err.code = windowFields.code;
702
+ throw err;
703
+ }
704
+
619
705
  const depth = Number.isFinite(params.depth) ? Math.max(1, Math.floor(params.depth)) : 1;
620
706
  const update =
621
707
  params.spec && (params.spec.update === "patch" || params.spec.update === "push")
622
708
  ? params.spec.update
623
709
  : "replace";
624
- // Stale-stack reaping (B3 safety net): a depth-1 render means NEW ROOT — a
625
- // session stack still holding PUSHED children at that moment is orphan
626
- // residue from an earlier run (e.g. a client that bailed to chat without
627
- // popping). Reap it before registering so a stale child can't swallow this
628
- // render's events or forward a stale latched exit. A SINGLE root entry is
629
- // NOT stale — that's the designed patch/replace re-attach path
630
- // (visible_awaiting_agent), which must keep its latch/queue semantics.
710
+
631
711
  if (depth <= 1 && surfaceStore.stackDepth(sessionKey) > 1) {
632
712
  const stackDepthBefore = surfaceStore.stackDepth(sessionKey);
633
713
  const reapedPending = reapSession(sessionKey, { result: "preempted" });
@@ -637,62 +717,54 @@ export function createGlassesUiToolHandler(deps) {
637
717
  reapedPending,
638
718
  });
639
719
  }
640
- // The plugin owns surfaceIds (spec §Core model). applyRender derives the
641
- // target from the session's current top: patch/replace reuse the top id
642
- // (re-attach in place), push mints a child + pauses the parent cron, the
643
- // first render mints a root. This is the single place a surfaceId is bound.
720
+
721
+ const stackDepthBeforeAttach = surfaceStore.stackDepth(sessionKey);
644
722
  const applied = surfaceStore.applyRender(sessionKey, {
645
723
  update,
646
724
  kind: validation.spec.kind,
647
725
  });
648
726
  const surfaceId = applied.surfaceId;
649
- const promise = surfaceStore.register(sessionKey, surfaceId, { kind: validation.spec.kind });
650
- // Re-attach flush (last-wins queue / latched exit): only for a patch/replace
651
- // onto an already-attached surface (the visible_awaiting_agent window can
652
- // only exist on a surface that previously resolved a call). A fresh
653
- // root/push has an empty queue and stays visible_pending. onReattached
654
- // delivers any queued nonterminal against the call we just established, or —
655
- // if an exit was latched — returns "discarded_for_exit" so this render is
656
- // dropped and the surface tears down (spec §Tool-call accounting). This is
657
- // move-independent: replace (the schema default) carries the prior entry's
658
- // latched exit / queued event forward (Task 8 makeEntry).
727
+
728
+ emitLifecycle("surface_attach", "debug", {
729
+ surfaceId,
730
+ sessionKey,
731
+ mode: applied.mode,
732
+ requestedUpdate: update,
733
+ stackDepthBefore: stackDepthBeforeAttach,
734
+ });
735
+ const promise = surfaceStore.register(sessionKey, surfaceId, {
736
+ kind: validation.spec.kind,
737
+ staleAfterMs: windowFields.staleAfterMs,
738
+ title: typeof validation.spec.title === "string" ? validation.spec.title : undefined,
739
+ });
740
+
659
741
  if (applied.mode === "patch" || applied.mode === "replace") {
660
742
  const reattach = surfaceStore.onReattached(surfaceId);
661
743
  if (reattach === "discarded_for_exit") {
662
- // onReattached already resolved THIS render's pending call with the
663
- // latched terminal outcome (so `promise` is settled). Tear down instead
664
- // of painting the discarded render.
744
+
665
745
  if (cronEngine.isActive(surfaceId)) cronEngine.stop(surfaceId, { result: "dismissed" });
666
746
  surfaceStore.exit(sessionKey);
667
747
  return promise;
668
748
  }
749
+ if (reattach === "reattached_stale_latch_dropped") {
750
+
751
+ emitLifecycle("stale_cron_summary_dropped", "debug", { surfaceId, sessionKey });
752
+ }
669
753
  }
670
754
 
671
- // The wire depth is the TRUE stack depth (entry count) after applyRender:
672
- // root=1, push=parent+1, replace/patch=unchanged. The client keys its
673
- // clear-vs-append-vs-swap decision and Back classification on this value,
674
- // and handleNavEvent's pop loop compares it against the same entry counts.
675
755
  const wireDepth = Math.max(1, surfaceStore.stackDepth(sessionKey));
676
756
 
677
- // Initial render uses the agent's seed (instant). Routed through the
678
- // paint-floor coalescer as a leading-edge render sentinel so it shares the
679
- // single send chokepoint (a render supersedes any queued field patch for
680
- // this surface; see glasses-ui-paint-floor mergePatch).
757
+ const breadcrumb = surfaceStore.breadcrumbFor(sessionKey);
758
+ if (breadcrumb) validation.spec.title = clipBreadcrumb(breadcrumb);
681
759
  paintFloor.enqueue({
682
760
  surfaceId,
683
761
  sessionKey,
684
- patch: { __render: true, __depth: wireDepth, __spec: validation.spec },
762
+
763
+ patch: { __render: true, __depth: wireDepth, __spec: validation.spec, __marker: surfaceStore.markerFor(surfaceId) },
685
764
  });
686
765
 
687
- // Live-refresh path: kick off the cron in parallel. A `patch` onto an
688
- // already-ticking surface leaves its cron alone (spec: "cron keeps
689
- // ticking"); every other move (replace/push/root) starts a cron for the new
690
- // content when this render carries a refresh.
691
766
  if (refreshValidated && !(update === "patch" && cronEngine.isActive(surfaceId))) {
692
- // Pre-warm the LLM API key cache so tick 1 doesn't see an empty key
693
- // and fail the smoke test. For non-LLM recipes this is a no-op. All llm
694
- // backends are HTTP API backends that resolve a key via host modelAuth;
695
- // a missing key degrades to a graceful recipe_failed on tick 1.
767
+
696
768
  if (refreshValidated.recipe.kind === "llm" && typeof deps.prewarmLlmApiKey === "function") {
697
769
  const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
698
770
  const agentModel =
@@ -706,8 +778,7 @@ export function createGlassesUiToolHandler(deps) {
706
778
  try {
707
779
  await deps.prewarmLlmApiKey(prewarmModel);
708
780
  } catch (_) {
709
- // Cache stays empty; tick 1 will fail and the cron resolves
710
- // recipe_failed with a useful error from the backend.
781
+
711
782
  }
712
783
  }
713
784
  }
@@ -726,77 +797,116 @@ export function createGlassesUiToolHandler(deps) {
726
797
  )
727
798
  : undefined,
728
799
  onResolve: (cronOutcome) => {
729
- // The cron-produced outcome (with ticks + lastBody) becomes the tool
730
- // result. capturedCronOutcome lets the terminal-via-user path read
731
- // the merged outcome. Only the cron's OWN terminal outcomes resolve
732
- // the pending call here (recipe_failed / timeout / external stop);
733
- // user-action results resolve via onGlassesUiResult above.
800
+
734
801
  const capture = capturedCronOutcome.get(surfaceId);
735
802
  if (capture) capture(cronOutcome);
736
803
  if (isTerminalOutcome(cronOutcome)) {
737
- // Settle the in-flight call; if none is pending (the surface is in
738
- // visible_awaiting_agent after a nonterminal user action and the
739
- // cron then hit its own terminal — recipe_failed / maxDuration
740
- // timeout), QUEUE the terminal so the agent's next render's
741
- // onReattached returns discarded_for_exit and tears the surface
742
- // down. Without this fallback the dead-cron surface would persist.
743
- // Symmetric with the onGlassesUiResult terminal path.
744
- if (!surfaceStore.resolve(surfaceId, cronOutcome)) {
745
- surfaceStore.queueEvent(surfaceId, cronOutcome);
804
+
805
+ const stamped = {
806
+ ...cronOutcome,
807
+ origin: typeof cronOutcome.origin === "string" ? cronOutcome.origin : "system",
808
+ };
809
+
810
+ if (!surfaceStore.resolve(surfaceId, stamped)) {
811
+ surfaceStore.queueEvent(surfaceId, stamped);
746
812
  }
747
813
  }
748
814
  },
749
815
  });
750
816
  }
751
817
 
752
- // Bound the wait via the existing timeout knob — disabled if cron is
753
- // active (cron has its own maxDurationMs cap and the user explicitly
754
- // owns the surface lifetime through dismiss).
818
+ const setTimeoutFn =
819
+ deps && typeof deps.setTimeout === "function" ? deps.setTimeout : setTimeout;
820
+ const clearTimeoutFn =
821
+ deps && typeof deps.clearTimeout === "function" ? deps.clearTimeout : clearTimeout;
822
+ const cleanups = [];
823
+
824
+ const effectiveWindowMs =
825
+ windowFields.timeoutMs !== undefined
826
+ ? windowFields.timeoutMs
827
+ : GATEWAY_DYNAMIC_TOOL_DEFAULT_TIMEOUT_MS;
828
+
829
+ const wrapUpMarginMs = Math.min(5000, Math.max(2000, Math.floor(effectiveWindowMs * 0.05)));
830
+ const wrapUpDelayMs = Math.max(effectiveWindowMs - wrapUpMarginMs, Math.floor(effectiveWindowMs / 2));
831
+ const windowExpiredOutcome = (extra) =>
832
+ Object.assign(
833
+ {
834
+ result: "window_expired",
835
+ surface_still_live: true,
836
+ window_ms: effectiveWindowMs,
837
+ origin: "system",
838
+ hint: WINDOW_EXPIRED_HINT,
839
+ },
840
+ extra,
841
+ );
842
+ const wrapUpHandle = setTimeoutFn(() => {
843
+ if (surfaceStore.resolve(surfaceId, windowExpiredOutcome())) {
844
+ emitLifecycle("window_expired", "debug", {
845
+ surfaceId,
846
+ sessionKey,
847
+ windowMs: effectiveWindowMs,
848
+ via: "wrap_up_timer",
849
+ });
850
+
851
+ emitMarker(sessionKey, surfaceId);
852
+ }
853
+ }, wrapUpDelayMs);
854
+ cleanups.push(() => clearTimeoutFn(wrapUpHandle));
855
+
856
+ const signal = params.signal;
857
+ if (signal && typeof signal.addEventListener === "function") {
858
+ const onAbort = () => {
859
+ if (surfaceStore.resolve(surfaceId, windowExpiredOutcome({ aborted: true }))) {
860
+ emitLifecycle("window_expired", "debug", {
861
+ surfaceId,
862
+ sessionKey,
863
+ windowMs: effectiveWindowMs,
864
+ via: "abort_signal",
865
+ });
866
+ }
867
+ };
868
+ if (signal.aborted) {
869
+ onAbort();
870
+ } else {
871
+ signal.addEventListener("abort", onAbort, { once: true });
872
+ cleanups.push(() => {
873
+ if (typeof signal.removeEventListener === "function") {
874
+ signal.removeEventListener("abort", onAbort);
875
+ }
876
+ });
877
+ }
878
+ }
879
+
755
880
  const timeoutMs = Number.isFinite(params.timeoutMs)
756
881
  ? params.timeoutMs
757
882
  : resolveHandlerTimeoutMs();
758
883
  if (!refreshValidated && Number.isFinite(timeoutMs) && timeoutMs > 0) {
759
- const setTimeoutFn =
760
- deps && typeof deps.setTimeout === "function" ? deps.setTimeout : setTimeout;
761
- const clearTimeoutFn =
762
- deps && typeof deps.clearTimeout === "function" ? deps.clearTimeout : clearTimeout;
763
884
  const handle = setTimeoutFn(() => {
764
- // Resolves only if the entry is still pending; surfaceStore.resolve is a
765
- // no-op when the client already produced a real outcome. timeout is
766
- // terminal, so it also moves the surface to `exiting`.
767
- surfaceStore.resolve(surfaceId, { result: "timeout", timeout_ms: timeoutMs });
885
+
886
+ surfaceStore.resolve(surfaceId, { result: "timeout", timeout_ms: timeoutMs, origin: "system" });
768
887
  }, timeoutMs);
769
- return promise.then((outcome) => {
770
- clearTimeoutFn(handle);
771
- return outcome;
772
- });
888
+ cleanups.push(() => clearTimeoutFn(handle));
773
889
  }
774
- // Decoupled lifecycle: the call resolves on the user's action (via
775
- // onGlassesUiResult) or the cron's terminal outcome (via onResolve). A
776
- // nonterminal selected/back resolves the call WITHOUT stopping the cron —
777
- // the surface (and its cron) persists until a terminal or a drain. So the
778
- // old "stop the cron after any resolved outcome" tail is gone.
779
- return promise;
890
+
891
+ return promise.then((outcome) => {
892
+ for (const fn of cleanups) {
893
+ try { fn(); } catch (_) { }
894
+ }
895
+ return outcome;
896
+ });
780
897
  }
781
898
 
782
- // Per-session last-seen depth, used only to distinguish push (depth up) from
783
- // pop (depth down) on the client nav-event. The surfaceIds + pause/resume
784
- // live in surfaceStore (the single source of truth) — there is NO second
785
- // stack here. On push the parent cron was already paused by applyRender
786
- // during the agent's push render, so push is idempotent here; on pop we drive
787
- // surfaceStore.popBack, which stops the child cron and staleness-resumes the
788
- // parent (Spike B: the plugin owns the resume target).
789
- const navDepthBySession = new Map(); // sessionKey -> lastSeenDepth
899
+ const navDepthBySession = new Map();
790
900
 
791
- function handleNavEvent(sessionKey, ev) {
901
+ function handleNavEvent(rawSessionKey, ev) {
902
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
792
903
  const newDepth = Number.isFinite(ev.depth) ? Math.max(1, Math.floor(ev.depth)) : 1;
793
904
  const lastDepth = navDepthBySession.get(sessionKey) || surfaceStore.stackDepth(sessionKey) || 1;
794
905
  const storeDepthBefore = surfaceStore.stackDepth(sessionKey);
795
906
  let popCount = 0;
796
907
  let resumedParent = null;
797
908
  if (newDepth < lastDepth) {
798
- // Pop(s): the client popped locally. Reconcile the store to the reported
799
- // depth — each popBack stops the child cron + resumes the new parent.
909
+
800
910
  let guard = 0;
801
911
  while (surfaceStore.stackDepth(sessionKey) > newDepth && guard < 64) {
802
912
  resumedParent = surfaceStore.popBack(sessionKey);
@@ -809,18 +919,14 @@ export function createGlassesUiToolHandler(deps) {
809
919
  storeDepthBefore > 1 &&
810
920
  surfaceStore.topSurfaceId(sessionKey) === ev.surfaceId
811
921
  ) {
812
- // Surface-match fallback (B6): a Back event reports the surfaceId being
813
- // backed OUT OF — the store top. If the depth comparison said no-op
814
- // (drifted ordinals from an older client, or any depth desync) but the
815
- // reported surface IS the top with a parent beneath, pop exactly one
816
- // level. Push events carry the PARENT surfaceId — never the top after a
817
- // push — so this cannot misfire on a push report; and a duplicate Back
818
- // delivery is idempotent (after the pop the top no longer matches).
922
+
819
923
  resumedParent = surfaceStore.popBack(sessionKey);
820
924
  popCount += 1;
821
925
  }
822
- // Push (newDepth > lastDepth) is already reflected in the store by the
823
- // agent's push render (applyRender), so it is intentionally a no-op here.
926
+
927
+ if (popCount > 0 && resumedParent) {
928
+ emitMarker(sessionKey, resumedParent);
929
+ }
824
930
  emitLifecycle("nav_reconcile", "debug", {
825
931
  sessionKey,
826
932
  evSurfaceId: ev.surfaceId,
@@ -834,26 +940,26 @@ export function createGlassesUiToolHandler(deps) {
834
940
  navDepthBySession.set(sessionKey, newDepth);
835
941
  }
836
942
 
837
- // Stop crons, resolve pending calls with `outcome`, clear the session stack.
838
- // Resolve pending + delete entries FIRST (so pending calls settle with
839
- // `outcome`), THEN clear the per-session stack. exit() deletes entries
840
- // without resolving, so it must NOT run before the drain or the pending
841
- // promises would hang. Shared by the public drainSession (agent_end /
842
- // disconnect) and the stale-stack reap in runDynamicUi (B3).
843
- function reapSession(sessionKey, outcome) {
943
+ function reapSession(rawSessionKey, outcome) {
944
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
844
945
  cronEngine.stopAllForSession(sessionKey, outcome);
845
946
  const reaped = surfaceStore.drainSession(sessionKey, outcome);
846
- surfaceStore.exit(sessionKey); // clear the stack (crons already stopped)
847
- navDepthBySession.delete(sessionKey); // drop stale nav depth (stack is now 0)
947
+ surfaceStore.exit(sessionKey);
948
+ navDepthBySession.delete(sessionKey);
848
949
  return reaped;
849
950
  }
850
951
 
851
952
  return {
953
+ storeId,
852
954
  runDynamicUi,
853
955
  handleNavEvent,
854
956
  drainSession(sessionKey, outcome) {
855
957
  return reapSession(sessionKey, outcome);
856
958
  },
959
+
960
+ settleSession(sessionKey, outcome) {
961
+ return surfaceStore.settlePending(sessionKey, outcome);
962
+ },
857
963
  drainAll(outcome) {
858
964
  cronEngine.stopAll(outcome);
859
965
  const reaped = surfaceStore.drainAll(outcome);
@@ -863,6 +969,17 @@ export function createGlassesUiToolHandler(deps) {
863
969
  navDepthBySession.clear();
864
970
  return reaped;
865
971
  },
972
+
973
+ peekWakeOutbox() {
974
+ return wakeController.peekWakeOutbox();
975
+ },
976
+ drainWakeOutbox() {
977
+ return wakeController.drainWakeOutbox();
978
+ },
979
+
980
+ buildVoicemailInjection(sessionKey) {
981
+ return voicemail.buildInjection(sessionKey);
982
+ },
866
983
  isCronActive(surfaceId) {
867
984
  return cronEngine.isActive(surfaceId);
868
985
  },
@@ -873,6 +990,12 @@ export function createGlassesUiToolHandler(deps) {
873
990
  surfaceStackDepth(sessionKey) {
874
991
  return surfaceStore.stackDepth(sessionKey);
875
992
  },
993
+
994
+ parkMarkerOnAgentEnd(sessionKey) {
995
+ surfaceStore.clearAwaitingResponse(sessionKey);
996
+ const top = surfaceStore.topSurfaceId(sessionKey);
997
+ if (top) emitMarker(sessionKey, top);
998
+ },
876
999
  sessionForSurface(surfaceId) {
877
1000
  return surfaceStore.sessionForSurface(surfaceId);
878
1001
  },
@@ -888,8 +1011,8 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
888
1011
  " short detail body (≤200 chars) shown as the user",
889
1012
  " scrolls; use when options need a 1-2 sentence",
890
1013
  " compare-before-choosing detail.",
891
- "The call blocks until the user selects, dismisses, or backs out. result is one",
892
- "of: selected, back, dismissed, timeout, recipe_failed, glasses_disconnected.",
1014
+ "The call carries one one-shot listen window. result is one of: selected,",
1015
+ "back, dismissed, window_expired, timeout, recipe_failed, glasses_disconnected.",
893
1016
  "",
894
1017
  "Optional params:",
895
1018
  " refresh — make the surface self-update on a timer (e.g. live host stats via",
@@ -899,6 +1022,10 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
899
1022
  " fields; cron keeps ticking), \"replace\" (default; swap content in",
900
1023
  " place, no back-target), \"push\" (stack a child screen; the parent",
901
1024
  " is retained and its cron pauses, resuming on back).",
1025
+ " timeoutMs — listen ms (default 90000, max 600000); 300000-600000 when the",
1026
+ " user must read or decide; omit for fire-and-forget.",
1027
+ "window_expired is NOT an error: the surface stays live; taps park — re-render",
1028
+ "(update:\"patch\") to collect, or end your turn (parked taps wake you).",
902
1029
  "",
903
1030
  "Before authoring any refreshing/live surface, per-item detail list, or",
904
1031
  "multi-screen flow, load the \"glasses-ui\" skill — it is the authoring source",
@@ -916,13 +1043,6 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
916
1043
  "\"selected\" result, follow up with another render or a brief one-line ack.",
917
1044
  ].join("\n");
918
1045
 
919
- // Shared per-session depth counter. OpenClaw loads the plugin's register(api)
920
- // in multiple isolated contexts (gateway startup, per-agent-run tool discovery)
921
- // and each call to registerGlassesUiTool would otherwise close over its own
922
- // Map. execute() runs in the per-run context's closure while api.on("agent_end",
923
- // ...) fires from an earlier global-context closure, so reset-on-end would miss
924
- // the live counter. Stashing the map on globalThis under a stable Symbol gives
925
- // every load context the same Map to read and mutate.
926
1046
  const DEPTH_MAP_SYMBOL = Symbol.for("ocuclaw.glasses-ui.depthBySession");
927
1047
  function getSharedDepthMap() {
928
1048
  let m = globalThis[DEPTH_MAP_SYMBOL];
@@ -933,24 +1053,30 @@ function getSharedDepthMap() {
933
1053
  return m;
934
1054
  }
935
1055
 
936
- export function registerGlassesUiTool(api, service) {
1056
+ const HANDLER_SCOPE_SYMBOL = Symbol.for("ocuclaw.glasses-ui.sharedHandler");
1057
+
1058
+ export function registerGlassesUiTool(api, service, opts = {}) {
937
1059
  if (!api || typeof api.registerTool !== "function") {
938
1060
  throw new Error("registerGlassesUiTool requires api.registerTool");
939
1061
  }
940
1062
  if (!service) {
941
1063
  throw new Error("registerGlassesUiTool requires the OcuClaw relay service");
942
1064
  }
1065
+ const scopeHost =
1066
+ opts && opts.scopeHost && typeof opts.scopeHost === "object" ? opts.scopeHost : globalThis;
943
1067
 
944
1068
  const depthBySession = getSharedDepthMap();
945
1069
 
946
- function nextDepth(sessionKey) {
1070
+ function nextDepth(rawSessionKey) {
1071
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
947
1072
  const prev = depthBySession.get(sessionKey) || 0;
948
1073
  const next = prev + 1;
949
1074
  depthBySession.set(sessionKey, next);
950
1075
  return next;
951
1076
  }
952
1077
 
953
- function resetDepth(sessionKey) {
1078
+ function resetDepth(rawSessionKey) {
1079
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
954
1080
  if (sessionKey) {
955
1081
  depthBySession.delete(sessionKey);
956
1082
  } else {
@@ -971,18 +1097,11 @@ export function registerGlassesUiTool(api, service) {
971
1097
  return typeof key === "string" ? key : "";
972
1098
  }
973
1099
  } catch (_) {
974
- /* fall through */
1100
+
975
1101
  }
976
1102
  return "";
977
1103
  }
978
1104
 
979
- // Inline resolution: the cron engine asks for the API key once per tick
980
- // for the current model. We cache the last-resolved model→key pair across
981
- // ticks since model rarely changes within a cron. runDynamicUi awaits
982
- // prewarmLlmApiKey before starting the cron, so tick 1 sees the resolved
983
- // key. resolveLlmApiKeySync is only called after the prewarm has populated
984
- // the cache; if it ever runs uncached it returns "" and the backend
985
- // reports a useful error (cron resolves recipe_failed via the breaker).
986
1105
  let lastModel = null;
987
1106
  let lastKey = "";
988
1107
  async function prewarmLlmApiKey(modelRef) {
@@ -993,8 +1112,7 @@ export function registerGlassesUiTool(api, service) {
993
1112
  }
994
1113
  function resolveLlmApiKeySync(modelRef) {
995
1114
  if (modelRef === lastModel) return lastKey;
996
- // Fallback: prewarm wasn't called (or model changed mid-cron). Kick
997
- // off async resolution but return empty for this tick.
1115
+
998
1116
  resolveLlmApiKey(modelRef).then((key) => {
999
1117
  lastModel = modelRef;
1000
1118
  lastKey = key;
@@ -1002,7 +1120,10 @@ export function registerGlassesUiTool(api, service) {
1002
1120
  return "";
1003
1121
  }
1004
1122
 
1005
- const handler = createGlassesUiToolHandler({
1123
+ let scopeRecord = scopeHost[HANDLER_SCOPE_SYMBOL];
1124
+ const createsHandler = !scopeRecord || !scopeRecord.handler;
1125
+
1126
+ const handler = createsHandler ? createGlassesUiToolHandler({
1006
1127
  relay: {
1007
1128
  sendGlassesUiRender: (msg) => service.sendGlassesUiRender(msg),
1008
1129
  sendGlassesUiSurfaceUpdate: (msg) => service.sendGlassesUiSurfaceUpdate(msg),
@@ -1014,7 +1135,7 @@ export function registerGlassesUiTool(api, service) {
1014
1135
  service.emitGlassesUiLifecycle(event, severity, data);
1015
1136
  }
1016
1137
  } catch (_) {
1017
- // observability must never break the tool path
1138
+
1018
1139
  }
1019
1140
  },
1020
1141
  getGlassesUiLiveConfig: () => {
@@ -1028,10 +1149,7 @@ export function registerGlassesUiTool(api, service) {
1028
1149
  resolveLlmApiKey: resolveLlmApiKeySync,
1029
1150
  prewarmLlmApiKey,
1030
1151
  timeoutMs: () => {
1031
- // Live-read so config hot-reloads (`openclawctl config set …`) take
1032
- // effect on the next render without a gateway restart. A non-finite
1033
- // or <=0 value disables the timeout (infinite wait — the pre-2026-05-23
1034
- // behaviour, kept available as an escape hatch).
1152
+
1035
1153
  try {
1036
1154
  const cfg = service.getRuntimeConfig && service.getRuntimeConfig();
1037
1155
  const v = cfg && cfg.renderGlassesUiTimeoutMs;
@@ -1041,25 +1159,14 @@ export function registerGlassesUiTool(api, service) {
1041
1159
  }
1042
1160
  },
1043
1161
  isSessionConnected: () => {
1044
- // Consult the shared relay singleton (works across plugin-load
1045
- // contexts) for any connected downstream client. Per-session
1046
- // connection tracking would need deeper plumbing; the global check
1047
- // catches the common failure mode where the tool is invoked with no
1048
- // glasses client at all, which is what produced the indefinite hangs
1049
- // before this gate existed.
1162
+
1050
1163
  if (typeof service.hasConnectedAppClient === "function") {
1051
1164
  return service.hasConnectedAppClient();
1052
1165
  }
1053
1166
  return false;
1054
1167
  },
1055
1168
  isUnderBackpressure: () => {
1056
- // The paint-floor coalescer sheds the trailing send while the relay/BLE
1057
- // send buffer is over its high-water mark (Spike D — there is no
1058
- // glass-side paint-ack, so transport pressure is the only signal). The
1059
- // signal source is relay-health-monitor's send-buffer high-water; until
1060
- // relay-service surfaces it as isGlassesSendBufferOverHighWater this
1061
- // returns false (safe default — no shedding). Completing this query is
1062
- // part of the BLE-backpressure hardening validated on hardware (Task 20).
1169
+
1063
1170
  try {
1064
1171
  return typeof service.isGlassesSendBufferOverHighWater === "function"
1065
1172
  ? service.isGlassesSendBufferOverHighWater()
@@ -1068,49 +1175,56 @@ export function registerGlassesUiTool(api, service) {
1068
1175
  return false;
1069
1176
  }
1070
1177
  },
1071
- });
1072
1178
 
1073
- // Wire glasses-disconnect to stop any active crons for the affected
1074
- // session. drainSession is also called by agent_end below; this path is
1075
- // distinct because a disconnect may happen mid-run without an agent_end.
1076
- if (typeof service.onAppClientDisconnect === "function") {
1077
- service.onAppClientDisconnect(({ sessionKey }) => {
1078
- const target = sessionKey || null;
1079
- if (target) {
1080
- handler.drainSession(target, { result: "glasses_disconnected" });
1081
- } else {
1082
- handler.drainAll({ result: "glasses_disconnected" });
1179
+ dispatchWake:
1180
+ typeof service.dispatchGlassesWake === "function"
1181
+ ? (params) => service.dispatchGlassesWake(params)
1182
+ : null,
1183
+ isAgentTurnBusy: (sessionKey) => {
1184
+ try {
1185
+ return typeof service.isAgentTurnBusy === "function"
1186
+ ? !!service.isAgentTurnBusy(sessionKey)
1187
+ : false;
1188
+ } catch (_) {
1189
+ return false;
1083
1190
  }
1084
- });
1085
- }
1191
+ },
1192
+ }) : scopeRecord.handler;
1193
+
1194
+ if (createsHandler) {
1195
+ scopeRecord = { handler, refs: 0 };
1196
+ scopeHost[HANDLER_SCOPE_SYMBOL] = scopeRecord;
1197
+
1198
+ if (typeof service.onAppClientDisconnect === "function") {
1199
+ service.onAppClientDisconnect(({ sessionKey }) => {
1200
+ const target = sessionKey || null;
1201
+ if (target) {
1202
+ handler.drainSession(target, { result: "glasses_disconnected" });
1203
+ } else {
1204
+ handler.drainAll({ result: "glasses_disconnected" });
1205
+ }
1206
+ });
1207
+ }
1208
+
1209
+ if (typeof service.onGlassesUiNavEvent === "function") {
1210
+ service.onGlassesUiNavEvent((ev) => {
1211
+ const sessionKey = handler.sessionForSurface(ev.surfaceId);
1212
+ if (!sessionKey) {
1213
+ try {
1214
+ if (typeof service.emitGlassesUiLifecycle === "function") {
1215
+ service.emitGlassesUiLifecycle("nav_event_skipped_foreign_surface", "debug", {
1216
+ evSurfaceId: ev.surfaceId,
1217
+ evDepth: ev.depth,
1218
+ });
1219
+ }
1220
+ } catch (_) {
1086
1221
 
1087
- // Reconcile client nav-events with the SINGLE store stack (Task 16). On pop
1088
- // the client reports the surfaceId now back on top + the post-pop depth; the
1089
- // store knows it. The relay frame carries no sessionKey, so resolve it from
1090
- // the surface's store entry (sessionForSurface). Every plugin-load context
1091
- // registers one of these handlers on the SHARED relay, so each nav-event
1092
- // fans out to N contexts but at most one context's store knows the surface —
1093
- // a context that cannot resolve it must NO-OP. (The old "main" fallback made
1094
- // the sibling contexts reconcile an empty store carrying stale cross-session
1095
- // lastDepth: the 3-4x duplicate nav_reconcile on hardware, bug B2.)
1096
- if (typeof service.onGlassesUiNavEvent === "function") {
1097
- service.onGlassesUiNavEvent((ev) => {
1098
- const sessionKey = handler.sessionForSurface(ev.surfaceId);
1099
- if (!sessionKey) {
1100
- try {
1101
- if (typeof service.emitGlassesUiLifecycle === "function") {
1102
- service.emitGlassesUiLifecycle("nav_event_skipped_foreign_surface", "debug", {
1103
- evSurfaceId: ev.surfaceId,
1104
- evDepth: ev.depth,
1105
- });
1106
1222
  }
1107
- } catch (_) {
1108
- // observability must never break the nav path
1223
+ return;
1109
1224
  }
1110
- return;
1111
- }
1112
- handler.handleNavEvent(sessionKey, ev);
1113
- });
1225
+ handler.handleNavEvent(sessionKey, ev);
1226
+ });
1227
+ }
1114
1228
  }
1115
1229
 
1116
1230
  function resolveDedicatedEvenAiSessionKey() {
@@ -1123,14 +1237,7 @@ export function registerGlassesUiTool(api, service) {
1123
1237
 
1124
1238
  api.registerTool(
1125
1239
  (ctx) => {
1126
- // Hide the tool from Even AI quick-action runs (dedicated or throwaway
1127
- // sessions). Those runs' responses go back to the Even Realities native
1128
- // app — not the OcuClaw chat surface — so a glasses popup wouldn't be
1129
- // reachable for the user. The tool stays visible to:
1130
- // • the normal OcuClaw chat path (ocuclaw:<timestamp> sessions)
1131
- // • the Even AI listen-mode path (which intercepts the HTTP
1132
- // request and routes it through the active OcuClaw session, so
1133
- // the sessionKey is an ocuclaw:<timestamp>, not ocuclaw:even-ai*).
1240
+
1134
1241
  const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : "";
1135
1242
  if (isEvenAiAgentSession(sessionKey, resolveDedicatedEvenAiSessionKey())) {
1136
1243
  return null;
@@ -1140,14 +1247,16 @@ export function registerGlassesUiTool(api, service) {
1140
1247
  name: "render_glasses_ui",
1141
1248
  description: GLASSES_UI_TOOL_DESCRIPTION,
1142
1249
  parameters: glassesUiParametersSchema,
1143
- async execute(_toolCallId, params) {
1144
- const resolvedSessionKey = factorySessionKey || "main";
1250
+ async execute(_toolCallId, params, signal) {
1251
+ const resolvedSessionKey = normalizeGlassesSessionKey(factorySessionKey || "main");
1145
1252
  const depth = nextDepth(resolvedSessionKey);
1146
1253
  try {
1254
+
1147
1255
  const outcome = await handler.runDynamicUi({
1148
1256
  sessionKey: resolvedSessionKey,
1149
1257
  depth,
1150
1258
  spec: params,
1259
+ signal,
1151
1260
  });
1152
1261
  return {
1153
1262
  content: [{ type: "text", text: JSON.stringify(outcome) }],
@@ -1164,22 +1273,57 @@ export function registerGlassesUiTool(api, service) {
1164
1273
  );
1165
1274
 
1166
1275
  if (typeof api.on === "function") {
1276
+
1277
+ api.on("before_prompt_build", (_event, ctx) => {
1278
+ const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
1279
+ if (!sessionKey) return undefined;
1280
+ try {
1281
+ const fragment = handler.buildVoicemailInjection(sessionKey);
1282
+ return fragment ? { appendSystemContext: fragment } : undefined;
1283
+ } catch (_) {
1284
+
1285
+ return undefined;
1286
+ }
1287
+ });
1288
+
1167
1289
  api.on("agent_end", (_event, ctx) => {
1168
1290
  const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
1169
- // Drain any still-pending render for this session before clearing the
1170
- // depth counter. Normal-completion runs will have already resolved
1171
- // their tool call; this branch is the safety net for runs torn down
1172
- // externally (timeout, abort) so the in-memory map doesn't leak
1173
- // across runs.
1291
+
1174
1292
  if (sessionKey) {
1175
- handler.drainSession(sessionKey, { result: "preempted" });
1293
+ const stackDepth = handler.surfaceStackDepth(sessionKey);
1294
+ const settledPending = handler.settleSession(sessionKey, { result: "preempted" });
1295
+
1296
+ try {
1297
+ if (typeof service.emitGlassesUiLifecycle === "function") {
1298
+ service.emitGlassesUiLifecycle("agent_end_settle", "debug", {
1299
+ sessionKey: normalizeGlassesSessionKey(sessionKey),
1300
+ stackDepth,
1301
+ settledPending,
1302
+ storeId: handler.storeId,
1303
+ });
1304
+ }
1305
+ } catch (_) {
1306
+
1307
+ }
1308
+
1309
+ handler.parkMarkerOnAgentEnd(sessionKey);
1176
1310
  }
1177
1311
  resetDepth(sessionKey);
1178
1312
  });
1179
1313
  }
1180
1314
 
1315
+ scopeRecord.refs += 1;
1316
+ let disposedThisContext = false;
1181
1317
  return function dispose() {
1318
+ if (disposedThisContext) return;
1319
+ disposedThisContext = true;
1320
+ scopeRecord.refs -= 1;
1321
+ if (scopeRecord.refs > 0) return;
1182
1322
  handler.drainAll({ result: "preempted" });
1183
1323
  depthBySession.clear();
1324
+
1325
+ if (scopeHost[HANDLER_SCOPE_SYMBOL] === scopeRecord) {
1326
+ delete scopeHost[HANDLER_SCOPE_SYMBOL];
1327
+ }
1184
1328
  };
1185
1329
  }