ocuclaw 1.3.3 → 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 (83) 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 +2 -24
  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 +601 -290
  51. package/dist/runtime/relay-service.js +19 -47
  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 +103 -41
  57. package/dist/runtime/relay-worker-transport.js +150 -17
  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 +22 -77
  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 +5 -39
  71. package/dist/tools/glasses-ui-recipes.js +92 -101
  72. package/dist/tools/glasses-ui-surfaces.js +31 -163
  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 +87 -451
  76. package/dist/tools/glasses-ui-voicemail.js +6 -63
  77. package/dist/tools/glasses-ui-wake.js +9 -76
  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/dist/runtime/protocol-adapter.js +0 -387
@@ -1,15 +1,11 @@
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
10
  import { createPendingRenderMap, createSurfaceStore, isTerminalOutcome, normalizeGlassesSessionKey } from "./glasses-ui-surfaces.js";
15
11
  import { createGlassesWakeController } from "./glasses-ui-wake.js";
@@ -22,14 +18,6 @@ import {
22
18
  buildOneOfBranches,
23
19
  } from "./glasses-ui-descriptors.js";
24
20
 
25
- // Re-exported so existing consumers/tests that import these from this module
26
- // keep working after the extractions (spec §Changes A — extraction is behavior-
27
- // preserving). Canonical homes: createPendingRenderMap/createSurfaceStore ->
28
- // ./glasses-ui-surfaces.js, GLASSES_UI_LIMITS -> ./glasses-ui-limits.js. Kept as
29
- // ONE bare `export {}` statement because the CJS emitter (scripts/build.mjs)
30
- // strips only the first such statement — a second would survive into the .cjs
31
- // as invalid syntax. createPendingRenderMap is the Phase-1 alias of the single
32
- // createSurfaceStore (see glasses-ui-surfaces.ts).
33
21
  export { createPendingRenderMap, createSurfaceStore, GLASSES_UI_LIMITS };
34
22
 
35
23
  export const GLASSES_UI_REFRESH_LIMITS = {
@@ -53,21 +41,15 @@ export const GLASSES_UI_REFRESH_LIMITS = {
53
41
  maxOutputTokensMin: 16,
54
42
  maxOutputTokensMax: 1000,
55
43
  maxOutputTokensDefault: 200,
56
- // Cap on a single template string (body or one items entry). The
57
- // substituted OUTPUT is clamped to bodyMax/itemMax at runtime, but a huge
58
- // template itself is wasted work — 4KB is generous for any HUD line.
44
+
59
45
  templateMaxChars: 4096,
60
- // L0' system-stats: bounds on the optional CPU-sample window (ms).
46
+
61
47
  systemStatsWindowMsMin: 50,
62
48
  systemStatsWindowMsMax: 1000,
63
49
  };
64
50
 
65
51
  const ON_ERROR_VALUES = new Set(["keep_last", "show_error", "stop"]);
66
52
 
67
- // The effective per-tick interval floor is the larger of the tier minimum and
68
- // the paint-floor coalescer's cadence (Spike D, 150ms) — no tick may schedule
69
- // faster than the glass can paint. Today every tier min already exceeds 150ms,
70
- // so this only guards the floor from ever relaxing below the coalescer cadence.
71
53
  function effectiveIntervalFloorMs(tierMinMs) {
72
54
  return Math.max(tierMinMs, DEFAULT_PAINT_FLOOR_MS);
73
55
  }
@@ -89,14 +71,11 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
89
71
  if (kind !== "http" && kind !== "llm" && kind !== "system-stats") {
90
72
  return { ok: false, code: "refresh_invalid_recipe", message: `recipe.kind must be http/llm/system-stats, got ${JSON.stringify(kind)}` };
91
73
  }
92
- // Sanitize the recipe — clamp/reject agent-supplied timeoutMs / outputCapBytes
93
- // / maxOutputTokens to declared bounds, copy known fields only. The returned
94
- // `refresh.recipe` is this sanitized version, never the raw input — so the
95
- // executors at run-time see vetted values.
74
+
96
75
  const sanitizedRecipe = { kind };
97
76
  const bounded = (raw, min, max) => {
98
77
  if (!Number.isFinite(raw)) return null;
99
- if (raw < min || raw > max) return undefined; // signal out-of-range
78
+ if (raw < min || raw > max) return undefined;
100
79
  return Math.floor(raw);
101
80
  };
102
81
  if (kind === "http") {
@@ -104,6 +83,13 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
104
83
  if (typeof recipe.url !== "string" || !recipe.url.trim()) {
105
84
  return { ok: false, code: "refresh_invalid_recipe", message: "http recipe requires url (non-empty string)" };
106
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
+ }
107
93
  sanitizedRecipe.url = recipe.url;
108
94
  if (typeof recipe.method === "string") sanitizedRecipe.method = recipe.method;
109
95
  if (recipe.headers && typeof recipe.headers === "object") sanitizedRecipe.headers = recipe.headers;
@@ -136,10 +122,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
136
122
  if (v !== null) sanitizedRecipe.maxOutputTokens = v;
137
123
  }
138
124
  } else if (kind === "system-stats") {
139
- // Built-in tier: host RAM/CPU via the in-process structured reader. NOT gated
140
- // by httpEnabled/llmEnabled — it touches no network, no shell, no
141
- // model. Only the master `enabled` switch (checked above) governs it. Do NOT
142
- // add a cfg.*Enabled gate here (intentional — Phase 3 design).
125
+
143
126
  if (recipe.sampleWindowMs !== undefined) {
144
127
  const v = bounded(recipe.sampleWindowMs, GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMin, GLASSES_UI_REFRESH_LIMITS.systemStatsWindowMsMax);
145
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}]` };
@@ -147,7 +130,6 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
147
130
  }
148
131
  }
149
132
 
150
- // Interval bounds.
151
133
  const intervalMs = refresh.intervalMs;
152
134
  if (!Number.isFinite(intervalMs)) {
153
135
  return { ok: false, code: "refresh_invalid_recipe", message: "refresh.intervalMs is required" };
@@ -168,7 +150,6 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
168
150
  return { ok: false, code: "refresh_interval_too_high", message: `intervalMs ${intervalMs} above max ${GLASSES_UI_REFRESH_LIMITS.intervalMsMax}` };
169
151
  }
170
152
 
171
- // Duration.
172
153
  const maxDurationMs = Number.isFinite(refresh.maxDurationMs)
173
154
  ? refresh.maxDurationMs
174
155
  : GLASSES_UI_REFRESH_LIMITS.maxDurationMsDefault;
@@ -176,13 +157,11 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
176
157
  return { ok: false, code: "refresh_duration_too_high", message: `maxDurationMs ${maxDurationMs} out of bounds` };
177
158
  }
178
159
 
179
- // onError.
180
160
  const onError = typeof refresh.onError === "string" ? refresh.onError : "keep_last";
181
161
  if (!ON_ERROR_VALUES.has(onError)) {
182
162
  return { ok: false, code: "refresh_invalid_recipe", message: `onError must be keep_last/show_error/stop` };
183
163
  }
184
164
 
185
- // Templates.
186
165
  const targets = refresh.targets && typeof refresh.targets === "object" ? refresh.targets : {};
187
166
  if (typeof targets.body === "string") {
188
167
  if (targets.body.length > GLASSES_UI_REFRESH_LIMITS.templateMaxChars) {
@@ -192,10 +171,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
192
171
  if (!v.ok) return v;
193
172
  }
194
173
  if (Array.isArray(targets.items)) {
195
- // Cap array length — only the first maxItems survive the runtime slice,
196
- // so a 100k-entry array would burn CPU substituting templates that are
197
- // immediately discarded. Reject (rather than truncate) so the agent gets
198
- // clear feedback that it over-supplied.
174
+
199
175
  if (targets.items.length > GLASSES_UI_LIMITS.maxItems) {
200
176
  return {
201
177
  ok: false,
@@ -212,7 +188,7 @@ export function validateRefreshSpec(refresh, glassesUiLiveCfg) {
212
188
  const v = validateTemplate(item);
213
189
  if (!v.ok) return v;
214
190
  } else if (item && typeof item === "object" && !Array.isArray(item)) {
215
- // {label, body?} per-item templates (list_with_details detail bodies).
191
+
216
192
  if (typeof item.label !== "string") {
217
193
  return { ok: false, code: "refresh_template_invalid", message: `targets.items[${i}].label must be a string template` };
218
194
  }
@@ -259,10 +235,6 @@ const updateSchemaForToolParams = {
259
235
  "\"push\": stack a new screen; the parent is retained and its cron pauses.",
260
236
  };
261
237
 
262
- // Roadmap 6b (§2.6 interaction-window contract). The gateway's dynamic-tool
263
- // watchdog reads call.arguments.timeoutMs (clamp ceiling 600000ms); this
264
- // schema entry legitimizes that knob and steers its use. One-shot by design:
265
- // the listen window is never renewed — the agent re-renders to listen again.
266
238
  const timeoutMsSchemaForToolParams = {
267
239
  type: "integer",
268
240
  minimum: 1000,
@@ -273,7 +245,6 @@ const timeoutMsSchemaForToolParams = {
273
245
  "omit for fire-and-forget. Never renewed automatically — re-render to listen again.",
274
246
  };
275
247
 
276
- // staleAfterMs reservation (roadmap 6b; annotate-only through the soak).
277
248
  const staleAfterMsSchemaForToolParams = {
278
249
  type: "integer",
279
250
  minimum: 1000,
@@ -291,10 +262,6 @@ export const GLASSES_UI_WINDOW_LIMITS = {
291
262
  staleAfterMsMax: staleAfterMsSchemaForToolParams.maximum,
292
263
  };
293
264
 
294
- // Cross-kind window fields (like `update`/`refresh`, these live at the tool
295
- // layer — the kind descriptors rebuild a whitelisted canonical spec, which is
296
- // also what keeps both fields OFF the wire). Reject (never clamp) so the agent
297
- // gets explicit feedback instead of a silently different window.
298
265
  function validateWindowFields(spec) {
299
266
  const out = { ok: true, timeoutMs: undefined, staleAfterMs: undefined };
300
267
  if (spec && spec.timeoutMs !== undefined) {
@@ -399,21 +366,13 @@ const refreshSchemaForToolParams = {
399
366
  },
400
367
  };
401
368
 
402
- // Top-level `properties` lists every field a valid spec may carry across all
403
- // `kind`s. OpenClaw's Anthropic provider strips `oneOf` when building
404
- // `input_schema` (it keeps only top-level `properties` + `required`), so
405
- // without this flat union the model would see `properties: {}` and have to
406
- // guess the shape from the tool description alone. Per-kind shape constraints
407
- // are still enforced by `validateGlassesUiSpec` and the JSON Schema `oneOf`
408
- // below for clients that honor it.
409
369
  export const glassesUiParametersSchema = {
410
370
  type: "object",
411
371
  required: ["kind"],
412
372
  properties: {
413
373
  kind: {
414
374
  type: "string",
415
- // Enum derived from the descriptor registry (enum order). Adding a kind
416
- // is one descriptor with no edit here (spec §Modularity).
375
+
417
376
  enum: listKindStrings(),
418
377
  description:
419
378
  "Surface kind. Each kind expects a different items/body shape — see " +
@@ -442,26 +401,15 @@ export const glassesUiParametersSchema = {
442
401
  "[{\"label\": \"Monday\", \"body\": \"Cloudy 14C, light rain pm\"}, " +
443
402
  "{\"label\": \"Tuesday\", \"body\": \"Sunny 19C\"}]. Up to 20 items.",
444
403
  },
445
- // refresh must be top-level too — the Anthropic provider strips `oneOf`
446
- // (see the block comment above), so a refresh entry that lives only in
447
- // the oneOf branches is invisible on that path and the live-refresh
448
- // feature becomes unreachable. The per-branch copies below stay for
449
- // clients that honor oneOf.
404
+
450
405
  refresh: refreshSchemaForToolParams,
451
- // update is the render-vs-current-surface move (patch/replace/push,
452
- // default replace). Top-level for the same Anthropic-strips-oneOf reason as
453
- // refresh; mirrored into every oneOf branch below.
406
+
454
407
  update: updateSchemaForToolParams,
455
- // Window fields (roadmap 6b) — same top-level + per-branch mirroring.
408
+
456
409
  timeoutMs: timeoutMsSchemaForToolParams,
457
410
  staleAfterMs: staleAfterMsSchemaForToolParams,
458
411
  },
459
- // oneOf is assembled from the descriptor registry (one branch per kind, in
460
- // enum order). Each branch's `refresh` slot — declared `undefined` in the
461
- // descriptor's schemaBranch — is filled here with the shared refresh schema
462
- // so the per-branch shape matches today's hand-written one (the tool owns
463
- // refreshSchemaForToolParams; the descriptor only declares the slot). `update`
464
- // is mirrored into every branch the same way.
412
+
465
413
  oneOf: buildOneOfBranches().map((branch) => ({
466
414
  ...branch,
467
415
  properties: {
@@ -479,10 +427,7 @@ export function validateGlassesUiSpec(input) {
479
427
  return { ok: false, code: "invalid_kind", message: "spec must be an object" };
480
428
  }
481
429
  const obj = input;
482
- // Dispatch by kind STRING through the descriptor registry. "No descriptor for
483
- // kind" reproduces today's invalid_kind. Per-kind validation (incl. the
484
- // shared title check, which each descriptor runs first) lives in the
485
- // descriptor's validateSpec, so behavior is identical to the old switch.
430
+
486
431
  const descriptor = getKindDescriptor(obj.kind);
487
432
  if (!descriptor) {
488
433
  return {
@@ -498,9 +443,6 @@ export function validateGlassesUiSpec(input) {
498
443
 
499
444
  import { randomUUID } from "node:crypto";
500
445
 
501
- // Mirror of even-ai-model-hook's session classifier. Inlined to avoid a
502
- // cross-file CJS dependency (even-ai-model-hook is ESM-only in dist).
503
- // Keep these constants in sync with even-ai-model-hook.ts.
504
446
  const EVEN_AI_THROWAWAY_SESSION_PREFIX = "ocuclaw:even-ai:";
505
447
  const EVEN_AI_DEFAULT_DEDICATED_SESSION_KEY = "ocuclaw:even-ai";
506
448
 
@@ -522,80 +464,40 @@ export function isEvenAiAgentSession(sessionKey, dedicatedSessionKey) {
522
464
  return !!normalizedDedicated && normalized === normalizedDedicated;
523
465
  }
524
466
 
525
- // Default timeout when no per-call or per-handler timeout is provided.
526
- // Bounds the orphan-tool_use corruption window (see Family 2 in the OpenClaw
527
- // task-runs zombies / orphan tool_use memory): if a render_glasses_ui call
528
- // stays unresolved this long, the plugin returns { result: "timeout" } so
529
- // the agent's runtime persists a matching tool_result and the next turn's
530
- // session replay won't 400 on an unmatched tool_use block.
531
467
  export const DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS = 30 * 60 * 1000;
532
468
 
533
- // The gateway's dynamic-tool watchdog default (resolveDynamicToolCallTimeoutMs
534
- // in the installed 2026.6.1 dist; agent-suppliable via the 6b timeoutMs field,
535
- // clamped there to <=600000). UNDOCUMENTED upstream — re-audit on any host
536
- // upgrade (§5c upstream ask (v) tracks getting it documented).
537
469
  export const GATEWAY_DYNAMIC_TOOL_DEFAULT_TIMEOUT_MS = 90_000;
538
470
 
539
- // Self-teaching wrap-up payload (roadmap 6c): window_expired is a listen
540
- // timeout, never an error and never a paint event — the surface stays live.
541
471
  const WINDOW_EXPIRED_HINT =
542
472
  "The listen window closed; the surface is still live on glass and keeps " +
543
473
  "updating. New taps park - re-render this surface (e.g. update:\"patch\") " +
544
474
  "to collect them in this run, or end your turn and they ride the next one.";
545
475
 
546
476
  export function createGlassesUiToolHandler(deps) {
547
- // The single live surface store is constructed below (after the cron engine,
548
- // which it delegates pause/resume/stop to). There is never a separate pending
549
- // map alongside it (spec §Core model — one store).
550
- // Short-lived per-surface capture used to read the cron's merged outcome
551
- // (ticks{}, lastBody, lastItems, ...extra) when runDynamicUi stops the
552
- // cron after a user dismissal (pending already resolved with a user-only
553
- // outcome that lacks ticks). Cleared immediately after stop returns.
477
+
554
478
  const capturedCronOutcome = new Map();
555
479
 
556
- // Breadcrumb title clip (roadmap 7a). The DynamicUiScreen title band is
557
- // BODY_W = MARKER_X = 548px on the client and the client does NOT clip
558
- // DynamicUiScreen titles — so the plugin must pre-clip the composed
559
- // breadcrumb or it overflows the band. Conservative pixel-safe char clip
560
- // (NOT font_measure: the plugin runtime has no require of
561
- // vendor/pretext-patched/dist/font_measure.js, and a cross-package require
562
- // from the installed plugin is fragile). Assume a worst-case 20px per char
563
- // (the probed uniform full-cell ceiling) so floor(budget/20) chars never
564
- // overflow — it over-clips slightly, never under. 540 = 548 minus an 8px
565
- // safety margin.
566
480
  const TITLE_BUDGET_PX = 540;
567
- // clipBreadcrumb keeps the rightmost (current) segment and drops
568
- // oldest-ancestor-first until the rejoined "A › B › C" fits the char budget;
569
- // if a single segment still overflows, it hard-truncates that last segment.
570
- // reserveText is kept defaulted-empty for future suffix callers (7a has
571
- // none — the (stale) suffix was dropped per Path D).
481
+
572
482
  function clipBreadcrumb(s, reserveText = "") {
573
483
  if (typeof s !== "string" || s.length === 0) return s;
574
484
  const charBudget = Math.floor((TITLE_BUDGET_PX - reserveText.length * 20) / 20);
575
485
  if (charBudget <= 0) return "";
576
486
  if (s.length <= charBudget) return s;
577
487
  const segments = s.split(" › ");
578
- // Drop leading (oldest-ancestor) segments until the rejoin fits, always
579
- // keeping at least the rightmost segment.
488
+
580
489
  while (segments.length > 1 && segments.join(" › ").length > charBudget) {
581
490
  segments.shift();
582
491
  }
583
492
  const joined = segments.join(" › ");
584
493
  if (joined.length <= charBudget) return joined;
585
- // One segment remains and still overflows → hard-truncate it to the budget.
494
+
586
495
  return joined.slice(0, charBudget);
587
496
  }
588
497
 
589
- // Marker-only emit on the content-less presence transitions (roadmap 7a). A
590
- // live resolve, a parked tap, a window_expired wrap-up, and a popBack resume
591
- // change the surface's derived presence marker without changing its content —
592
- // so ship a marker-only surface_update (no title/body/items) through the same
593
- // paint-floor chokepoint. Gated to the session's TOP surface so a transition
594
- // on a backgrounded surface stays quiet; an off-enum/absent marker is a no-op.
595
- // (surfaceStore/paintFloor are defined further down but only read at call time.)
596
498
  function emitMarker(sessionKey, surfaceId) {
597
499
  if (!surfaceId) return;
598
- if (surfaceStore.topSurfaceId(sessionKey) !== surfaceId) return; // only the active surface
500
+ if (surfaceStore.topSurfaceId(sessionKey) !== surfaceId) return;
599
501
  const marker = surfaceStore.markerFor(surfaceId);
600
502
  if (!marker) return;
601
503
  paintFloor.enqueue({ surfaceId, sessionKey, patch: { marker } });
@@ -606,14 +508,6 @@ export function createGlassesUiToolHandler(deps) {
606
508
  ? deps.newSurfaceId
607
509
  : () => `ui-${randomUUID().slice(0, 8)}`;
608
510
 
609
- // Permanent glasses.lifecycle observability (nav reconcile + cron pause/
610
- // resume/tick). No-op when the dep is absent (tests) or the debug category is
611
- // disabled. See docs/superpowers/findings/2026-05-30-glasses-ui-phase4-hardware.md.
612
- // Every event is stamped with the owning store's id: OpenClaw loads the
613
- // plugin register() in multiple isolated contexts, each with its own handler
614
- // + store, and only the storeId makes a cross-context divergence visible in
615
- // traces (drift #3, 2026-06-12: a queued wake run's collect rendered against
616
- // a sibling context's empty store and minted a phantom root).
617
511
  const storeId =
618
512
  typeof deps.storeId === "string" && deps.storeId
619
513
  ? deps.storeId
@@ -623,8 +517,6 @@ export function createGlassesUiToolHandler(deps) {
623
517
  const emitLifecycle = (event, severity, data) =>
624
518
  baseEmitLifecycle(event, severity, { storeId, ...(data || {}) });
625
519
 
626
- // Resolve the handler-wide default timeout. Per-call timeouts may still
627
- // override this via params.timeoutMs.
628
520
  function resolveHandlerTimeoutMs() {
629
521
  if (!deps || deps.timeoutMs === undefined) return DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
630
522
  if (typeof deps.timeoutMs === "function") {
@@ -634,15 +526,8 @@ export function createGlassesUiToolHandler(deps) {
634
526
  return Number.isFinite(deps.timeoutMs) ? deps.timeoutMs : DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS;
635
527
  }
636
528
 
637
- // The single plugin->glass send chokepoint (Spike D). EVERY send — the
638
- // initial render frame and every cron surface_update — routes through this
639
- // trailing-edge coalescer so bursts collapse to ≤1 frame per 150ms and shed
640
- // under BLE backpressure. A { __render } sentinel is a full container
641
- // rebuild; a plain field patch is a surface_update.
642
529
  const paintFloor = createPaintFloorCoalescer({
643
- // Tests may inject paintFloorMs: 0 to disable coalescing (every enqueue is
644
- // a leading-edge send) so synchronous send-ordering assertions hold;
645
- // production uses the 150ms Spike-D cadence.
530
+
646
531
  paintFloorMs: Number.isFinite(deps.paintFloorMs) ? deps.paintFloorMs : DEFAULT_PAINT_FLOOR_MS,
647
532
  send: ({ surfaceId, sessionKey, patch }) => {
648
533
  if (patch && patch.__render) {
@@ -658,7 +543,11 @@ export function createGlassesUiToolHandler(deps) {
658
543
  emitLifecycle,
659
544
  monotonicNowMs: () => performance.now(),
660
545
  executeRecipe: async (recipe, ctx) => {
661
- 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
+ }
662
551
  if (recipe.kind === "system-stats") return executeSystemStatsRecipe(recipe);
663
552
  if (recipe.kind === "llm") return executeLlmRecipe(recipe, ctx);
664
553
  return { error: `unknown recipe kind: ${recipe.kind}` };
@@ -686,22 +575,14 @@ export function createGlassesUiToolHandler(deps) {
686
575
  },
687
576
  });
688
577
 
689
- // The SINGLE live surface store (spec §Core model). Constructed after the
690
- // cron engine so it can delegate pause/resume/stop; id minting reuses the
691
- // handler's minter. Replaces the Phase-1 pending map — never a second store.
692
578
  const surfaceStore = createSurfaceStore({
693
579
  storeId,
694
580
  emitLifecycle,
695
- // Injectable clock for parked-event age math (queuedAtMs/parkedForMs/stale).
581
+
696
582
  now: typeof deps.now === "function" ? deps.now : undefined,
697
583
  pauseCron: (id) => cronEngine.pause(id),
698
584
  resumeCron: (id) => cronEngine.resume(id),
699
- // stopCron fires on every surface teardown (replace swap, popBack child,
700
- // exit, drain). Dispose the paint-floor coalescer entry too so an armed
701
- // trailing flush can't paint a stale surface_update onto the now-visible
702
- // parent/chat after a back/pop, and the per-surface coalescer state doesn't
703
- // leak across a long push/replace session. (pauseCron on push does NOT
704
- // dispose — the parent resumes.)
585
+
705
586
  stopCron: (id, opts) => {
706
587
  cronEngine.stop(id, { result: "preempted" }, opts);
707
588
  paintFloor.dispose(id);
@@ -709,12 +590,6 @@ export function createGlassesUiToolHandler(deps) {
709
590
  mintSurfaceId: newSurfaceId,
710
591
  });
711
592
 
712
- // Tap-to-wake (roadmap 6f): a parked GESTURE nonterminal buys one agent
713
- // turn via the relay's gateway client. The controller owns the arbitration
714
- // (origin gate, voice-absorbs-wake, in-flight coalescing, cooldown, retry/
715
- // outbox); the lane and busy signal are injected from the relay facade by
716
- // registerGlassesUiTool. Without a lane (legacy host) the controller
717
- // no-ops and parked taps keep their collect-on-next-render semantics.
718
593
  const wakeController = createGlassesWakeController({
719
594
  dispatchWake: typeof deps.dispatchWake === "function" ? deps.dispatchWake : null,
720
595
  isAgentTurnBusy: typeof deps.isAgentTurnBusy === "function" ? deps.isAgentTurnBusy : () => false,
@@ -723,12 +598,6 @@ export function createGlassesUiToolHandler(deps) {
723
598
  wakeCooldownMs: deps.wakeCooldownMs,
724
599
  });
725
600
 
726
- // Voicemail (roadmap 7b): parked events whose wake was unavailable/failed
727
- // (wake outbox) or whose surface was destructively reaped (dead-letter)
728
- // are delivered to the session's NEXT genuine turn as a refs-only
729
- // system-context fragment. Lives on the handler so the shared-handler
730
- // hoist gives every load context's before_prompt_build hook the same
731
- // pending state (first hook to fire drains; duplicates see nothing).
732
601
  const voicemail = createGlassesVoicemail({
733
602
  now: typeof deps.now === "function" ? deps.now : Date.now,
734
603
  ttlMs: deps.voicemailTtlMs,
@@ -739,9 +608,7 @@ export function createGlassesUiToolHandler(deps) {
739
608
 
740
609
  deps.relay.onGlassesUiResult((msg) => {
741
610
  if (!msg || typeof msg.surfaceId !== "string" || !msg.outcome) return;
742
- // Provenance stamp at the client boundary (roadmap 6b): everything arriving
743
- // on this channel is a wearer gesture. The actor slot is present-and-
744
- // ignorable today (policy stays single-wearer at launch).
611
+
745
612
  const outcome = {
746
613
  ...msg.outcome,
747
614
  origin: typeof msg.outcome.origin === "string" ? msg.outcome.origin : "gesture",
@@ -749,9 +616,7 @@ export function createGlassesUiToolHandler(deps) {
749
616
  };
750
617
  const terminal = isTerminalOutcome(outcome);
751
618
  if (terminal && cronEngine.isActive(msg.surfaceId)) {
752
- // Terminal with a live cron: stop the cron, merging its tick stats into
753
- // the outcome via capturedCronOutcome, then settle the in-flight call (or
754
- // queue the terminal so the next render discards-for-exit).
619
+
755
620
  let merged = outcome;
756
621
  capturedCronOutcome.set(msg.surfaceId, (cronOutcome) => { merged = cronOutcome; });
757
622
  cronEngine.stop(msg.surfaceId, outcome);
@@ -761,25 +626,17 @@ export function createGlassesUiToolHandler(deps) {
761
626
  }
762
627
  return;
763
628
  }
764
- // Nonterminal (selected/back): settle the call if one is pending; otherwise
765
- // the surface is in visible_awaiting_agent — queue last-wins so the agent's
766
- // next render delivers the latest event (spec §Tool-call accounting). A
767
- // terminal with no live cron also lands here (queue → next render tears down).
768
- // Capture the session up front: a live resolve moves the surface to
769
- // visible_awaiting_agent (●→◌); a parked tap queues (◌). Both are
770
- // content-less marker transitions on the active surface (roadmap 7a).
629
+
771
630
  const sessionKey = surfaceStore.sessionForSurface(msg.surfaceId);
772
631
  if (surfaceStore.resolve(msg.surfaceId, outcome)) {
773
- // LIVE tap on the open window → the agent is now responding → ●→◌.
632
+
774
633
  emitMarker(sessionKey, msg.surfaceId);
775
634
  } else {
776
635
  const receipt = surfaceStore.queueEvent(msg.surfaceId, outcome, {
777
636
  origin: outcome.origin,
778
637
  actor: outcome.actor,
779
638
  });
780
- // Wake only on a REAL parked gesture: a truthy NONTERMINAL receipt
781
- // (terminal latches carry no actionable intent — the next render tears
782
- // down anyway; a latched-exit drop returns falsy and must never wake).
639
+
783
640
  if (receipt && !receipt.kind) {
784
641
  wakeController.onParkedGesture({
785
642
  sessionKey: surfaceStore.sessionForSurface(msg.surfaceId),
@@ -790,7 +647,7 @@ export function createGlassesUiToolHandler(deps) {
790
647
  origin: outcome.origin,
791
648
  });
792
649
  }
793
- // PARK → ◌: the queued tap (events.length≥1) derives inflight.
650
+
794
651
  emitMarker(sessionKey, msg.surfaceId);
795
652
  }
796
653
  });
@@ -807,9 +664,7 @@ export function createGlassesUiToolHandler(deps) {
807
664
  err.code = validation.code;
808
665
  throw err;
809
666
  }
810
- // Normalize at the handler boundary so the canonical agent ctx form and
811
- // the stripped relay form address the same session everywhere downstream
812
- // (store, nav-depth map, cron sessionKey, wake refs, lifecycle events).
667
+
813
668
  const sessionKey = normalizeGlassesSessionKey(
814
669
  typeof params.sessionKey === "string" && params.sessionKey.trim()
815
670
  ? params.sessionKey.trim()
@@ -835,9 +690,6 @@ export function createGlassesUiToolHandler(deps) {
835
690
  refreshValidated = v.refresh;
836
691
  }
837
692
 
838
- // Cross-kind window fields (roadmap 6b). Validated here — the kind
839
- // descriptors rebuild a whitelisted canonical spec, so neither field can
840
- // reach the wire; staleAfterMs is handed to the store per render.
841
693
  const windowFields = validateWindowFields(params.spec);
842
694
  if (!windowFields.ok) {
843
695
  emitLifecycle("render_rejected", "warn", {
@@ -850,24 +702,12 @@ export function createGlassesUiToolHandler(deps) {
850
702
  throw err;
851
703
  }
852
704
 
853
- // params.depth is the execute-level RUN-CALL ordinal (resets at agent_end).
854
- // It is used ONLY as the "first render of this run" signal for stale-stack
855
- // reaping below — it must NEVER reach the wire. The wire depth is derived
856
- // from the store's true stack depth after applyRender (B6: ordinals never
857
- // decrement on Back, so they drift past entry counts and break both the
858
- // plugin pop reconciliation and the client's clear-vs-append decision).
859
705
  const depth = Number.isFinite(params.depth) ? Math.max(1, Math.floor(params.depth)) : 1;
860
706
  const update =
861
707
  params.spec && (params.spec.update === "patch" || params.spec.update === "push")
862
708
  ? params.spec.update
863
709
  : "replace";
864
- // Stale-stack reaping (B3 safety net): a depth-1 render means NEW ROOT — a
865
- // session stack still holding PUSHED children at that moment is orphan
866
- // residue from an earlier run (e.g. a client that bailed to chat without
867
- // popping). Reap it before registering so a stale child can't swallow this
868
- // render's events or forward a stale latched exit. A SINGLE root entry is
869
- // NOT stale — that's the designed patch/replace re-attach path
870
- // (visible_awaiting_agent), which must keep its latch/queue semantics.
710
+
871
711
  if (depth <= 1 && surfaceStore.stackDepth(sessionKey) > 1) {
872
712
  const stackDepthBefore = surfaceStore.stackDepth(sessionKey);
873
713
  const reapedPending = reapSession(sessionKey, { result: "preempted" });
@@ -877,19 +717,14 @@ export function createGlassesUiToolHandler(deps) {
877
717
  reapedPending,
878
718
  });
879
719
  }
880
- // The plugin owns surfaceIds (spec §Core model). applyRender derives the
881
- // target from the session's current top: patch/replace reuse the top id
882
- // (re-attach in place), push mints a child + pauses the parent cron, the
883
- // first render mints a root. This is the single place a surfaceId is bound.
720
+
884
721
  const stackDepthBeforeAttach = surfaceStore.stackDepth(sessionKey);
885
722
  const applied = surfaceStore.applyRender(sessionKey, {
886
723
  update,
887
724
  kind: validation.spec.kind,
888
725
  });
889
726
  const surfaceId = applied.surfaceId;
890
- // Attach-path observability: requestedUpdate "patch"/"replace" with mode
891
- // "root" means a collect render did NOT find the surface it references —
892
- // the drift-#3 phantom-root signature. Permanent tripwire.
727
+
893
728
  emitLifecycle("surface_attach", "debug", {
894
729
  surfaceId,
895
730
  sessionKey,
@@ -902,68 +737,34 @@ export function createGlassesUiToolHandler(deps) {
902
737
  staleAfterMs: windowFields.staleAfterMs,
903
738
  title: typeof validation.spec.title === "string" ? validation.spec.title : undefined,
904
739
  });
905
- // Re-attach flush (last-wins queue / latched exit): only for a patch/replace
906
- // onto an already-attached surface (the visible_awaiting_agent window can
907
- // only exist on a surface that previously resolved a call). A fresh
908
- // root/push has an empty queue and stays visible_pending. onReattached
909
- // delivers any queued nonterminal against the call we just established, or —
910
- // if an exit was latched — returns "discarded_for_exit" so this render is
911
- // dropped and the surface tears down (spec §Tool-call accounting). This is
912
- // move-independent: replace (the schema default) carries the prior entry's
913
- // latched exit / queued event forward (Task 8 makeEntry).
740
+
914
741
  if (applied.mode === "patch" || applied.mode === "replace") {
915
742
  const reattach = surfaceStore.onReattached(surfaceId);
916
743
  if (reattach === "discarded_for_exit") {
917
- // onReattached already resolved THIS render's pending call with the
918
- // latched terminal outcome (so `promise` is settled). Tear down instead
919
- // of painting the discarded render.
744
+
920
745
  if (cronEngine.isActive(surfaceId)) cronEngine.stop(surfaceId, { result: "dismissed" });
921
746
  surfaceStore.exit(sessionKey);
922
747
  return promise;
923
748
  }
924
749
  if (reattach === "reattached_stale_latch_dropped") {
925
- // 6d generation gate: a system-origin terminal (stale cron tick-
926
- // summary) queued under a prior call was dropped instead of consuming
927
- // this fresh call's window. Observable for ride forensics.
750
+
928
751
  emitLifecycle("stale_cron_summary_dropped", "debug", { surfaceId, sessionKey });
929
752
  }
930
753
  }
931
754
 
932
- // The wire depth is the TRUE stack depth (entry count) after applyRender:
933
- // root=1, push=parent+1, replace/patch=unchanged. The client keys its
934
- // clear-vs-append-vs-swap decision and Back classification on this value,
935
- // and handleNavEvent's pop loop compares it against the same entry counts.
936
755
  const wireDepth = Math.max(1, surfaceStore.stackDepth(sessionKey));
937
756
 
938
- // Initial render uses the agent's seed (instant). Routed through the
939
- // paint-floor coalescer as a leading-edge render sentinel so it shares the
940
- // single send chokepoint (a render supersedes any queued field patch for
941
- // this surface; see glasses-ui-paint-floor mergePatch).
942
- // Compose the depth breadcrumb into the spec title BEFORE the enqueue
943
- // (roadmap 7a). The store retains each surface's own title at register, so
944
- // breadcrumbFor joins the live stack's titles ("System stats › CPU"); the
945
- // conservative pixel-safe clip keeps it inside the client's title band.
946
757
  const breadcrumb = surfaceStore.breadcrumbFor(sessionKey);
947
758
  if (breadcrumb) validation.spec.title = clipBreadcrumb(breadcrumb);
948
759
  paintFloor.enqueue({
949
760
  surfaceId,
950
761
  sessionKey,
951
- // __marker rides the render frame: "listening" for a plain render with an
952
- // open window, "inflight" for a collect that just delivered a parked tap
953
- // via onReattached (the enqueue runs AFTER onReattached, so markerFor
954
- // reads true post-collect state).
762
+
955
763
  patch: { __render: true, __depth: wireDepth, __spec: validation.spec, __marker: surfaceStore.markerFor(surfaceId) },
956
764
  });
957
765
 
958
- // Live-refresh path: kick off the cron in parallel. A `patch` onto an
959
- // already-ticking surface leaves its cron alone (spec: "cron keeps
960
- // ticking"); every other move (replace/push/root) starts a cron for the new
961
- // content when this render carries a refresh.
962
766
  if (refreshValidated && !(update === "patch" && cronEngine.isActive(surfaceId))) {
963
- // Pre-warm the LLM API key cache so tick 1 doesn't see an empty key
964
- // and fail the smoke test. For non-LLM recipes this is a no-op. All llm
965
- // backends are HTTP API backends that resolve a key via host modelAuth;
966
- // a missing key degrades to a graceful recipe_failed on tick 1.
767
+
967
768
  if (refreshValidated.recipe.kind === "llm" && typeof deps.prewarmLlmApiKey === "function") {
968
769
  const cfg = deps.getGlassesUiLiveConfig ? deps.getGlassesUiLiveConfig() : {};
969
770
  const agentModel =
@@ -977,8 +778,7 @@ export function createGlassesUiToolHandler(deps) {
977
778
  try {
978
779
  await deps.prewarmLlmApiKey(prewarmModel);
979
780
  } catch (_) {
980
- // Cache stays empty; tick 1 will fail and the cron resolves
981
- // recipe_failed with a useful error from the backend.
781
+
982
782
  }
983
783
  }
984
784
  }
@@ -997,29 +797,16 @@ export function createGlassesUiToolHandler(deps) {
997
797
  )
998
798
  : undefined,
999
799
  onResolve: (cronOutcome) => {
1000
- // The cron-produced outcome (with ticks + lastBody) becomes the tool
1001
- // result. capturedCronOutcome lets the terminal-via-user path read
1002
- // the merged outcome. Only the cron's OWN terminal outcomes resolve
1003
- // the pending call here (recipe_failed / timeout / external stop);
1004
- // user-action results resolve via onGlassesUiResult above.
800
+
1005
801
  const capture = capturedCronOutcome.get(surfaceId);
1006
802
  if (capture) capture(cronOutcome);
1007
803
  if (isTerminalOutcome(cronOutcome)) {
1008
- // The cron's OWN terminal is plugin-initiated, not a wearer
1009
- // gesture — origin "system" (roadmap 6b). A user-action terminal
1010
- // routed through cron stop carries its gesture stamp in `extra`
1011
- // and wins the merge, so this fallback never overwrites it.
804
+
1012
805
  const stamped = {
1013
806
  ...cronOutcome,
1014
807
  origin: typeof cronOutcome.origin === "string" ? cronOutcome.origin : "system",
1015
808
  };
1016
- // Settle the in-flight call; if none is pending (the surface is in
1017
- // visible_awaiting_agent after a nonterminal user action and the
1018
- // cron then hit its own terminal — recipe_failed / maxDuration
1019
- // timeout), QUEUE the terminal so the agent's next render's
1020
- // onReattached returns discarded_for_exit and tears the surface
1021
- // down. Without this fallback the dead-cron surface would persist.
1022
- // Symmetric with the onGlassesUiResult terminal path.
809
+
1023
810
  if (!surfaceStore.resolve(surfaceId, stamped)) {
1024
811
  surfaceStore.queueEvent(surfaceId, stamped);
1025
812
  }
@@ -1034,21 +821,11 @@ export function createGlassesUiToolHandler(deps) {
1034
821
  deps && typeof deps.clearTimeout === "function" ? deps.clearTimeout : clearTimeout;
1035
822
  const cleanups = [];
1036
823
 
1037
- // 6c wrap-up: a NON-terminal pre-deadline resolve beats the gateway
1038
- // watchdog (default 90s; agent-suppliable via the 6b timeoutMs schema
1039
- // field) so the agent gets a useful, self-teaching result instead of a
1040
- // bare timeout string — and the pending resolver is FREED, so subsequent
1041
- // taps PARK in the 6a event log. Armed on EVERY render, including cron/
1042
- // refresh surfaces: pre-6c the only timer here was the terminal janitor
1043
- // below, gated !refreshValidated, so cron surfaces had no plugin-side
1044
- // deadline at all and their calls died only at the watchdog cliff.
1045
824
  const effectiveWindowMs =
1046
825
  windowFields.timeoutMs !== undefined
1047
826
  ? windowFields.timeoutMs
1048
827
  : GATEWAY_DYNAMIC_TOOL_DEFAULT_TIMEOUT_MS;
1049
- // Fire 2-5s (5% clamped) before the watchdog; for tiny windows where the
1050
- // margin doesn't fit, fall back to half the window — the wrap-up must
1051
- // ALWAYS beat the gateway or it delivers into an abandoned call.
828
+
1052
829
  const wrapUpMarginMs = Math.min(5000, Math.max(2000, Math.floor(effectiveWindowMs * 0.05)));
1053
830
  const wrapUpDelayMs = Math.max(effectiveWindowMs - wrapUpMarginMs, Math.floor(effectiveWindowMs / 2));
1054
831
  const windowExpiredOutcome = (extra) =>
@@ -1070,18 +847,12 @@ export function createGlassesUiToolHandler(deps) {
1070
847
  windowMs: effectiveWindowMs,
1071
848
  via: "wrap_up_timer",
1072
849
  });
1073
- // ●→○: the window closed with no wearer gesture (origin:"system"), so
1074
- // awaitingAgentResponse stays false and markerFor derives parked (7a).
850
+
1075
851
  emitMarker(sessionKey, surfaceId);
1076
852
  }
1077
853
  }, wrapUpDelayMs);
1078
854
  cleanups.push(() => clearTimeoutFn(wrapUpHandle));
1079
855
 
1080
- // 6c abort: the gateway delivers its watchdog/run-abort AbortSignal as
1081
- // execute()'s third argument — and ALSO aborts it after NORMAL completion,
1082
- // so releasing here must tolerate abort-after-resolve (surfaceStore.resolve
1083
- // no-ops once settled). Releasing the orphaned resolver is what turns the
1084
- // first post-cliff tap from resolved-into-the-void into a parked event.
1085
856
  const signal = params.signal;
1086
857
  if (signal && typeof signal.addEventListener === "function") {
1087
858
  const onAbort = () => {
@@ -1106,43 +877,26 @@ export function createGlassesUiToolHandler(deps) {
1106
877
  }
1107
878
  }
1108
879
 
1109
- // Terminal janitor (pre-6c behavior, unchanged semantics): bounds the
1110
- // orphan-tool_use corruption window on non-refresh surfaces via the
1111
- // 30-min default knob. Cron surfaces keep relying on maxDurationMs.
1112
880
  const timeoutMs = Number.isFinite(params.timeoutMs)
1113
881
  ? params.timeoutMs
1114
882
  : resolveHandlerTimeoutMs();
1115
883
  if (!refreshValidated && Number.isFinite(timeoutMs) && timeoutMs > 0) {
1116
884
  const handle = setTimeoutFn(() => {
1117
- // Resolves only if the entry is still pending; surfaceStore.resolve is a
1118
- // no-op when the client already produced a real outcome. timeout is
1119
- // terminal, so it also moves the surface to `exiting`.
885
+
1120
886
  surfaceStore.resolve(surfaceId, { result: "timeout", timeout_ms: timeoutMs, origin: "system" });
1121
887
  }, timeoutMs);
1122
888
  cleanups.push(() => clearTimeoutFn(handle));
1123
889
  }
1124
890
 
1125
- // Decoupled lifecycle: the call resolves on the user's action (via
1126
- // onGlassesUiResult), the cron's terminal outcome (via onResolve), the
1127
- // wrap-up timer, or the abort release. A nonterminal resolve (selected/
1128
- // back/window_expired) does NOT stop the cron — the surface persists
1129
- // until a terminal or a drain. Per-call timers/listeners die with the call.
1130
891
  return promise.then((outcome) => {
1131
892
  for (const fn of cleanups) {
1132
- try { fn(); } catch (_) { /* cleanup must never mask the outcome */ }
893
+ try { fn(); } catch (_) { }
1133
894
  }
1134
895
  return outcome;
1135
896
  });
1136
897
  }
1137
898
 
1138
- // Per-session last-seen depth, used only to distinguish push (depth up) from
1139
- // pop (depth down) on the client nav-event. The surfaceIds + pause/resume
1140
- // live in surfaceStore (the single source of truth) — there is NO second
1141
- // stack here. On push the parent cron was already paused by applyRender
1142
- // during the agent's push render, so push is idempotent here; on pop we drive
1143
- // surfaceStore.popBack, which stops the child cron and staleness-resumes the
1144
- // parent (Spike B: the plugin owns the resume target).
1145
- const navDepthBySession = new Map(); // sessionKey -> lastSeenDepth
899
+ const navDepthBySession = new Map();
1146
900
 
1147
901
  function handleNavEvent(rawSessionKey, ev) {
1148
902
  const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
@@ -1152,8 +906,7 @@ export function createGlassesUiToolHandler(deps) {
1152
906
  let popCount = 0;
1153
907
  let resumedParent = null;
1154
908
  if (newDepth < lastDepth) {
1155
- // Pop(s): the client popped locally. Reconcile the store to the reported
1156
- // depth — each popBack stops the child cron + resumes the new parent.
909
+
1157
910
  let guard = 0;
1158
911
  while (surfaceStore.stackDepth(sessionKey) > newDepth && guard < 64) {
1159
912
  resumedParent = surfaceStore.popBack(sessionKey);
@@ -1166,21 +919,11 @@ export function createGlassesUiToolHandler(deps) {
1166
919
  storeDepthBefore > 1 &&
1167
920
  surfaceStore.topSurfaceId(sessionKey) === ev.surfaceId
1168
921
  ) {
1169
- // Surface-match fallback (B6): a Back event reports the surfaceId being
1170
- // backed OUT OF — the store top. If the depth comparison said no-op
1171
- // (drifted ordinals from an older client, or any depth desync) but the
1172
- // reported surface IS the top with a parent beneath, pop exactly one
1173
- // level. Push events carry the PARENT surfaceId — never the top after a
1174
- // push — so this cannot misfire on a push report; and a duplicate Back
1175
- // delivery is idempotent (after the pop the top no longer matches).
922
+
1176
923
  resumedParent = surfaceStore.popBack(sessionKey);
1177
924
  popCount += 1;
1178
925
  }
1179
- // Push (newDepth > lastDepth) is already reflected in the store by the
1180
- // agent's push render (applyRender), so it is intentionally a no-op here.
1181
- // Resume re-asserts the restored parent's presence marker (7a): a pop
1182
- // brings the parent back as the top surface, so emit its current marker
1183
- // (emitMarker self-gates to the session top — resumedParent is now it).
926
+
1184
927
  if (popCount > 0 && resumedParent) {
1185
928
  emitMarker(sessionKey, resumedParent);
1186
929
  }
@@ -1197,18 +940,12 @@ export function createGlassesUiToolHandler(deps) {
1197
940
  navDepthBySession.set(sessionKey, newDepth);
1198
941
  }
1199
942
 
1200
- // Stop crons, resolve pending calls with `outcome`, clear the session stack.
1201
- // Resolve pending + delete entries FIRST (so pending calls settle with
1202
- // `outcome`), THEN clear the per-session stack. exit() deletes entries
1203
- // without resolving, so it must NOT run before the drain or the pending
1204
- // promises would hang. Shared by the public drainSession (agent_end /
1205
- // disconnect) and the stale-stack reap in runDynamicUi (B3).
1206
943
  function reapSession(rawSessionKey, outcome) {
1207
944
  const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
1208
945
  cronEngine.stopAllForSession(sessionKey, outcome);
1209
946
  const reaped = surfaceStore.drainSession(sessionKey, outcome);
1210
- surfaceStore.exit(sessionKey); // clear the stack (crons already stopped)
1211
- navDepthBySession.delete(sessionKey); // drop stale nav depth (stack is now 0)
947
+ surfaceStore.exit(sessionKey);
948
+ navDepthBySession.delete(sessionKey);
1212
949
  return reaped;
1213
950
  }
1214
951
 
@@ -1219,10 +956,7 @@ export function createGlassesUiToolHandler(deps) {
1219
956
  drainSession(sessionKey, outcome) {
1220
957
  return reapSession(sessionKey, outcome);
1221
958
  },
1222
- // Run-teardown safety net (agent_end): settle leaked pending calls but
1223
- // PRESERVE surfaces, parked events, crons and the stack — surfaces are
1224
- // designed to outlive runs (drift #3: the old unconditional reap here
1225
- // destroyed parked wearer intent whenever it fired in the owning context).
959
+
1226
960
  settleSession(sessionKey, outcome) {
1227
961
  return surfaceStore.settlePending(sessionKey, outcome);
1228
962
  },
@@ -1235,18 +969,14 @@ export function createGlassesUiToolHandler(deps) {
1235
969
  navDepthBySession.clear();
1236
970
  return reaped;
1237
971
  },
1238
- // Failed-wake outbox (roadmap 6f). The 7b voicemail (buildVoicemailInjection
1239
- // below) is the production consumer — an external drain steals owed
1240
- // voicemail, so these passthroughs are debug/ops surfaces only.
972
+
1241
973
  peekWakeOutbox() {
1242
974
  return wakeController.peekWakeOutbox();
1243
975
  },
1244
976
  drainWakeOutbox() {
1245
977
  return wakeController.drainWakeOutbox();
1246
978
  },
1247
- // Voicemail injection (roadmap 7b): refs-only fragment for the session's
1248
- // next genuine turn, or null when nothing is owed. Consumed by the
1249
- // before_prompt_build hook in registerGlassesUiTool.
979
+
1250
980
  buildVoicemailInjection(sessionKey) {
1251
981
  return voicemail.buildInjection(sessionKey);
1252
982
  },
@@ -1260,16 +990,11 @@ export function createGlassesUiToolHandler(deps) {
1260
990
  surfaceStackDepth(sessionKey) {
1261
991
  return surfaceStore.stackDepth(sessionKey);
1262
992
  },
1263
- // Run-teardown marker (7a): a silent agent_end (the agent never re-rendered
1264
- // after a wearer tap) must flip the surface inflight→parked. settleSession
1265
- // already cleared any leaked pending call; clearing awaitingResponse drops
1266
- // the last "agent is responding" fact so markerFor derives parked. Routed
1267
- // through the handler closure because surfaceStore/emitMarker are NOT in
1268
- // scope at the agent_end hook site (Codex finding).
993
+
1269
994
  parkMarkerOnAgentEnd(sessionKey) {
1270
- surfaceStore.clearAwaitingResponse(sessionKey); // inflight → parked if the agent ended silently
995
+ surfaceStore.clearAwaitingResponse(sessionKey);
1271
996
  const top = surfaceStore.topSurfaceId(sessionKey);
1272
- if (top) emitMarker(sessionKey, top); // emitMarker + surfaceStore are in scope HERE
997
+ if (top) emitMarker(sessionKey, top);
1273
998
  },
1274
999
  sessionForSurface(surfaceId) {
1275
1000
  return surfaceStore.sessionForSurface(surfaceId);
@@ -1318,13 +1043,6 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
1318
1043
  "\"selected\" result, follow up with another render or a brief one-line ack.",
1319
1044
  ].join("\n");
1320
1045
 
1321
- // Shared per-session depth counter. OpenClaw loads the plugin's register(api)
1322
- // in multiple isolated contexts (gateway startup, per-agent-run tool discovery)
1323
- // and each call to registerGlassesUiTool would otherwise close over its own
1324
- // Map. execute() runs in the per-run context's closure while api.on("agent_end",
1325
- // ...) fires from an earlier global-context closure, so reset-on-end would miss
1326
- // the live counter. Stashing the map on globalThis under a stable Symbol gives
1327
- // every load context the same Map to read and mutate.
1328
1046
  const DEPTH_MAP_SYMBOL = Symbol.for("ocuclaw.glasses-ui.depthBySession");
1329
1047
  function getSharedDepthMap() {
1330
1048
  let m = globalThis[DEPTH_MAP_SYMBOL];
@@ -1335,16 +1053,6 @@ function getSharedDepthMap() {
1335
1053
  return m;
1336
1054
  }
1337
1055
 
1338
- // Shared handler record, same multi-context reasoning as the depth map but for
1339
- // ALL of the tool's mutable state: surface store + dead-letter, cron engine,
1340
- // paint-floor coalescer and the 6f wake controller/outbox. The 2026-06-12
1341
- // live census (drift #3) proved tool execution and agent_end hooks land in
1342
- // DIFFERENT load contexts, and a QUEUED run's execution resolves to the
1343
- // non-owning context ~3/4 of the time — with per-context stores that minted a
1344
- // phantom root and stranded the parked tap. One shared handler makes every
1345
- // context's execute/hook/relay-callback address the same state, so which
1346
- // registry the gateway picks per run stops mattering. Tests inject an
1347
- // isolated `opts.scopeHost` ({}); production omits it and shares globalThis.
1348
1056
  const HANDLER_SCOPE_SYMBOL = Symbol.for("ocuclaw.glasses-ui.sharedHandler");
1349
1057
 
1350
1058
  export function registerGlassesUiTool(api, service, opts = {}) {
@@ -1389,18 +1097,11 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1389
1097
  return typeof key === "string" ? key : "";
1390
1098
  }
1391
1099
  } catch (_) {
1392
- /* fall through */
1100
+
1393
1101
  }
1394
1102
  return "";
1395
1103
  }
1396
1104
 
1397
- // Inline resolution: the cron engine asks for the API key once per tick
1398
- // for the current model. We cache the last-resolved model→key pair across
1399
- // ticks since model rarely changes within a cron. runDynamicUi awaits
1400
- // prewarmLlmApiKey before starting the cron, so tick 1 sees the resolved
1401
- // key. resolveLlmApiKeySync is only called after the prewarm has populated
1402
- // the cache; if it ever runs uncached it returns "" and the backend
1403
- // reports a useful error (cron resolves recipe_failed via the breaker).
1404
1105
  let lastModel = null;
1405
1106
  let lastKey = "";
1406
1107
  async function prewarmLlmApiKey(modelRef) {
@@ -1411,8 +1112,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1411
1112
  }
1412
1113
  function resolveLlmApiKeySync(modelRef) {
1413
1114
  if (modelRef === lastModel) return lastKey;
1414
- // Fallback: prewarm wasn't called (or model changed mid-cron). Kick
1415
- // off async resolution but return empty for this tick.
1115
+
1416
1116
  resolveLlmApiKey(modelRef).then((key) => {
1417
1117
  lastModel = modelRef;
1418
1118
  lastKey = key;
@@ -1420,12 +1120,6 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1420
1120
  return "";
1421
1121
  }
1422
1122
 
1423
- // Get-or-create the shared handler. Only the CREATING context builds the
1424
- // handler and wires the relay-singleton callbacks (disconnect drain, nav
1425
- // reconcile) — a second wiring against the same shared relay would
1426
- // double-handle every nav event / disconnect on the now-shared store.
1427
- // Boot order makes the creator the gateway-startup full registry, whose
1428
- // api/service carry the complete runtime surface.
1429
1123
  let scopeRecord = scopeHost[HANDLER_SCOPE_SYMBOL];
1430
1124
  const createsHandler = !scopeRecord || !scopeRecord.handler;
1431
1125
 
@@ -1441,7 +1135,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1441
1135
  service.emitGlassesUiLifecycle(event, severity, data);
1442
1136
  }
1443
1137
  } catch (_) {
1444
- // observability must never break the tool path
1138
+
1445
1139
  }
1446
1140
  },
1447
1141
  getGlassesUiLiveConfig: () => {
@@ -1455,10 +1149,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1455
1149
  resolveLlmApiKey: resolveLlmApiKeySync,
1456
1150
  prewarmLlmApiKey,
1457
1151
  timeoutMs: () => {
1458
- // Live-read so config hot-reloads (`openclawctl config set …`) take
1459
- // effect on the next render without a gateway restart. A non-finite
1460
- // or <=0 value disables the timeout (infinite wait — the pre-2026-05-23
1461
- // behaviour, kept available as an escape hatch).
1152
+
1462
1153
  try {
1463
1154
  const cfg = service.getRuntimeConfig && service.getRuntimeConfig();
1464
1155
  const v = cfg && cfg.renderGlassesUiTimeoutMs;
@@ -1468,25 +1159,14 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1468
1159
  }
1469
1160
  },
1470
1161
  isSessionConnected: () => {
1471
- // Consult the shared relay singleton (works across plugin-load
1472
- // contexts) for any connected downstream client. Per-session
1473
- // connection tracking would need deeper plumbing; the global check
1474
- // catches the common failure mode where the tool is invoked with no
1475
- // glasses client at all, which is what produced the indefinite hangs
1476
- // before this gate existed.
1162
+
1477
1163
  if (typeof service.hasConnectedAppClient === "function") {
1478
1164
  return service.hasConnectedAppClient();
1479
1165
  }
1480
1166
  return false;
1481
1167
  },
1482
1168
  isUnderBackpressure: () => {
1483
- // The paint-floor coalescer sheds the trailing send while the relay/BLE
1484
- // send buffer is over its high-water mark (Spike D — there is no
1485
- // glass-side paint-ack, so transport pressure is the only signal). The
1486
- // signal source is relay-health-monitor's send-buffer high-water; until
1487
- // relay-service surfaces it as isGlassesSendBufferOverHighWater this
1488
- // returns false (safe default — no shedding). Completing this query is
1489
- // part of the BLE-backpressure hardening validated on hardware (Task 20).
1169
+
1490
1170
  try {
1491
1171
  return typeof service.isGlassesSendBufferOverHighWater === "function"
1492
1172
  ? service.isGlassesSendBufferOverHighWater()
@@ -1495,9 +1175,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1495
1175
  return false;
1496
1176
  }
1497
1177
  },
1498
- // Tap-to-wake lane + busy signal (roadmap 6f). Absent on a relay that
1499
- // predates them — the wake controller then no-ops (parked taps keep
1500
- // their collect-on-next-render semantics).
1178
+
1501
1179
  dispatchWake:
1502
1180
  typeof service.dispatchGlassesWake === "function"
1503
1181
  ? (params) => service.dispatchGlassesWake(params)
@@ -1517,10 +1195,6 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1517
1195
  scopeRecord = { handler, refs: 0 };
1518
1196
  scopeHost[HANDLER_SCOPE_SYMBOL] = scopeRecord;
1519
1197
 
1520
- // Wire glasses-disconnect to stop any active crons for the affected
1521
- // session. The agent_end hook below only SETTLES pending calls; this path
1522
- // is the real teardown — the glasses are gone, so surfaces, parked events
1523
- // and crons all drain (dead-lettering undelivered nonterminals).
1524
1198
  if (typeof service.onAppClientDisconnect === "function") {
1525
1199
  service.onAppClientDisconnect(({ sessionKey }) => {
1526
1200
  const target = sessionKey || null;
@@ -1532,12 +1206,6 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1532
1206
  });
1533
1207
  }
1534
1208
 
1535
- // Reconcile client nav-events with the SINGLE store stack (Task 16). On pop
1536
- // the client reports the surfaceId now back on top + the post-pop depth; the
1537
- // store knows it. The relay frame carries no sessionKey, so resolve it from
1538
- // the surface's store entry (sessionForSurface). With the shared handler
1539
- // this wiring exists ONCE per process; the foreign-surface no-op guard
1540
- // stays for surfaces the store genuinely does not know (B2).
1541
1209
  if (typeof service.onGlassesUiNavEvent === "function") {
1542
1210
  service.onGlassesUiNavEvent((ev) => {
1543
1211
  const sessionKey = handler.sessionForSurface(ev.surfaceId);
@@ -1550,7 +1218,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1550
1218
  });
1551
1219
  }
1552
1220
  } catch (_) {
1553
- // observability must never break the nav path
1221
+
1554
1222
  }
1555
1223
  return;
1556
1224
  }
@@ -1569,14 +1237,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1569
1237
 
1570
1238
  api.registerTool(
1571
1239
  (ctx) => {
1572
- // Hide the tool from Even AI quick-action runs (dedicated or throwaway
1573
- // sessions). Those runs' responses go back to the Even Realities native
1574
- // app — not the OcuClaw chat surface — so a glasses popup wouldn't be
1575
- // reachable for the user. The tool stays visible to:
1576
- // • the normal OcuClaw chat path (ocuclaw:<timestamp> sessions)
1577
- // • the Even AI listen-mode path (which intercepts the HTTP
1578
- // request and routes it through the active OcuClaw session, so
1579
- // the sessionKey is an ocuclaw:<timestamp>, not ocuclaw:even-ai*).
1240
+
1580
1241
  const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : "";
1581
1242
  if (isEvenAiAgentSession(sessionKey, resolveDedicatedEvenAiSessionKey())) {
1582
1243
  return null;
@@ -1590,10 +1251,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1590
1251
  const resolvedSessionKey = normalizeGlassesSessionKey(factorySessionKey || "main");
1591
1252
  const depth = nextDepth(resolvedSessionKey);
1592
1253
  try {
1593
- // The gateway delivers its watchdog AbortSignal as the third
1594
- // argument (execute(toolCallId, args, signal, onUpdate) — verified
1595
- // against the installed 2026.6.1 dist). runDynamicUi releases the
1596
- // pending resolver on abort so post-cliff taps park (roadmap 6c).
1254
+
1597
1255
  const outcome = await handler.runDynamicUi({
1598
1256
  sessionKey: resolvedSessionKey,
1599
1257
  depth,
@@ -1615,12 +1273,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1615
1273
  );
1616
1274
 
1617
1275
  if (typeof api.on === "function") {
1618
- // Voicemail delivery (roadmap 7b): owed parked events ride the next
1619
- // genuine turn's prompt as appended system context (Channel-2 class —
1620
- // gateway hooks.ts concatenates appendSystemContext across handlers, so
1621
- // this composes with the channel-two fragment). Registered per load
1622
- // context; the shared handler's pending state makes duplicate firings
1623
- // drain-once.
1276
+
1624
1277
  api.on("before_prompt_build", (_event, ctx) => {
1625
1278
  const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
1626
1279
  if (!sessionKey) return undefined;
@@ -1628,27 +1281,18 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1628
1281
  const fragment = handler.buildVoicemailInjection(sessionKey);
1629
1282
  return fragment ? { appendSystemContext: fragment } : undefined;
1630
1283
  } catch (_) {
1631
- // Defensive: voicemail must never break prompt building.
1284
+
1632
1285
  return undefined;
1633
1286
  }
1634
1287
  });
1635
1288
 
1636
1289
  api.on("agent_end", (_event, ctx) => {
1637
1290
  const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
1638
- // Settle any still-pending render for this session before clearing the
1639
- // depth counter — the safety net for runs torn down externally (timeout,
1640
- // abort) so no pending call leaks across runs / corrupts tool_use
1641
- // bookkeeping. SETTLE ONLY: surfaces, parked events, crons and the
1642
- // stack persist (surfaces are designed to outlive runs; the old
1643
- // unconditional drain here destroyed parked wearer intent whenever it
1644
- // fired in the store-owning context — drift #3). With the shared
1645
- // handler this hook fires from every load context against ONE store;
1646
- // settlePending is idempotent so the duplicate firings are harmless.
1291
+
1647
1292
  if (sessionKey) {
1648
1293
  const stackDepth = handler.surfaceStackDepth(sessionKey);
1649
1294
  const settledPending = handler.settleSession(sessionKey, { result: "preempted" });
1650
- // Run-end observability (drift #3): this hook was silent, so WHERE it
1651
- // fired (which context's store, against what stack) was invisible.
1295
+
1652
1296
  try {
1653
1297
  if (typeof service.emitGlassesUiLifecycle === "function") {
1654
1298
  service.emitGlassesUiLifecycle("agent_end_settle", "debug", {
@@ -1659,22 +1303,15 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1659
1303
  });
1660
1304
  }
1661
1305
  } catch (_) {
1662
- // observability must never break the hook path
1306
+
1663
1307
  }
1664
- // 7a: a silent agent_end (no re-render after a wearer tap) parks the
1665
- // surface marker — clears awaiting-response so markerFor derives parked.
1308
+
1666
1309
  handler.parkMarkerOnAgentEnd(sessionKey);
1667
1310
  }
1668
1311
  resetDepth(sessionKey);
1669
1312
  });
1670
1313
  }
1671
1314
 
1672
- // Refcounted teardown (review P1): every register context holds a reference
1673
- // to the shared handler. Only the LAST live context's dispose drains it —
1674
- // a secondary context disposing mid-flight (e.g. an ephemeral registry
1675
- // being torn down) must not preempt live surfaces, clear the shared depth
1676
- // map, or delete the record (which would let a later register mint a second
1677
- // store and reintroduce the drift-#3 context split).
1678
1315
  scopeRecord.refs += 1;
1679
1316
  let disposedThisContext = false;
1680
1317
  return function dispose() {
@@ -1684,8 +1321,7 @@ export function registerGlassesUiTool(api, service, opts = {}) {
1684
1321
  if (scopeRecord.refs > 0) return;
1685
1322
  handler.drainAll({ result: "preempted" });
1686
1323
  depthBySession.clear();
1687
- // Drop the shared record so a post-dispose register builds fresh state
1688
- // (real teardown happens once, at gateway service stop).
1324
+
1689
1325
  if (scopeHost[HANDLER_SCOPE_SYMBOL] === scopeRecord) {
1690
1326
  delete scopeHost[HANDLER_SCOPE_SYMBOL];
1691
1327
  }