ocuclaw 1.3.2 → 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.
- package/dist/runtime/glasses-backpressure-latch.js +115 -0
- package/dist/runtime/relay-core.js +75 -0
- package/dist/runtime/relay-service.js +32 -0
- package/dist/runtime/relay-worker-supervisor.js +8 -0
- package/dist/runtime/relay-worker-transport.js +10 -1
- package/dist/tools/glasses-ui-cron.js +50 -0
- package/dist/tools/glasses-ui-paint-floor.js +23 -1
- package/dist/tools/glasses-ui-surfaces.js +361 -34
- package/dist/tools/glasses-ui-tool.js +594 -86
- package/dist/tools/glasses-ui-voicemail.js +299 -0
- package/dist/tools/glasses-ui-wake.js +262 -0
- package/dist/version.js +2 -2
- package/package.json +2 -2
- package/skills/glasses-ui/SKILL.md +19 -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
|
-
|
|
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,
|
|
@@ -549,16 +709,52 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
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
|
-
|
|
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 =
|
|
755
|
+
let merged = outcome;
|
|
560
756
|
capturedCronOutcome.set(msg.surfaceId, (cronOutcome) => { merged = cronOutcome; });
|
|
561
|
-
cronEngine.stop(msg.surfaceId,
|
|
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
|
-
|
|
573
|
-
|
|
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
|
-
|
|
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,6 +835,21 @@ 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
|
+
|
|
613
853
|
// params.depth is the execute-level RUN-CALL ordinal (resets at agent_end).
|
|
614
854
|
// It is used ONLY as the "first render of this run" signal for stale-stack
|
|
615
855
|
// reaping below — it must NEVER reach the wire. The wire depth is derived
|
|
@@ -641,12 +881,27 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
641
881
|
// target from the session's current top: patch/replace reuse the top id
|
|
642
882
|
// (re-attach in place), push mints a child + pauses the parent cron, the
|
|
643
883
|
// first render mints a root. This is the single place a surfaceId is bound.
|
|
884
|
+
const stackDepthBeforeAttach = surfaceStore.stackDepth(sessionKey);
|
|
644
885
|
const applied = surfaceStore.applyRender(sessionKey, {
|
|
645
886
|
update,
|
|
646
887
|
kind: validation.spec.kind,
|
|
647
888
|
});
|
|
648
889
|
const surfaceId = applied.surfaceId;
|
|
649
|
-
|
|
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
|
+
});
|
|
650
905
|
// Re-attach flush (last-wins queue / latched exit): only for a patch/replace
|
|
651
906
|
// onto an already-attached surface (the visible_awaiting_agent window can
|
|
652
907
|
// only exist on a surface that previously resolved a call). A fresh
|
|
@@ -666,6 +921,12 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
666
921
|
surfaceStore.exit(sessionKey);
|
|
667
922
|
return promise;
|
|
668
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
|
+
}
|
|
669
930
|
}
|
|
670
931
|
|
|
671
932
|
// The wire depth is the TRUE stack depth (entry count) after applyRender:
|
|
@@ -678,10 +939,20 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
678
939
|
// paint-floor coalescer as a leading-edge render sentinel so it shares the
|
|
679
940
|
// single send chokepoint (a render supersedes any queued field patch for
|
|
680
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);
|
|
681
948
|
paintFloor.enqueue({
|
|
682
949
|
surfaceId,
|
|
683
950
|
sessionKey,
|
|
684
|
-
|
|
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) },
|
|
685
956
|
});
|
|
686
957
|
|
|
687
958
|
// Live-refresh path: kick off the cron in parallel. A `patch` onto an
|
|
@@ -734,6 +1005,14 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
734
1005
|
const capture = capturedCronOutcome.get(surfaceId);
|
|
735
1006
|
if (capture) capture(cronOutcome);
|
|
736
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
|
+
};
|
|
737
1016
|
// Settle the in-flight call; if none is pending (the surface is in
|
|
738
1017
|
// visible_awaiting_agent after a nonterminal user action and the
|
|
739
1018
|
// cron then hit its own terminal — recipe_failed / maxDuration
|
|
@@ -741,42 +1020,119 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
741
1020
|
// onReattached returns discarded_for_exit and tears the surface
|
|
742
1021
|
// down. Without this fallback the dead-cron surface would persist.
|
|
743
1022
|
// Symmetric with the onGlassesUiResult terminal path.
|
|
744
|
-
if (!surfaceStore.resolve(surfaceId,
|
|
745
|
-
surfaceStore.queueEvent(surfaceId,
|
|
1023
|
+
if (!surfaceStore.resolve(surfaceId, stamped)) {
|
|
1024
|
+
surfaceStore.queueEvent(surfaceId, stamped);
|
|
746
1025
|
}
|
|
747
1026
|
}
|
|
748
1027
|
},
|
|
749
1028
|
});
|
|
750
1029
|
}
|
|
751
1030
|
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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.
|
|
755
1112
|
const timeoutMs = Number.isFinite(params.timeoutMs)
|
|
756
1113
|
? params.timeoutMs
|
|
757
1114
|
: resolveHandlerTimeoutMs();
|
|
758
1115
|
if (!refreshValidated && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
759
|
-
const setTimeoutFn =
|
|
760
|
-
deps && typeof deps.setTimeout === "function" ? deps.setTimeout : setTimeout;
|
|
761
|
-
const clearTimeoutFn =
|
|
762
|
-
deps && typeof deps.clearTimeout === "function" ? deps.clearTimeout : clearTimeout;
|
|
763
1116
|
const handle = setTimeoutFn(() => {
|
|
764
1117
|
// Resolves only if the entry is still pending; surfaceStore.resolve is a
|
|
765
1118
|
// no-op when the client already produced a real outcome. timeout is
|
|
766
1119
|
// terminal, so it also moves the surface to `exiting`.
|
|
767
|
-
surfaceStore.resolve(surfaceId, { result: "timeout", timeout_ms: timeoutMs });
|
|
1120
|
+
surfaceStore.resolve(surfaceId, { result: "timeout", timeout_ms: timeoutMs, origin: "system" });
|
|
768
1121
|
}, timeoutMs);
|
|
769
|
-
|
|
770
|
-
clearTimeoutFn(handle);
|
|
771
|
-
return outcome;
|
|
772
|
-
});
|
|
1122
|
+
cleanups.push(() => clearTimeoutFn(handle));
|
|
773
1123
|
}
|
|
1124
|
+
|
|
774
1125
|
// Decoupled lifecycle: the call resolves on the user's action (via
|
|
775
|
-
// onGlassesUiResult)
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
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
|
+
});
|
|
780
1136
|
}
|
|
781
1137
|
|
|
782
1138
|
// Per-session last-seen depth, used only to distinguish push (depth up) from
|
|
@@ -788,7 +1144,8 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
788
1144
|
// parent (Spike B: the plugin owns the resume target).
|
|
789
1145
|
const navDepthBySession = new Map(); // sessionKey -> lastSeenDepth
|
|
790
1146
|
|
|
791
|
-
function handleNavEvent(
|
|
1147
|
+
function handleNavEvent(rawSessionKey, ev) {
|
|
1148
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
792
1149
|
const newDepth = Number.isFinite(ev.depth) ? Math.max(1, Math.floor(ev.depth)) : 1;
|
|
793
1150
|
const lastDepth = navDepthBySession.get(sessionKey) || surfaceStore.stackDepth(sessionKey) || 1;
|
|
794
1151
|
const storeDepthBefore = surfaceStore.stackDepth(sessionKey);
|
|
@@ -821,6 +1178,12 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
821
1178
|
}
|
|
822
1179
|
// Push (newDepth > lastDepth) is already reflected in the store by the
|
|
823
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
|
+
}
|
|
824
1187
|
emitLifecycle("nav_reconcile", "debug", {
|
|
825
1188
|
sessionKey,
|
|
826
1189
|
evSurfaceId: ev.surfaceId,
|
|
@@ -840,7 +1203,8 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
840
1203
|
// without resolving, so it must NOT run before the drain or the pending
|
|
841
1204
|
// promises would hang. Shared by the public drainSession (agent_end /
|
|
842
1205
|
// disconnect) and the stale-stack reap in runDynamicUi (B3).
|
|
843
|
-
function reapSession(
|
|
1206
|
+
function reapSession(rawSessionKey, outcome) {
|
|
1207
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
844
1208
|
cronEngine.stopAllForSession(sessionKey, outcome);
|
|
845
1209
|
const reaped = surfaceStore.drainSession(sessionKey, outcome);
|
|
846
1210
|
surfaceStore.exit(sessionKey); // clear the stack (crons already stopped)
|
|
@@ -849,11 +1213,19 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
849
1213
|
}
|
|
850
1214
|
|
|
851
1215
|
return {
|
|
1216
|
+
storeId,
|
|
852
1217
|
runDynamicUi,
|
|
853
1218
|
handleNavEvent,
|
|
854
1219
|
drainSession(sessionKey, outcome) {
|
|
855
1220
|
return reapSession(sessionKey, outcome);
|
|
856
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);
|
|
1228
|
+
},
|
|
857
1229
|
drainAll(outcome) {
|
|
858
1230
|
cronEngine.stopAll(outcome);
|
|
859
1231
|
const reaped = surfaceStore.drainAll(outcome);
|
|
@@ -863,6 +1235,21 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
863
1235
|
navDepthBySession.clear();
|
|
864
1236
|
return reaped;
|
|
865
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
|
+
},
|
|
866
1253
|
isCronActive(surfaceId) {
|
|
867
1254
|
return cronEngine.isActive(surfaceId);
|
|
868
1255
|
},
|
|
@@ -873,6 +1260,17 @@ export function createGlassesUiToolHandler(deps) {
|
|
|
873
1260
|
surfaceStackDepth(sessionKey) {
|
|
874
1261
|
return surfaceStore.stackDepth(sessionKey);
|
|
875
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
|
+
},
|
|
876
1274
|
sessionForSurface(surfaceId) {
|
|
877
1275
|
return surfaceStore.sessionForSurface(surfaceId);
|
|
878
1276
|
},
|
|
@@ -888,8 +1286,8 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
|
|
|
888
1286
|
" short detail body (≤200 chars) shown as the user",
|
|
889
1287
|
" scrolls; use when options need a 1-2 sentence",
|
|
890
1288
|
" compare-before-choosing detail.",
|
|
891
|
-
"The call
|
|
892
|
-
"
|
|
1289
|
+
"The call carries one one-shot listen window. result is one of: selected,",
|
|
1290
|
+
"back, dismissed, window_expired, timeout, recipe_failed, glasses_disconnected.",
|
|
893
1291
|
"",
|
|
894
1292
|
"Optional params:",
|
|
895
1293
|
" refresh — make the surface self-update on a timer (e.g. live host stats via",
|
|
@@ -899,6 +1297,10 @@ export const GLASSES_UI_TOOL_DESCRIPTION = [
|
|
|
899
1297
|
" fields; cron keeps ticking), \"replace\" (default; swap content in",
|
|
900
1298
|
" place, no back-target), \"push\" (stack a child screen; the parent",
|
|
901
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).",
|
|
902
1304
|
"",
|
|
903
1305
|
"Before authoring any refreshing/live surface, per-item detail list, or",
|
|
904
1306
|
"multi-screen flow, load the \"glasses-ui\" skill — it is the authoring source",
|
|
@@ -933,24 +1335,40 @@ function getSharedDepthMap() {
|
|
|
933
1335
|
return m;
|
|
934
1336
|
}
|
|
935
1337
|
|
|
936
|
-
|
|
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 = {}) {
|
|
937
1351
|
if (!api || typeof api.registerTool !== "function") {
|
|
938
1352
|
throw new Error("registerGlassesUiTool requires api.registerTool");
|
|
939
1353
|
}
|
|
940
1354
|
if (!service) {
|
|
941
1355
|
throw new Error("registerGlassesUiTool requires the OcuClaw relay service");
|
|
942
1356
|
}
|
|
1357
|
+
const scopeHost =
|
|
1358
|
+
opts && opts.scopeHost && typeof opts.scopeHost === "object" ? opts.scopeHost : globalThis;
|
|
943
1359
|
|
|
944
1360
|
const depthBySession = getSharedDepthMap();
|
|
945
1361
|
|
|
946
|
-
function nextDepth(
|
|
1362
|
+
function nextDepth(rawSessionKey) {
|
|
1363
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
947
1364
|
const prev = depthBySession.get(sessionKey) || 0;
|
|
948
1365
|
const next = prev + 1;
|
|
949
1366
|
depthBySession.set(sessionKey, next);
|
|
950
1367
|
return next;
|
|
951
1368
|
}
|
|
952
1369
|
|
|
953
|
-
function resetDepth(
|
|
1370
|
+
function resetDepth(rawSessionKey) {
|
|
1371
|
+
const sessionKey = normalizeGlassesSessionKey(rawSessionKey);
|
|
954
1372
|
if (sessionKey) {
|
|
955
1373
|
depthBySession.delete(sessionKey);
|
|
956
1374
|
} else {
|
|
@@ -1002,7 +1420,16 @@ export function registerGlassesUiTool(api, service) {
|
|
|
1002
1420
|
return "";
|
|
1003
1421
|
}
|
|
1004
1422
|
|
|
1005
|
-
|
|
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({
|
|
1006
1433
|
relay: {
|
|
1007
1434
|
sendGlassesUiRender: (msg) => service.sendGlassesUiRender(msg),
|
|
1008
1435
|
sendGlassesUiSurfaceUpdate: (msg) => service.sendGlassesUiSurfaceUpdate(msg),
|
|
@@ -1068,49 +1495,68 @@ export function registerGlassesUiTool(api, service) {
|
|
|
1068
1495
|
return false;
|
|
1069
1496
|
}
|
|
1070
1497
|
},
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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;
|
|
1083
1512
|
}
|
|
1084
|
-
}
|
|
1085
|
-
}
|
|
1513
|
+
},
|
|
1514
|
+
}) : scopeRecord.handler;
|
|
1086
1515
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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
|
|
1106
1554
|
}
|
|
1107
|
-
|
|
1108
|
-
// observability must never break the nav path
|
|
1555
|
+
return;
|
|
1109
1556
|
}
|
|
1110
|
-
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
});
|
|
1557
|
+
handler.handleNavEvent(sessionKey, ev);
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1114
1560
|
}
|
|
1115
1561
|
|
|
1116
1562
|
function resolveDedicatedEvenAiSessionKey() {
|
|
@@ -1140,14 +1586,19 @@ export function registerGlassesUiTool(api, service) {
|
|
|
1140
1586
|
name: "render_glasses_ui",
|
|
1141
1587
|
description: GLASSES_UI_TOOL_DESCRIPTION,
|
|
1142
1588
|
parameters: glassesUiParametersSchema,
|
|
1143
|
-
async execute(_toolCallId, params) {
|
|
1144
|
-
const resolvedSessionKey = factorySessionKey || "main";
|
|
1589
|
+
async execute(_toolCallId, params, signal) {
|
|
1590
|
+
const resolvedSessionKey = normalizeGlassesSessionKey(factorySessionKey || "main");
|
|
1145
1591
|
const depth = nextDepth(resolvedSessionKey);
|
|
1146
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).
|
|
1147
1597
|
const outcome = await handler.runDynamicUi({
|
|
1148
1598
|
sessionKey: resolvedSessionKey,
|
|
1149
1599
|
depth,
|
|
1150
1600
|
spec: params,
|
|
1601
|
+
signal,
|
|
1151
1602
|
});
|
|
1152
1603
|
return {
|
|
1153
1604
|
content: [{ type: "text", text: JSON.stringify(outcome) }],
|
|
@@ -1164,22 +1615,79 @@ export function registerGlassesUiTool(api, service) {
|
|
|
1164
1615
|
);
|
|
1165
1616
|
|
|
1166
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
|
+
|
|
1167
1636
|
api.on("agent_end", (_event, ctx) => {
|
|
1168
1637
|
const sessionKey = ctx && typeof ctx.sessionKey === "string" ? ctx.sessionKey : null;
|
|
1169
|
-
//
|
|
1170
|
-
// depth counter
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
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.
|
|
1174
1647
|
if (sessionKey) {
|
|
1175
|
-
handler.
|
|
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);
|
|
1176
1667
|
}
|
|
1177
1668
|
resetDepth(sessionKey);
|
|
1178
1669
|
});
|
|
1179
1670
|
}
|
|
1180
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;
|
|
1181
1680
|
return function dispose() {
|
|
1681
|
+
if (disposedThisContext) return;
|
|
1682
|
+
disposedThisContext = true;
|
|
1683
|
+
scopeRecord.refs -= 1;
|
|
1684
|
+
if (scopeRecord.refs > 0) return;
|
|
1182
1685
|
handler.drainAll({ result: "preempted" });
|
|
1183
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
|
+
}
|
|
1184
1692
|
};
|
|
1185
1693
|
}
|