ocuclaw 1.3.1 → 1.3.3

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 (40) hide show
  1. package/dist/config/runtime-config-session-title-model.test.js +22 -0
  2. package/dist/config/runtime-config.js +7 -1
  3. package/dist/domain/glasses-display-system-prompt.js +52 -0
  4. package/dist/domain/glasses-display-system-prompt.test.js +44 -0
  5. package/dist/domain/glasses-ui-system-prompt.js +6 -22
  6. package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
  7. package/dist/domain/prompt-channel-fragments.js +32 -0
  8. package/dist/domain/prompt-channel-fragments.test.js +70 -0
  9. package/dist/gateway/gateway-timing-ledger.js +15 -3
  10. package/dist/gateway/openclaw-client.js +80 -3
  11. package/dist/index.js +22 -0
  12. package/dist/runtime/channel-two-hook.js +36 -0
  13. package/dist/runtime/container-env.js +41 -0
  14. package/dist/runtime/display-toggle-states.js +98 -0
  15. package/dist/runtime/glasses-backpressure-latch.js +115 -0
  16. package/dist/runtime/register-session-title-distiller.js +100 -0
  17. package/dist/runtime/relay-core.js +284 -33
  18. package/dist/runtime/relay-service.js +152 -13
  19. package/dist/runtime/relay-worker-entry.js +26 -0
  20. package/dist/runtime/relay-worker-supervisor.js +51 -2
  21. package/dist/runtime/relay-worker-transport.js +51 -1
  22. package/dist/runtime/session-service.js +136 -12
  23. package/dist/runtime/session-title-distiller-budget.js +36 -0
  24. package/dist/runtime/session-title-distiller-helpers.js +130 -0
  25. package/dist/runtime/session-title-distiller.js +354 -0
  26. package/dist/runtime/session-title-record.js +21 -0
  27. package/dist/runtime/stable-prompt-snapshot.js +119 -0
  28. package/dist/tools/glasses-ui-cron.js +59 -3
  29. package/dist/tools/glasses-ui-paint-floor.js +33 -4
  30. package/dist/tools/glasses-ui-surfaces.js +369 -35
  31. package/dist/tools/glasses-ui-tool-description.test.js +16 -0
  32. package/dist/tools/glasses-ui-tool.js +662 -80
  33. package/dist/tools/glasses-ui-voicemail.js +299 -0
  34. package/dist/tools/glasses-ui-wake.js +262 -0
  35. package/dist/tools/session-title-tool.js +14 -76
  36. package/dist/tools/session-title-tool.test.js +53 -0
  37. package/dist/version.js +2 -2
  38. package/openclaw.plugin.json +9 -0
  39. package/package.json +4 -3
  40. package/skills/glasses-ui/SKILL.md +26 -3
@@ -11,7 +11,9 @@ import {
11
11
  executeLlmRecipe,
12
12
  executeSystemStatsRecipe,
13
13
  } from "./glasses-ui-recipes.js";
14
- import { createPendingRenderMap, createSurfaceStore, isTerminalOutcome } from "./glasses-ui-surfaces.js";
14
+ import { createPendingRenderMap, createSurfaceStore, isTerminalOutcome, normalizeGlassesSessionKey } from "./glasses-ui-surfaces.js";
15
+ import { createGlassesWakeController } from "./glasses-ui-wake.js";
16
+ import { createGlassesVoicemail } from "./glasses-ui-voicemail.js";
15
17
  import { createPaintFloorCoalescer, DEFAULT_PAINT_FLOOR_MS } from "./glasses-ui-paint-floor.js";
16
18
  import { GLASSES_UI_LIMITS } from "./glasses-ui-limits.js";
17
19
  import {
@@ -257,6 +259,82 @@ const updateSchemaForToolParams = {
257
259
  "\"push\": stack a new screen; the parent is retained and its cron pauses.",
258
260
  };
259
261
 
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
+ const timeoutMsSchemaForToolParams = {
267
+ type: "integer",
268
+ minimum: 1000,
269
+ maximum: 600_000,
270
+ description:
271
+ "Optional one-shot interaction window for THIS call, in ms (default 90000, " +
272
+ "max 600000). Pass 300000-600000 when expecting the user to read or decide; " +
273
+ "omit for fire-and-forget. Never renewed automatically — re-render to listen again.",
274
+ };
275
+
276
+ // staleAfterMs reservation (roadmap 6b; annotate-only through the soak).
277
+ const staleAfterMsSchemaForToolParams = {
278
+ type: "integer",
279
+ minimum: 1000,
280
+ maximum: 86_400_000,
281
+ description:
282
+ "Optional per-render staleness window, in ms. A tap parked longer than this " +
283
+ "is still delivered but annotated stale:true — treat a stale actuating tap " +
284
+ "as a re-confirm prompt, never an action. Default absent (no annotation).",
285
+ };
286
+
287
+ export const GLASSES_UI_WINDOW_LIMITS = {
288
+ timeoutMsMin: timeoutMsSchemaForToolParams.minimum,
289
+ timeoutMsMax: timeoutMsSchemaForToolParams.maximum,
290
+ staleAfterMsMin: staleAfterMsSchemaForToolParams.minimum,
291
+ staleAfterMsMax: staleAfterMsSchemaForToolParams.maximum,
292
+ };
293
+
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
+ function validateWindowFields(spec) {
299
+ const out = { ok: true, timeoutMs: undefined, staleAfterMs: undefined };
300
+ if (spec && spec.timeoutMs !== undefined) {
301
+ const v = spec.timeoutMs;
302
+ if (
303
+ !Number.isFinite(v) ||
304
+ v < GLASSES_UI_WINDOW_LIMITS.timeoutMsMin ||
305
+ v > GLASSES_UI_WINDOW_LIMITS.timeoutMsMax
306
+ ) {
307
+ return {
308
+ ok: false,
309
+ code: "timeout_ms_out_of_bounds",
310
+ message:
311
+ `timeoutMs ${JSON.stringify(v)} out of bounds ` +
312
+ `[${GLASSES_UI_WINDOW_LIMITS.timeoutMsMin}..${GLASSES_UI_WINDOW_LIMITS.timeoutMsMax}]; ` +
313
+ "pass 300000-600000 when expecting the user to read or decide, omit for fire-and-forget",
314
+ };
315
+ }
316
+ out.timeoutMs = Math.floor(v);
317
+ }
318
+ if (spec && spec.staleAfterMs !== undefined) {
319
+ const v = spec.staleAfterMs;
320
+ if (
321
+ !Number.isFinite(v) ||
322
+ v < GLASSES_UI_WINDOW_LIMITS.staleAfterMsMin ||
323
+ v > GLASSES_UI_WINDOW_LIMITS.staleAfterMsMax
324
+ ) {
325
+ return {
326
+ ok: false,
327
+ code: "stale_after_ms_out_of_bounds",
328
+ message:
329
+ `staleAfterMs ${JSON.stringify(v)} out of bounds ` +
330
+ `[${GLASSES_UI_WINDOW_LIMITS.staleAfterMsMin}..${GLASSES_UI_WINDOW_LIMITS.staleAfterMsMax}]`,
331
+ };
332
+ }
333
+ out.staleAfterMs = Math.floor(v);
334
+ }
335
+ return out;
336
+ }
337
+
260
338
  const refreshSchemaForToolParams = {
261
339
  type: "object",
262
340
  description: "Optional periodic refresh policy; turns this surface into a live-updating one.",
@@ -374,6 +452,9 @@ export const glassesUiParametersSchema = {
374
452
  // default replace). Top-level for the same Anthropic-strips-oneOf reason as
375
453
  // refresh; mirrored into every oneOf branch below.
376
454
  update: updateSchemaForToolParams,
455
+ // Window fields (roadmap 6b) — same top-level + per-branch mirroring.
456
+ timeoutMs: timeoutMsSchemaForToolParams,
457
+ staleAfterMs: staleAfterMsSchemaForToolParams,
377
458
  },
378
459
  // oneOf is assembled from the descriptor registry (one branch per kind, in
379
460
  // enum order). Each branch's `refresh` slot — declared `undefined` in the
@@ -387,6 +468,8 @@ export const glassesUiParametersSchema = {
387
468
  ...branch.properties,
388
469
  refresh: refreshSchemaForToolParams,
389
470
  update: updateSchemaForToolParams,
471
+ timeoutMs: timeoutMsSchemaForToolParams,
472
+ staleAfterMs: staleAfterMsSchemaForToolParams,
390
473
  },
391
474
  })),
392
475
  };
@@ -447,6 +530,19 @@ export function isEvenAiAgentSession(sessionKey, dedicatedSessionKey) {
447
530
  // session replay won't 400 on an unmatched tool_use block.
448
531
  export const DEFAULT_RENDER_GLASSES_UI_TIMEOUT_MS = 30 * 60 * 1000;
449
532
 
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
+ export const GATEWAY_DYNAMIC_TOOL_DEFAULT_TIMEOUT_MS = 90_000;
538
+
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
+ const WINDOW_EXPIRED_HINT =
542
+ "The listen window closed; the surface is still live on glass and keeps " +
543
+ "updating. New taps park - re-render this surface (e.g. update:\"patch\") " +
544
+ "to collect them in this run, or end your turn and they ride the next one.";
545
+
450
546
  export function createGlassesUiToolHandler(deps) {
451
547
  // The single live surface store is constructed below (after the cron engine,
452
548
  // which it delegates pause/resume/stop to). There is never a separate pending
@@ -456,6 +552,55 @@ export function createGlassesUiToolHandler(deps) {
456
552
  // cron after a user dismissal (pending already resolved with a user-only
457
553
  // outcome that lacks ticks). Cleared immediately after stop returns.
458
554
  const capturedCronOutcome = new Map();
555
+
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
+ 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).
572
+ function clipBreadcrumb(s, reserveText = "") {
573
+ if (typeof s !== "string" || s.length === 0) return s;
574
+ const charBudget = Math.floor((TITLE_BUDGET_PX - reserveText.length * 20) / 20);
575
+ if (charBudget <= 0) return "";
576
+ if (s.length <= charBudget) return s;
577
+ const segments = s.split(" › ");
578
+ // Drop leading (oldest-ancestor) segments until the rejoin fits, always
579
+ // keeping at least the rightmost segment.
580
+ while (segments.length > 1 && segments.join(" › ").length > charBudget) {
581
+ segments.shift();
582
+ }
583
+ const joined = segments.join(" › ");
584
+ if (joined.length <= charBudget) return joined;
585
+ // One segment remains and still overflows → hard-truncate it to the budget.
586
+ return joined.slice(0, charBudget);
587
+ }
588
+
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
+ function emitMarker(sessionKey, surfaceId) {
597
+ if (!surfaceId) return;
598
+ if (surfaceStore.topSurfaceId(sessionKey) !== surfaceId) return; // only the active surface
599
+ const marker = surfaceStore.markerFor(surfaceId);
600
+ if (!marker) return;
601
+ paintFloor.enqueue({ surfaceId, sessionKey, patch: { marker } });
602
+ }
603
+
459
604
  const newSurfaceId =
460
605
  deps && typeof deps.newSurfaceId === "function"
461
606
  ? deps.newSurfaceId
@@ -464,8 +609,19 @@ export function createGlassesUiToolHandler(deps) {
464
609
  // Permanent glasses.lifecycle observability (nav reconcile + cron pause/
465
610
  // resume/tick). No-op when the dep is absent (tests) or the debug category is
466
611
  // disabled. See docs/superpowers/findings/2026-05-30-glasses-ui-phase4-hardware.md.
467
- const emitLifecycle =
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
+ const storeId =
618
+ typeof deps.storeId === "string" && deps.storeId
619
+ ? deps.storeId
620
+ : `st-${Math.random().toString(36).slice(2, 8)}`;
621
+ const baseEmitLifecycle =
468
622
  typeof deps.emitLifecycle === "function" ? deps.emitLifecycle : () => {};
623
+ const emitLifecycle = (event, severity, data) =>
624
+ baseEmitLifecycle(event, severity, { storeId, ...(data || {}) });
469
625
 
470
626
  // Resolve the handler-wide default timeout. Per-call timeouts may still
471
627
  // override this via params.timeoutMs.
@@ -490,7 +646,7 @@ export function createGlassesUiToolHandler(deps) {
490
646
  paintFloorMs: Number.isFinite(deps.paintFloorMs) ? deps.paintFloorMs : DEFAULT_PAINT_FLOOR_MS,
491
647
  send: ({ surfaceId, sessionKey, patch }) => {
492
648
  if (patch && patch.__render) {
493
- deps.relay.sendGlassesUiRender({ sessionKey, surfaceId, depth: patch.__depth, spec: patch.__spec });
649
+ deps.relay.sendGlassesUiRender({ sessionKey, surfaceId, depth: patch.__depth, spec: patch.__spec, marker: patch.__marker });
494
650
  } else {
495
651
  deps.relay.sendGlassesUiSurfaceUpdate({ sessionKey, surfaceId, patch });
496
652
  }
@@ -534,6 +690,10 @@ export function createGlassesUiToolHandler(deps) {
534
690
  // cron engine so it can delegate pause/resume/stop; id minting reuses the
535
691
  // handler's minter. Replaces the Phase-1 pending map — never a second store.
536
692
  const surfaceStore = createSurfaceStore({
693
+ storeId,
694
+ emitLifecycle,
695
+ // Injectable clock for parked-event age math (queuedAtMs/parkedForMs/stale).
696
+ now: typeof deps.now === "function" ? deps.now : undefined,
537
697
  pauseCron: (id) => cronEngine.pause(id),
538
698
  resumeCron: (id) => cronEngine.resume(id),
539
699
  // stopCron fires on every surface teardown (replace swap, popBack child,
@@ -542,23 +702,59 @@ export function createGlassesUiToolHandler(deps) {
542
702
  // parent/chat after a back/pop, and the per-surface coalescer state doesn't
543
703
  // leak across a long push/replace session. (pauseCron on push does NOT
544
704
  // dispose — the parent resumes.)
545
- stopCron: (id) => {
546
- cronEngine.stop(id, { result: "preempted" });
705
+ stopCron: (id, opts) => {
706
+ cronEngine.stop(id, { result: "preempted" }, opts);
547
707
  paintFloor.dispose(id);
548
708
  },
549
709
  mintSurfaceId: newSurfaceId,
550
710
  });
551
711
 
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
+ const wakeController = createGlassesWakeController({
719
+ dispatchWake: typeof deps.dispatchWake === "function" ? deps.dispatchWake : null,
720
+ isAgentTurnBusy: typeof deps.isAgentTurnBusy === "function" ? deps.isAgentTurnBusy : () => false,
721
+ emitLifecycle,
722
+ now: typeof deps.now === "function" ? deps.now : Date.now,
723
+ wakeCooldownMs: deps.wakeCooldownMs,
724
+ });
725
+
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
+ const voicemail = createGlassesVoicemail({
733
+ now: typeof deps.now === "function" ? deps.now : Date.now,
734
+ ttlMs: deps.voicemailTtlMs,
735
+ drainWakeOutbox: () => wakeController.drainWakeOutbox(),
736
+ drainDeadLetter: (sessionKey) => surfaceStore.drainDeadLetter(sessionKey),
737
+ emitLifecycle,
738
+ });
739
+
552
740
  deps.relay.onGlassesUiResult((msg) => {
553
741
  if (!msg || typeof msg.surfaceId !== "string" || !msg.outcome) return;
554
- const terminal = isTerminalOutcome(msg.outcome);
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).
745
+ const outcome = {
746
+ ...msg.outcome,
747
+ origin: typeof msg.outcome.origin === "string" ? msg.outcome.origin : "gesture",
748
+ actor: typeof msg.outcome.actor === "string" ? msg.outcome.actor : "wearer",
749
+ };
750
+ const terminal = isTerminalOutcome(outcome);
555
751
  if (terminal && cronEngine.isActive(msg.surfaceId)) {
556
752
  // Terminal with a live cron: stop the cron, merging its tick stats into
557
753
  // the outcome via capturedCronOutcome, then settle the in-flight call (or
558
754
  // queue the terminal so the next render discards-for-exit).
559
- let merged = msg.outcome;
755
+ let merged = outcome;
560
756
  capturedCronOutcome.set(msg.surfaceId, (cronOutcome) => { merged = cronOutcome; });
561
- cronEngine.stop(msg.surfaceId, msg.outcome);
757
+ cronEngine.stop(msg.surfaceId, outcome);
562
758
  capturedCronOutcome.delete(msg.surfaceId);
563
759
  if (!surfaceStore.resolve(msg.surfaceId, merged)) {
564
760
  surfaceStore.queueEvent(msg.surfaceId, merged);
@@ -569,8 +765,33 @@ export function createGlassesUiToolHandler(deps) {
569
765
  // the surface is in visible_awaiting_agent — queue last-wins so the agent's
570
766
  // next render delivers the latest event (spec §Tool-call accounting). A
571
767
  // 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);
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).
771
+ const sessionKey = surfaceStore.sessionForSurface(msg.surfaceId);
772
+ if (surfaceStore.resolve(msg.surfaceId, outcome)) {
773
+ // LIVE tap on the open window → the agent is now responding → ●→◌.
774
+ emitMarker(sessionKey, msg.surfaceId);
775
+ } else {
776
+ const receipt = surfaceStore.queueEvent(msg.surfaceId, outcome, {
777
+ origin: outcome.origin,
778
+ actor: outcome.actor,
779
+ });
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).
783
+ if (receipt && !receipt.kind) {
784
+ wakeController.onParkedGesture({
785
+ sessionKey: surfaceStore.sessionForSurface(msg.surfaceId),
786
+ surfaceUuid: receipt.surfaceUuid,
787
+ eventId: receipt.eventId,
788
+ result: outcome.result,
789
+ itemIndex: outcome.selected_index,
790
+ origin: outcome.origin,
791
+ });
792
+ }
793
+ // PARK → ◌: the queued tap (events.length≥1) derives inflight.
794
+ emitMarker(sessionKey, msg.surfaceId);
574
795
  }
575
796
  });
576
797
 
@@ -586,10 +807,14 @@ export function createGlassesUiToolHandler(deps) {
586
807
  err.code = validation.code;
587
808
  throw err;
588
809
  }
589
- const sessionKey =
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).
813
+ const sessionKey = normalizeGlassesSessionKey(
590
814
  typeof params.sessionKey === "string" && params.sessionKey.trim()
591
815
  ? params.sessionKey.trim()
592
- : "main";
816
+ : "main",
817
+ );
593
818
  if (typeof deps.isSessionConnected === "function" && !deps.isSessionConnected(sessionKey)) {
594
819
  const err = new Error(
595
820
  "glasses_not_connected: no Even glasses client connected for this session",
@@ -610,21 +835,73 @@ export function createGlassesUiToolHandler(deps) {
610
835
  refreshValidated = v.refresh;
611
836
  }
612
837
 
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
+ const windowFields = validateWindowFields(params.spec);
842
+ if (!windowFields.ok) {
843
+ emitLifecycle("render_rejected", "warn", {
844
+ surfaceId: null,
845
+ code: windowFields.code,
846
+ reason: windowFields.message,
847
+ });
848
+ const err = new Error(`${windowFields.code}: ${windowFields.message}`);
849
+ err.code = windowFields.code;
850
+ throw err;
851
+ }
852
+
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).
613
859
  const depth = Number.isFinite(params.depth) ? Math.max(1, Math.floor(params.depth)) : 1;
614
860
  const update =
615
861
  params.spec && (params.spec.update === "patch" || params.spec.update === "push")
616
862
  ? params.spec.update
617
863
  : "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.
871
+ if (depth <= 1 && surfaceStore.stackDepth(sessionKey) > 1) {
872
+ const stackDepthBefore = surfaceStore.stackDepth(sessionKey);
873
+ const reapedPending = reapSession(sessionKey, { result: "preempted" });
874
+ emitLifecycle("stale_stack_reaped", "warn", {
875
+ sessionKey,
876
+ stackDepthBefore,
877
+ reapedPending,
878
+ });
879
+ }
618
880
  // The plugin owns surfaceIds (spec §Core model). applyRender derives the
619
881
  // target from the session's current top: patch/replace reuse the top id
620
882
  // (re-attach in place), push mints a child + pauses the parent cron, the
621
883
  // first render mints a root. This is the single place a surfaceId is bound.
884
+ const stackDepthBeforeAttach = surfaceStore.stackDepth(sessionKey);
622
885
  const applied = surfaceStore.applyRender(sessionKey, {
623
886
  update,
624
887
  kind: validation.spec.kind,
625
888
  });
626
889
  const surfaceId = applied.surfaceId;
627
- const promise = surfaceStore.register(sessionKey, surfaceId, { kind: validation.spec.kind });
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.
893
+ emitLifecycle("surface_attach", "debug", {
894
+ surfaceId,
895
+ sessionKey,
896
+ mode: applied.mode,
897
+ requestedUpdate: update,
898
+ stackDepthBefore: stackDepthBeforeAttach,
899
+ });
900
+ const promise = surfaceStore.register(sessionKey, surfaceId, {
901
+ kind: validation.spec.kind,
902
+ staleAfterMs: windowFields.staleAfterMs,
903
+ title: typeof validation.spec.title === "string" ? validation.spec.title : undefined,
904
+ });
628
905
  // Re-attach flush (last-wins queue / latched exit): only for a patch/replace
629
906
  // onto an already-attached surface (the visible_awaiting_agent window can
630
907
  // only exist on a surface that previously resolved a call). A fresh
@@ -644,16 +921,38 @@ export function createGlassesUiToolHandler(deps) {
644
921
  surfaceStore.exit(sessionKey);
645
922
  return promise;
646
923
  }
924
+ 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.
928
+ emitLifecycle("stale_cron_summary_dropped", "debug", { surfaceId, sessionKey });
929
+ }
647
930
  }
648
931
 
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
+ const wireDepth = Math.max(1, surfaceStore.stackDepth(sessionKey));
937
+
649
938
  // Initial render uses the agent's seed (instant). Routed through the
650
939
  // paint-floor coalescer as a leading-edge render sentinel so it shares the
651
940
  // single send chokepoint (a render supersedes any queued field patch for
652
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
+ const breadcrumb = surfaceStore.breadcrumbFor(sessionKey);
947
+ if (breadcrumb) validation.spec.title = clipBreadcrumb(breadcrumb);
653
948
  paintFloor.enqueue({
654
949
  surfaceId,
655
950
  sessionKey,
656
- patch: { __render: true, __depth: depth, __spec: validation.spec },
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).
955
+ patch: { __render: true, __depth: wireDepth, __spec: validation.spec, __marker: surfaceStore.markerFor(surfaceId) },
657
956
  });
658
957
 
659
958
  // Live-refresh path: kick off the cron in parallel. A `patch` onto an
@@ -706,6 +1005,14 @@ export function createGlassesUiToolHandler(deps) {
706
1005
  const capture = capturedCronOutcome.get(surfaceId);
707
1006
  if (capture) capture(cronOutcome);
708
1007
  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.
1012
+ const stamped = {
1013
+ ...cronOutcome,
1014
+ origin: typeof cronOutcome.origin === "string" ? cronOutcome.origin : "system",
1015
+ };
709
1016
  // Settle the in-flight call; if none is pending (the surface is in
710
1017
  // visible_awaiting_agent after a nonterminal user action and the
711
1018
  // cron then hit its own terminal — recipe_failed / maxDuration
@@ -713,42 +1020,119 @@ export function createGlassesUiToolHandler(deps) {
713
1020
  // onReattached returns discarded_for_exit and tears the surface
714
1021
  // down. Without this fallback the dead-cron surface would persist.
715
1022
  // Symmetric with the onGlassesUiResult terminal path.
716
- if (!surfaceStore.resolve(surfaceId, cronOutcome)) {
717
- surfaceStore.queueEvent(surfaceId, cronOutcome);
1023
+ if (!surfaceStore.resolve(surfaceId, stamped)) {
1024
+ surfaceStore.queueEvent(surfaceId, stamped);
718
1025
  }
719
1026
  }
720
1027
  },
721
1028
  });
722
1029
  }
723
1030
 
724
- // Bound the wait via the existing timeout knob — disabled if cron is
725
- // active (cron has its own maxDurationMs cap and the user explicitly
726
- // owns the surface lifetime through dismiss).
1031
+ const setTimeoutFn =
1032
+ deps && typeof deps.setTimeout === "function" ? deps.setTimeout : setTimeout;
1033
+ const clearTimeoutFn =
1034
+ deps && typeof deps.clearTimeout === "function" ? deps.clearTimeout : clearTimeout;
1035
+ const cleanups = [];
1036
+
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
+ const effectiveWindowMs =
1046
+ windowFields.timeoutMs !== undefined
1047
+ ? windowFields.timeoutMs
1048
+ : 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.
1052
+ const wrapUpMarginMs = Math.min(5000, Math.max(2000, Math.floor(effectiveWindowMs * 0.05)));
1053
+ const wrapUpDelayMs = Math.max(effectiveWindowMs - wrapUpMarginMs, Math.floor(effectiveWindowMs / 2));
1054
+ const windowExpiredOutcome = (extra) =>
1055
+ Object.assign(
1056
+ {
1057
+ result: "window_expired",
1058
+ surface_still_live: true,
1059
+ window_ms: effectiveWindowMs,
1060
+ origin: "system",
1061
+ hint: WINDOW_EXPIRED_HINT,
1062
+ },
1063
+ extra,
1064
+ );
1065
+ const wrapUpHandle = setTimeoutFn(() => {
1066
+ if (surfaceStore.resolve(surfaceId, windowExpiredOutcome())) {
1067
+ emitLifecycle("window_expired", "debug", {
1068
+ surfaceId,
1069
+ sessionKey,
1070
+ windowMs: effectiveWindowMs,
1071
+ via: "wrap_up_timer",
1072
+ });
1073
+ // ●→○: the window closed with no wearer gesture (origin:"system"), so
1074
+ // awaitingAgentResponse stays false and markerFor derives parked (7a).
1075
+ emitMarker(sessionKey, surfaceId);
1076
+ }
1077
+ }, wrapUpDelayMs);
1078
+ cleanups.push(() => clearTimeoutFn(wrapUpHandle));
1079
+
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
+ const signal = params.signal;
1086
+ if (signal && typeof signal.addEventListener === "function") {
1087
+ const onAbort = () => {
1088
+ if (surfaceStore.resolve(surfaceId, windowExpiredOutcome({ aborted: true }))) {
1089
+ emitLifecycle("window_expired", "debug", {
1090
+ surfaceId,
1091
+ sessionKey,
1092
+ windowMs: effectiveWindowMs,
1093
+ via: "abort_signal",
1094
+ });
1095
+ }
1096
+ };
1097
+ if (signal.aborted) {
1098
+ onAbort();
1099
+ } else {
1100
+ signal.addEventListener("abort", onAbort, { once: true });
1101
+ cleanups.push(() => {
1102
+ if (typeof signal.removeEventListener === "function") {
1103
+ signal.removeEventListener("abort", onAbort);
1104
+ }
1105
+ });
1106
+ }
1107
+ }
1108
+
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.
727
1112
  const timeoutMs = Number.isFinite(params.timeoutMs)
728
1113
  ? params.timeoutMs
729
1114
  : resolveHandlerTimeoutMs();
730
1115
  if (!refreshValidated && Number.isFinite(timeoutMs) && timeoutMs > 0) {
731
- const setTimeoutFn =
732
- deps && typeof deps.setTimeout === "function" ? deps.setTimeout : setTimeout;
733
- const clearTimeoutFn =
734
- deps && typeof deps.clearTimeout === "function" ? deps.clearTimeout : clearTimeout;
735
1116
  const handle = setTimeoutFn(() => {
736
1117
  // Resolves only if the entry is still pending; surfaceStore.resolve is a
737
1118
  // no-op when the client already produced a real outcome. timeout is
738
1119
  // terminal, so it also moves the surface to `exiting`.
739
- surfaceStore.resolve(surfaceId, { result: "timeout", timeout_ms: timeoutMs });
1120
+ surfaceStore.resolve(surfaceId, { result: "timeout", timeout_ms: timeoutMs, origin: "system" });
740
1121
  }, timeoutMs);
741
- return promise.then((outcome) => {
742
- clearTimeoutFn(handle);
743
- return outcome;
744
- });
1122
+ cleanups.push(() => clearTimeoutFn(handle));
745
1123
  }
1124
+
746
1125
  // Decoupled lifecycle: the call resolves on the user's action (via
747
- // onGlassesUiResult) or the cron's terminal outcome (via onResolve). A
748
- // nonterminal selected/back resolves the call WITHOUT stopping the cron
749
- // the surface (and its cron) persists until a terminal or a drain. So the
750
- // old "stop the cron after any resolved outcome" tail is gone.
751
- return promise;
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
+ return promise.then((outcome) => {
1131
+ for (const fn of cleanups) {
1132
+ try { fn(); } catch (_) { /* cleanup must never mask the outcome */ }
1133
+ }
1134
+ return outcome;
1135
+ });
752
1136
  }
753
1137
 
754
1138
  // Per-session last-seen depth, used only to distinguish push (depth up) from
@@ -760,7 +1144,8 @@ export function createGlassesUiToolHandler(deps) {
760
1144
  // parent (Spike B: the plugin owns the resume target).
761
1145
  const navDepthBySession = new Map(); // sessionKey -> lastSeenDepth
762
1146
 
763
- function handleNavEvent(sessionKey, ev) {
1147
+ function handleNavEvent(rawSessionKey, ev) {
1148
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
764
1149
  const newDepth = Number.isFinite(ev.depth) ? Math.max(1, Math.floor(ev.depth)) : 1;
765
1150
  const lastDepth = navDepthBySession.get(sessionKey) || surfaceStore.stackDepth(sessionKey) || 1;
766
1151
  const storeDepthBefore = surfaceStore.stackDepth(sessionKey);
@@ -776,8 +1161,29 @@ export function createGlassesUiToolHandler(deps) {
776
1161
  guard += 1;
777
1162
  }
778
1163
  }
1164
+ if (
1165
+ popCount === 0 &&
1166
+ storeDepthBefore > 1 &&
1167
+ surfaceStore.topSurfaceId(sessionKey) === ev.surfaceId
1168
+ ) {
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).
1176
+ resumedParent = surfaceStore.popBack(sessionKey);
1177
+ popCount += 1;
1178
+ }
779
1179
  // Push (newDepth > lastDepth) is already reflected in the store by the
780
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).
1184
+ if (popCount > 0 && resumedParent) {
1185
+ emitMarker(sessionKey, resumedParent);
1186
+ }
781
1187
  emitLifecycle("nav_reconcile", "debug", {
782
1188
  sessionKey,
783
1189
  evSurfaceId: ev.surfaceId,
@@ -791,19 +1197,34 @@ export function createGlassesUiToolHandler(deps) {
791
1197
  navDepthBySession.set(sessionKey, newDepth);
792
1198
  }
793
1199
 
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
+ function reapSession(rawSessionKey, outcome) {
1207
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
1208
+ cronEngine.stopAllForSession(sessionKey, outcome);
1209
+ 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)
1212
+ return reaped;
1213
+ }
1214
+
794
1215
  return {
1216
+ storeId,
795
1217
  runDynamicUi,
796
1218
  handleNavEvent,
797
1219
  drainSession(sessionKey, outcome) {
798
- cronEngine.stopAllForSession(sessionKey, outcome);
799
- // Resolve pending + delete entries FIRST (so pending calls settle with
800
- // `outcome`), THEN clear the per-session stack. exit() deletes entries
801
- // without resolving, so it must NOT run before the drain or the pending
802
- // promises would hang.
803
- const reaped = surfaceStore.drainSession(sessionKey, outcome);
804
- surfaceStore.exit(sessionKey); // clear the stack (crons already stopped)
805
- navDepthBySession.delete(sessionKey); // drop stale nav depth (stack is now 0)
806
- return reaped;
1220
+ return reapSession(sessionKey, outcome);
1221
+ },
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).
1226
+ settleSession(sessionKey, outcome) {
1227
+ return surfaceStore.settlePending(sessionKey, outcome);
807
1228
  },
808
1229
  drainAll(outcome) {
809
1230
  cronEngine.stopAll(outcome);
@@ -814,6 +1235,21 @@ export function createGlassesUiToolHandler(deps) {
814
1235
  navDepthBySession.clear();
815
1236
  return reaped;
816
1237
  },
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.
1241
+ peekWakeOutbox() {
1242
+ return wakeController.peekWakeOutbox();
1243
+ },
1244
+ drainWakeOutbox() {
1245
+ return wakeController.drainWakeOutbox();
1246
+ },
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.
1250
+ buildVoicemailInjection(sessionKey) {
1251
+ return voicemail.buildInjection(sessionKey);
1252
+ },
817
1253
  isCronActive(surfaceId) {
818
1254
  return cronEngine.isActive(surfaceId);
819
1255
  },
@@ -824,6 +1260,17 @@ export function createGlassesUiToolHandler(deps) {
824
1260
  surfaceStackDepth(sessionKey) {
825
1261
  return surfaceStore.stackDepth(sessionKey);
826
1262
  },
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).
1269
+ parkMarkerOnAgentEnd(sessionKey) {
1270
+ surfaceStore.clearAwaitingResponse(sessionKey); // inflight → parked if the agent ended silently
1271
+ const top = surfaceStore.topSurfaceId(sessionKey);
1272
+ if (top) emitMarker(sessionKey, top); // emitMarker + surfaceStore are in scope HERE
1273
+ },
827
1274
  sessionForSurface(surfaceId) {
828
1275
  return surfaceStore.sessionForSurface(surfaceId);
829
1276
  },
@@ -839,8 +1286,8 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
839
1286
  " short detail body (≤200 chars) shown as the user",
840
1287
  " scrolls; use when options need a 1-2 sentence",
841
1288
  " compare-before-choosing detail.",
842
- "The call blocks until the user selects, dismisses, or backs out. result is one",
843
- "of: selected, back, dismissed, timeout, recipe_failed, glasses_disconnected.",
1289
+ "The call carries one one-shot listen window. result is one of: selected,",
1290
+ "back, dismissed, window_expired, timeout, recipe_failed, glasses_disconnected.",
844
1291
  "",
845
1292
  "Optional params:",
846
1293
  " refresh — make the surface self-update on a timer (e.g. live host stats via",
@@ -850,6 +1297,10 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
850
1297
  " fields; cron keeps ticking), \"replace\" (default; swap content in",
851
1298
  " place, no back-target), \"push\" (stack a child screen; the parent",
852
1299
  " is retained and its cron pauses, resuming on back).",
1300
+ " timeoutMs — listen ms (default 90000, max 600000); 300000-600000 when the",
1301
+ " user must read or decide; omit for fire-and-forget.",
1302
+ "window_expired is NOT an error: the surface stays live; taps park — re-render",
1303
+ "(update:\"patch\") to collect, or end your turn (parked taps wake you).",
853
1304
  "",
854
1305
  "Before authoring any refreshing/live surface, per-item detail list, or",
855
1306
  "multi-screen flow, load the \"glasses-ui\" skill — it is the authoring source",
@@ -858,6 +1309,13 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
858
1309
  "exit-to-chat policy, the {{path|filter}} template + per-item {label,body}",
859
1310
  "reference, and worked examples (including a live system-stats",
860
1311
  "list_with_details surface). Keep this description lean; depth lives in the skill.",
1312
+ "",
1313
+ "After the call resolves, your NEXT output decides the glasses: another",
1314
+ "render_glasses_ui replaces the surface (drill-down / next step); a short text",
1315
+ "reply hands the screen back to chat (the surface disappears); a silent run-end",
1316
+ "leaves the surface up until the user dismisses it. A \"back\" result means the",
1317
+ "user wants to revise their previous answer — re-render it or pivot; after a",
1318
+ "\"selected\" result, follow up with another render or a brief one-line ack.",
861
1319
  ].join("\n");
862
1320
 
863
1321
  // Shared per-session depth counter. OpenClaw loads the plugin's register(api)
@@ -877,24 +1335,40 @@ function getSharedDepthMap() {
877
1335
  return m;
878
1336
  }
879
1337
 
880
- export function registerGlassesUiTool(api, service) {
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
+ const HANDLER_SCOPE_SYMBOL = Symbol.for("ocuclaw.glasses-ui.sharedHandler");
1349
+
1350
+ export function registerGlassesUiTool(api, service, opts = {}) {
881
1351
  if (!api || typeof api.registerTool !== "function") {
882
1352
  throw new Error("registerGlassesUiTool requires api.registerTool");
883
1353
  }
884
1354
  if (!service) {
885
1355
  throw new Error("registerGlassesUiTool requires the OcuClaw relay service");
886
1356
  }
1357
+ const scopeHost =
1358
+ opts && opts.scopeHost && typeof opts.scopeHost === "object" ? opts.scopeHost : globalThis;
887
1359
 
888
1360
  const depthBySession = getSharedDepthMap();
889
1361
 
890
- function nextDepth(sessionKey) {
1362
+ function nextDepth(rawSessionKey) {
1363
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
891
1364
  const prev = depthBySession.get(sessionKey) || 0;
892
1365
  const next = prev + 1;
893
1366
  depthBySession.set(sessionKey, next);
894
1367
  return next;
895
1368
  }
896
1369
 
897
- function resetDepth(sessionKey) {
1370
+ function resetDepth(rawSessionKey) {
1371
+ const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
898
1372
  if (sessionKey) {
899
1373
  depthBySession.delete(sessionKey);
900
1374
  } else {
@@ -946,7 +1420,16 @@ export function registerGlassesUiTool(api, service) {
946
1420
  return "";
947
1421
  }
948
1422
 
949
- const handler = createGlassesUiToolHandler({
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
+ let scopeRecord = scopeHost[HANDLER_SCOPE_SYMBOL];
1430
+ const createsHandler = !scopeRecord || !scopeRecord.handler;
1431
+
1432
+ const handler = createsHandler ? createGlassesUiToolHandler({
950
1433
  relay: {
951
1434
  sendGlassesUiRender: (msg) => service.sendGlassesUiRender(msg),
952
1435
  sendGlassesUiSurfaceUpdate: (msg) => service.sendGlassesUiSurfaceUpdate(msg),
@@ -1012,31 +1495,68 @@ export function registerGlassesUiTool(api, service) {
1012
1495
  return false;
1013
1496
  }
1014
1497
  },
1015
- });
1016
-
1017
- // Wire glasses-disconnect to stop any active crons for the affected
1018
- // session. drainSession is also called by agent_end below; this path is
1019
- // distinct because a disconnect may happen mid-run without an agent_end.
1020
- if (typeof service.onAppClientDisconnect === "function") {
1021
- service.onAppClientDisconnect(({ sessionKey }) => {
1022
- const target = sessionKey || null;
1023
- if (target) {
1024
- handler.drainSession(target, { result: "glasses_disconnected" });
1025
- } else {
1026
- handler.drainAll({ result: "glasses_disconnected" });
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).
1501
+ dispatchWake:
1502
+ typeof service.dispatchGlassesWake === "function"
1503
+ ? (params) => service.dispatchGlassesWake(params)
1504
+ : null,
1505
+ isAgentTurnBusy: (sessionKey) => {
1506
+ try {
1507
+ return typeof service.isAgentTurnBusy === "function"
1508
+ ? !!service.isAgentTurnBusy(sessionKey)
1509
+ : false;
1510
+ } catch (_) {
1511
+ return false;
1027
1512
  }
1028
- });
1029
- }
1513
+ },
1514
+ }) : scopeRecord.handler;
1030
1515
 
1031
- // Reconcile client nav-events with the SINGLE store stack (Task 16). On pop
1032
- // the client reports the surfaceId now back on top + the post-pop depth; the
1033
- // store knows it. The relay frame carries no sessionKey, so resolve it from
1034
- // the surface's store entry (sessionForSurface), falling back to "main".
1035
- if (typeof service.onGlassesUiNavEvent === "function") {
1036
- service.onGlassesUiNavEvent((ev) => {
1037
- const sessionKey = handler.sessionForSurface(ev.surfaceId) || "main";
1038
- handler.handleNavEvent(sessionKey, ev);
1039
- });
1516
+ if (createsHandler) {
1517
+ scopeRecord = { handler, refs: 0 };
1518
+ scopeHost[HANDLER_SCOPE_SYMBOL] = scopeRecord;
1519
+
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
+ if (typeof service.onAppClientDisconnect === "function") {
1525
+ service.onAppClientDisconnect(({ sessionKey }) => {
1526
+ const target = sessionKey || null;
1527
+ if (target) {
1528
+ handler.drainSession(target, { result: "glasses_disconnected" });
1529
+ } else {
1530
+ handler.drainAll({ result: "glasses_disconnected" });
1531
+ }
1532
+ });
1533
+ }
1534
+
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
+ if (typeof service.onGlassesUiNavEvent === "function") {
1542
+ service.onGlassesUiNavEvent((ev) => {
1543
+ const sessionKey = handler.sessionForSurface(ev.surfaceId);
1544
+ if (!sessionKey) {
1545
+ try {
1546
+ if (typeof service.emitGlassesUiLifecycle === "function") {
1547
+ service.emitGlassesUiLifecycle("nav_event_skipped_foreign_surface", "debug", {
1548
+ evSurfaceId: ev.surfaceId,
1549
+ evDepth: ev.depth,
1550
+ });
1551
+ }
1552
+ } catch (_) {
1553
+ // observability must never break the nav path
1554
+ }
1555
+ return;
1556
+ }
1557
+ handler.handleNavEvent(sessionKey, ev);
1558
+ });
1559
+ }
1040
1560
  }
1041
1561
 
1042
1562
  function resolveDedicatedEvenAiSessionKey() {
@@ -1066,14 +1586,19 @@ export function registerGlassesUiTool(api, service) {
1066
1586
  name: "render_glasses_ui",
1067
1587
  description: GLASSES_UI_TOOL_DESCRIPTION,
1068
1588
  parameters: glassesUiParametersSchema,
1069
- async execute(_toolCallId, params) {
1070
- const resolvedSessionKey = factorySessionKey || "main";
1589
+ async execute(_toolCallId, params, signal) {
1590
+ const resolvedSessionKey = normalizeGlassesSessionKey(factorySessionKey || "main");
1071
1591
  const depth = nextDepth(resolvedSessionKey);
1072
1592
  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).
1073
1597
  const outcome = await handler.runDynamicUi({
1074
1598
  sessionKey: resolvedSessionKey,
1075
1599
  depth,
1076
1600
  spec: params,
1601
+ signal,
1077
1602
  });
1078
1603
  return {
1079
1604
  content: [{ type: "text", text: JSON.stringify(outcome) }],
@@ -1090,22 +1615,79 @@ export function registerGlassesUiTool(api, service) {
1090
1615
  );
1091
1616
 
1092
1617
  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.
1624
+ api.on("before_prompt_build", (_event, ctx) => {
1625
+ const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
1626
+ if (!sessionKey) return undefined;
1627
+ try {
1628
+ const fragment = handler.buildVoicemailInjection(sessionKey);
1629
+ return fragment ? { appendSystemContext: fragment } : undefined;
1630
+ } catch (_) {
1631
+ // Defensive: voicemail must never break prompt building.
1632
+ return undefined;
1633
+ }
1634
+ });
1635
+
1093
1636
  api.on("agent_end", (_event, ctx) => {
1094
1637
  const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
1095
- // Drain any still-pending render for this session before clearing the
1096
- // depth counter. Normal-completion runs will have already resolved
1097
- // their tool call; this branch is the safety net for runs torn down
1098
- // externally (timeout, abort) so the in-memory map doesn't leak
1099
- // across runs.
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.
1100
1647
  if (sessionKey) {
1101
- handler.drainSession(sessionKey, { result: "preempted" });
1648
+ const stackDepth = handler.surfaceStackDepth(sessionKey);
1649
+ 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.
1652
+ try {
1653
+ if (typeof service.emitGlassesUiLifecycle === "function") {
1654
+ service.emitGlassesUiLifecycle("agent_end_settle", "debug", {
1655
+ sessionKey: normalizeGlassesSessionKey(sessionKey),
1656
+ stackDepth,
1657
+ settledPending,
1658
+ storeId: handler.storeId,
1659
+ });
1660
+ }
1661
+ } catch (_) {
1662
+ // observability must never break the hook path
1663
+ }
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.
1666
+ handler.parkMarkerOnAgentEnd(sessionKey);
1102
1667
  }
1103
1668
  resetDepth(sessionKey);
1104
1669
  });
1105
1670
  }
1106
1671
 
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
+ scopeRecord.refs += 1;
1679
+ let disposedThisContext = false;
1107
1680
  return function dispose() {
1681
+ if (disposedThisContext) return;
1682
+ disposedThisContext = true;
1683
+ scopeRecord.refs -= 1;
1684
+ if (scopeRecord.refs > 0) return;
1108
1685
  handler.drainAll({ result: "preempted" });
1109
1686
  depthBySession.clear();
1687
+ // Drop the shared record so a post-dispose register builds fresh state
1688
+ // (real teardown happens once, at gateway service stop).
1689
+ if (scopeHost[HANDLER_SCOPE_SYMBOL] === scopeRecord) {
1690
+ delete scopeHost[HANDLER_SCOPE_SYMBOL];
1691
+ }
1110
1692
  };
1111
1693
  }