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.
- package/dist/config/runtime-config-session-title-model.test.js +22 -0
- package/dist/config/runtime-config.js +7 -1
- package/dist/domain/glasses-display-system-prompt.js +52 -0
- package/dist/domain/glasses-display-system-prompt.test.js +44 -0
- package/dist/domain/glasses-ui-system-prompt.js +6 -22
- package/dist/domain/glasses-ui-system-prompt.test.js +13 -0
- package/dist/domain/prompt-channel-fragments.js +32 -0
- package/dist/domain/prompt-channel-fragments.test.js +70 -0
- package/dist/gateway/gateway-timing-ledger.js +15 -3
- package/dist/gateway/openclaw-client.js +80 -3
- package/dist/index.js +22 -0
- package/dist/runtime/channel-two-hook.js +36 -0
- package/dist/runtime/container-env.js +41 -0
- package/dist/runtime/display-toggle-states.js +98 -0
- package/dist/runtime/glasses-backpressure-latch.js +115 -0
- package/dist/runtime/register-session-title-distiller.js +100 -0
- package/dist/runtime/relay-core.js +284 -33
- package/dist/runtime/relay-service.js +152 -13
- package/dist/runtime/relay-worker-entry.js +26 -0
- package/dist/runtime/relay-worker-supervisor.js +51 -2
- package/dist/runtime/relay-worker-transport.js +51 -1
- package/dist/runtime/session-service.js +136 -12
- package/dist/runtime/session-title-distiller-budget.js +36 -0
- package/dist/runtime/session-title-distiller-helpers.js +130 -0
- package/dist/runtime/session-title-distiller.js +354 -0
- package/dist/runtime/session-title-record.js +21 -0
- package/dist/runtime/stable-prompt-snapshot.js +119 -0
- package/dist/tools/glasses-ui-cron.js +59 -3
- package/dist/tools/glasses-ui-paint-floor.js +33 -4
- package/dist/tools/glasses-ui-surfaces.js +369 -35
- package/dist/tools/glasses-ui-tool-description.test.js +16 -0
- package/dist/tools/glasses-ui-tool.js +662 -80
- package/dist/tools/glasses-ui-voicemail.js +299 -0
- package/dist/tools/glasses-ui-wake.js +262 -0
- package/dist/tools/session-title-tool.js +14 -76
- package/dist/tools/session-title-tool.test.js +53 -0
- package/dist/version.js +2 -2
- package/openclaw.plugin.json +9 -0
- package/package.json +4 -3
- 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
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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,
|
|
717
|
-
surfaceStore.queueEvent(surfaceId,
|
|
1023
|
+
if (!surfaceStore.resolve(surfaceId, stamped)) {
|
|
1024
|
+
surfaceStore.queueEvent(surfaceId, stamped);
|
|
718
1025
|
}
|
|
719
1026
|
}
|
|
720
1027
|
},
|
|
721
1028
|
});
|
|
722
1029
|
}
|
|
723
1030
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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)
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
//
|
|
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(
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
|
843
|
-
"
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
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
|
-
//
|
|
1096
|
-
// depth counter
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
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.
|
|
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
|
}
|