@ggui-ai/iframe-runtime 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/call-tool-unwrap.d.ts +29 -0
  2. package/dist/call-tool-unwrap.js +56 -0
  3. package/dist/channels/props-update.d.ts +19 -0
  4. package/dist/channels/props-update.js +14 -1
  5. package/dist/compiled-validators.d.ts +16 -7
  6. package/dist/compiled-validators.js +41 -8
  7. package/dist/events-polling.d.ts +123 -1
  8. package/dist/events-polling.js +181 -53
  9. package/dist/iframe-runtime.js +65 -64
  10. package/dist/iframe-runtime.meta.json +1 -1
  11. package/dist/mcp-app-iframe-host.d.ts +1 -1
  12. package/dist/meta-parse.d.ts +13 -0
  13. package/dist/meta-parse.js +41 -0
  14. package/dist/observability.d.ts +1 -1
  15. package/dist/provisional-renderer.d.ts +1 -1
  16. package/dist/provisional-renderer.js +1 -1
  17. package/dist/react-renderer.d.ts +13 -1
  18. package/dist/react-renderer.js +87 -17
  19. package/dist/registry-subscribe.d.ts +42 -3
  20. package/dist/registry-subscribe.js +33 -10
  21. package/dist/render-item.d.ts +9 -0
  22. package/dist/render-item.js +7 -1
  23. package/dist/runtime-telemetry.d.ts +53 -0
  24. package/dist/runtime-telemetry.js +74 -0
  25. package/dist/runtime.d.ts +84 -0
  26. package/dist/runtime.js +522 -100
  27. package/dist/server.d.ts +19 -0
  28. package/dist/server.js +19 -0
  29. package/dist/shims/app-components.js +1 -0
  30. package/dist/shims/components.js +1 -0
  31. package/dist/shims/compositions.js +1 -0
  32. package/dist/shims/design.js +1 -0
  33. package/dist/shims/gadgets.js +1 -0
  34. package/dist/shims/interact.js +1 -0
  35. package/dist/shims/jsx-runtime.js +1 -0
  36. package/dist/shims/primitives.js +1 -0
  37. package/dist/shims/react-dom.js +1 -0
  38. package/dist/shims/react.js +1 -0
  39. package/dist/shims/templates.js +1 -0
  40. package/dist/shims/tokens.js +1 -0
  41. package/dist/shims/wire.js +1 -0
  42. package/dist/system-cards/ProtocolProbeCard.js +1 -1
  43. package/dist/system-cards/UiFeedbackCard.d.ts +1 -1
  44. package/dist/system-cards/UiFeedbackCard.js +1 -1
  45. package/dist/ui-feedback-chrome.js +7 -4
  46. package/dist/zod-jitless.d.ts +1 -0
  47. package/dist/zod-jitless.js +18 -0
  48. package/package.json +8 -7
package/dist/runtime.js CHANGED
@@ -1,5 +1,34 @@
1
- import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, } from '@ggui-ai/protocol/integrations/mcp-apps';
2
- import { parseMetaFromGlobal, parseMetaFromToolResult, } from './meta-parse.js';
1
+ /**
2
+ * `@ggui-ai/iframe-runtime` iframe runtime entry.
3
+ *
4
+ * This is the file esbuild bundles into `dist/iframe-runtime.js`. The
5
+ * thin-shell HTML loads it via `<script type="module" src=".../iframe-runtime.js">`;
6
+ * on import the side-effects below take over: build a status DOM,
7
+ * postMessage `ui/initialize` to the parent, parse the bootstrap, open
8
+ * the WebSocket, run the version handshake, and mount the render
9
+ * placeholder.
10
+ *
11
+ * Boot sequence:
12
+ * - Boot from the `_meta["ai.ggui/render"]` slice received via
13
+ * `ui/initialize`.
14
+ * - Open the WebSocket, run the subscribe handshake.
15
+ * - Mount the render — either a structural placeholder or, when
16
+ * the renderer hooks are wired, a React mount of `componentCode`.
17
+ *
18
+ * The runtime advertises its build version via a post-`ui/initialize`
19
+ * notification (`ggui:renderer-ready`).
20
+ *
21
+ * Failure surfacing: every parse / handshake failure goes to two
22
+ * places — (1) the in-iframe status line (operator-visible) and
23
+ * (2) a parent-bound `postMessage({type:'ggui:bootstrap-failed', ...})`.
24
+ * The post-message envelope is consumed by the `<McpAppIframe>` host
25
+ * wrapper, which routes it to the host's `onError` callback.
26
+ */
27
+ // FIRST import on purpose — declares zod jitless before any
28
+ // schema-defining dependency initializes (see zod-jitless.ts).
29
+ import './zod-jitless.js';
30
+ import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, } from '@ggui-ai/protocol/integrations/mcp-apps';
31
+ import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
3
32
  import { projectHostContext } from '@ggui-ai/protocol';
4
33
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
5
34
  import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
@@ -7,9 +36,11 @@ import { buildRootWireConfig, StreamBus, } from './wire-config.js';
7
36
  import { createChannelTransportRouter, } from './channel-transport.js';
8
37
  import { RelayIncapableError } from './relay-incapability.js';
9
38
  import { ChannelRegistry } from '@ggui-ai/live-channel';
39
+ import { createTelemetrySink, } from './runtime-telemetry.js';
10
40
  import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createPropsUpdateHandler, createRenderHandler, } from './channels/index.js';
11
41
  import { connectViaRegistry, } from './registry-subscribe.js';
12
- import { buildEventsPolling } from './events-polling.js';
42
+ import { buildBridgePolling, buildEventsPolling, createSequenceCursor, } from './events-polling.js';
43
+ import { unwrapCallToolResult } from './call-tool-unwrap.js';
13
44
  import { ensureStatusDom, setStatus, setConnectedStatus, } from './status-dom.js';
14
45
  import { mergeReservedValidators, setActiveValidatorSet, } from './validation.js';
15
46
  import { loadCompiledValidatorsFromUrl } from './compiled-validators.js';
@@ -129,6 +160,32 @@ export function setCurrentApp(app) {
129
160
  function getCurrentApp() {
130
161
  return currentApp;
131
162
  }
163
+ /**
164
+ * The host's announced color mode, read from the connected App's
165
+ * pre-merged hostContext (`ui/initialize` result, kept current by the
166
+ * App across `host-context-changed`). `undefined` when no App is
167
+ * connected, the host sent no `theme`, or it sent something outside
168
+ * the spec's `'light' | 'dark'` literals (dropped, never guessed —
169
+ * the same tolerant posture as `projectHostContext`).
170
+ *
171
+ * This is the INPUT leg of host-theme adaptation
172
+ * (rnd/gen-ui/beauty/experiments/002-host-theme-adaptation.md, ggui#551):
173
+ * `hostContext.theme` alone reaches only `data-theme` +
174
+ * `color-scheme` via `applyDocumentTheme` — nothing in the token
175
+ * pipeline keys off it, so a dark host painted the light ladder
176
+ * (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
177
+ * `buildOpts` consumes this ONLY when the slice stamps no `themeMode`:
178
+ * every operator layer (live provider > per-render override > app
179
+ * default > server default) still wins by being stamped; the host
180
+ * fills the gap where no operator had an opinion.
181
+ *
182
+ * @internal — exported for unit tests (`setCurrentApp` injects the
183
+ * App); production caller is `buildOpts` inside `bootSequence`.
184
+ */
185
+ export function hostAnnouncedThemeMode() {
186
+ const theme = currentApp?.getHostContext()?.theme;
187
+ return theme === 'light' || theme === 'dark' ? theme : undefined;
188
+ }
132
189
  /**
133
190
  * @internal — exported for unit tests to reset module state between
134
191
  * scenarios.
@@ -312,7 +369,16 @@ export async function bootSequence(opts) {
312
369
  parsed = inline;
313
370
  }
314
371
  else {
315
- const fromToolResult = await toolResultPromise;
372
+ // Tier 1.5 — the read-plane door (ggui#537). A pre-boot tool
373
+ // result named the view but carried no material; now that the
374
+ // App is connected, resolve the locator through the host's
375
+ // `resources/read` proxy. One round trip, ahead of the Tier 2
376
+ // wait; a miss falls through to Tier 2 unchanged (its listener
377
+ // is already armed and resolves locator-only arrivals too).
378
+ const viaDoor = opts.preResolvedLocator !== undefined
379
+ ? await resolveMetaViaReadDoor(app, opts.preResolvedLocator)
380
+ : null;
381
+ const fromToolResult = viaDoor ?? (await toolResultPromise);
316
382
  if (fromToolResult !== null) {
317
383
  parsed = { ok: true, meta: fromToolResult };
318
384
  }
@@ -386,16 +452,24 @@ export async function bootSequence(opts) {
386
452
  // In-iframe UI-feedback affordance (ggui#244) — mounted adjacent to
387
453
  // the session root, gated inside the helper on `window.parent !==
388
454
  // window` (top-level `/r/<shortCode>` tabs have no `ggui:observe`
389
- // egress, so they get NO affordance). Interaction emits a
390
- // `ui-feedback` ObservabilityEvent through the boot path's emitter;
391
- // when no emitter was injected the postMessage-to-parent default
392
- // still gives the mounted affordance a live sink (the gate already
393
- // proved a parent exists). Fire-and-forget: chrome never blocks or
394
- // fails the boot.
395
- void mountUiFeedbackChrome(doc, {
396
- emit: onObserve ?? postObservabilityToParent,
397
- sessionId: meta.sessionId,
398
- });
455
+ // egress, so they get NO affordance) AND here on a LIVE SINK: an
456
+ // injected `onObserve` emitter, or a first-party embed host — the
457
+ // only parent that consumes the `ggui:observe` envelope
458
+ // (mcp-app-iframe-host answers `ui/initialize` with exactly this
459
+ // hostInfo name). Third-party MCP-Apps hosts (claude.ai, ChatGPT,
460
+ // Claude Desktop) drop the envelope on the floor, so rendering the
461
+ // affordance there is a dead control — the first #471 claude.ai
462
+ // retest surfaced it as unstyled clutter that could never deliver
463
+ // feedback anywhere. Fire-and-forget: chrome never blocks or fails
464
+ // the boot.
465
+ const feedbackSinkLive = onObserve !== undefined ||
466
+ app.getHostVersion()?.name === 'ggui-iframe-runtime-embed-host';
467
+ if (feedbackSinkLive) {
468
+ void mountUiFeedbackChrome(doc, {
469
+ emit: onObserve ?? postObservabilityToParent,
470
+ sessionId: meta.sessionId,
471
+ });
472
+ }
399
473
  setStatus(refs, `Connecting to ${meta.sessionId}…`, 'connecting');
400
474
  // Boot-without-renderer path: we still need a ChannelRegistry to
401
475
  // receive frames, because the registry is the only dispatch
@@ -471,6 +545,27 @@ export async function bootSequence(opts) {
471
545
  meta.wsUrl.length > 0 &&
472
546
  typeof meta.wsToken === 'string' &&
473
547
  meta.wsToken.length > 0;
548
+ // Transport-telemetry sink — the iframe's self-report channel (see
549
+ // runtime-telemetry.ts). Created for EVERY boot with an App bridge;
550
+ // the first batch carries the boot-path decision so operators can
551
+ // see which ladder (if any) this mount composed, even on hosts
552
+ // whose console and network are unreachable.
553
+ const telemetryApp = getCurrentApp();
554
+ const telemetry = telemetryApp !== null
555
+ ? createTelemetrySink({
556
+ sessionId: meta.sessionId,
557
+ callTool: (args) => telemetryApp.callServerTool(args),
558
+ })
559
+ : null;
560
+ currentTelemetrySink = telemetry;
561
+ // Fresh boot ⇒ not superseded (matters only for test re-runs that
562
+ // reuse the module; a real iframe boots once).
563
+ mountSuperseded = false;
564
+ telemetry?.record('boot.path', JSON.stringify({
565
+ hasStaticContent,
566
+ hasLiveTrio,
567
+ bridgeCapable: telemetryApp !== null,
568
+ }));
474
569
  // ── Static seed mount — zero-round-trip paint, no WS required. ──────
475
570
  // The ONLY mount path for spec-compliant MCP-Apps hosts that expose no
476
571
  // ggui live channel (claude.ai / ChatGPT / Claude Desktop), AND the
@@ -511,17 +606,28 @@ export async function bootSequence(opts) {
511
606
  emitCodeReadyOnce();
512
607
  }
513
608
  }
514
- // ── No live channel → static-only host. ────────────────────────────
609
+ // ── No live channel → static-only host (unless bridge-capable). ─────
515
610
  // The static seed already painted (or there was nothing to paint).
516
611
  // A bootstrap with neither static content nor a live trio is a
517
612
  // misconfiguration — surface a typed boot failure.
518
- if (!hasLiveTrio) {
519
- if (mountedRender === null) {
520
- const message = 'bootstrap carries neither static content (codeUrl/codeB64/kind) nor a live trio (wsUrl/wsToken)';
521
- setStatus(refs, message, 'error');
522
- emitBootFailure('MISSING_META_GGUI_BOOTSTRAP', message);
523
- return { ok: false, mountedRender };
524
- }
613
+ //
614
+ // #471 round-6 live finding: when the host exposes an App bridge
615
+ // (tools/call postMessage — claude.ai, every MCP Apps host), a
616
+ // trio-less static mount must NOT stop here — the bridge-pull
617
+ // terminal rung is exactly its subscription. Fall through to the
618
+ // subscribe below, which binds trio-less (the registry starts the
619
+ // ladder at its tail). Gating the terminal rung behind the WS
620
+ // trio's presence disabled it on precisely the host class it was
621
+ // built for.
622
+ const staticOnlyNoBridge = !hasLiveTrio && getCurrentApp() === null;
623
+ if (!hasLiveTrio && mountedRender === null) {
624
+ const message = 'bootstrap carries neither static content (codeUrl/codeB64/kind) nor a live trio (wsUrl/wsToken)';
625
+ setStatus(refs, message, 'error');
626
+ emitBootFailure('MISSING_META_GGUI_BOOTSTRAP', message);
627
+ return { ok: false, mountedRender };
628
+ }
629
+ if (staticOnlyNoBridge) {
630
+ telemetry?.record('boot.static_only_no_bridge');
525
631
  // Host-context wiring for the no-WS static mount (claude.ai / ChatGPT
526
632
  // / Claude Desktop). The INITIAL host theme/style/fonts were already
527
633
  // applied at boot (applyHostContextStyling, above); attach the
@@ -543,12 +649,42 @@ export async function bootSequence(opts) {
543
649
  return { ok: true, mountedRender };
544
650
  }
545
651
  // ── Live-channel subscribe (conditional enhancement). ──────────────
652
+ // Failover-ladder rung URLs (WS → SSE → polling → bridge-pull),
653
+ // hoisted so the spreads below stay exact-optional-clean without
654
+ // non-null assertions. `sseUrl` is the server-stamped wsToken-gated
655
+ // `/api/sessions/<id>/stream` URL; SSE `id:` = ledger sequence = the
656
+ // SAME cursor model as the WS `sinceSequence` replay and polling
657
+ // ticks — switching rungs does not lose events.
658
+ const sseUrl = typeof meta.sseUrl === 'string' && meta.sseUrl.length > 0
659
+ ? meta.sseUrl
660
+ : undefined;
661
+ const pollingBaseUrl = typeof meta.pollingUrl === 'string' && meta.pollingUrl.length > 0
662
+ ? meta.pollingUrl
663
+ : undefined;
664
+ // One shared cursor for the whole ladder — SSE deliveries advance it
665
+ // (via onSequence), the polling + bridge descriptors read it per
666
+ // tick, so a demotion down the ladder resumes from the last
667
+ // delivered event instead of re-replaying from the boot snapshot.
668
+ // Created when ANY fallback rung exists — including the bridge-pull
669
+ // rung, whose only precondition is a connected App handle (the
670
+ // universal floor: a CSP-jailed host stamps no sseUrl/pollingUrl,
671
+ // yet its `tools/call` postMessage bridge still reaches the ledger).
672
+ const bridgeApp = getCurrentApp();
673
+ const ladderCursor = sseUrl !== undefined || pollingBaseUrl !== undefined || bridgeApp !== null
674
+ ? createSequenceCursor(meta.lastSequence ?? 0)
675
+ : undefined;
546
676
  let handle;
547
677
  try {
548
678
  handle = await connectFn({
549
679
  meta,
550
680
  registry: activeRegistry,
681
+ // Live-channel diagnostics tap — every channel_* event the
682
+ // transports emit (failover swaps, polling budget exhaustion,
683
+ // SSE lifecycle) lands in the telemetry buffer. Without this the
684
+ // ladder's story on sandboxed hosts is unobservable.
685
+ ...(telemetry !== null ? { logger: telemetry.channelLogger } : {}),
551
686
  onStatusChange: (status) => {
687
+ telemetry?.record(`status.${status}`);
552
688
  setStatus(refs, status === 'connected' ? 'Connected.' : `Connection ${status}…`, status);
553
689
  // Propagate WS status to the per-channel transport router so
554
690
  // it can flip WS-bound channels into polling
@@ -573,21 +709,48 @@ export async function bootSequence(opts) {
573
709
  onResubscribeAck: (ack) => {
574
710
  void applyAck(ack);
575
711
  },
576
- // R7 — registry-level events-polling fallback. Composed once at
577
- // bind time from `meta.pollingUrl` (server-stamped wsToken-
578
- // gated /api/sessions/<id>/events URL) + `meta.lastSequence`
579
- // (cursor seed). FailoverHandle uses this when WS reaches
580
- // 'failed'; absent → no polling fallback (WS-only mode).
712
+ // Failover-ladder fallback rungs (WS → SSE → polling →
713
+ // bridge-pull), composed once at bind time from the
714
+ // server-stamped wsToken-gated URLs + `meta.lastSequence`
715
+ // (shared cursor seed). FailoverHandle descends a rung on each
716
+ // 'failed'; the bridge rung is terminal (never emits 'failed').
581
717
  //
582
718
  // Same cursor model as the WS subscribe `sinceSequence` replay
583
719
  // path — switching transports does not lose events.
584
- ...(typeof meta.pollingUrl === 'string' && meta.pollingUrl.length > 0
720
+ ...(sseUrl !== undefined && ladderCursor !== undefined
721
+ ? {
722
+ sse: {
723
+ url: sseUrl,
724
+ initialSinceSequence: ladderCursor.get(),
725
+ onSequence: (seq) => {
726
+ ladderCursor.advance(seq);
727
+ },
728
+ },
729
+ }
730
+ : {}),
731
+ ...(pollingBaseUrl !== undefined && ladderCursor !== undefined
585
732
  ? {
586
733
  polling: buildEventsPolling({
587
- baseUrl: meta.pollingUrl,
588
- ...(meta.lastSequence !== undefined
589
- ? { initialSinceSequence: meta.lastSequence }
590
- : {}),
734
+ baseUrl: pollingBaseUrl,
735
+ cursor: ladderCursor,
736
+ }),
737
+ }
738
+ : {}),
739
+ // Bridge-pull terminal rung — composed whenever the App handle
740
+ // is bound (it always is by this point in the boot: connectApp
741
+ // succeeded and setCurrentApp ran above), INDEPENDENT of
742
+ // sseUrl/pollingUrl presence. This is the universal floor for
743
+ // CSP-jailed hosts (claude.ai) where the iframe has no network
744
+ // path at all: the ledger is pulled via `ggui_runtime_pull`
745
+ // `tools/call`s over the host's postMessage bridge. Shares the
746
+ // SAME ladder cursor — a demotion into the bridge resumes from
747
+ // whatever the rungs above already delivered.
748
+ ...(bridgeApp !== null && ladderCursor !== undefined
749
+ ? {
750
+ bridge: buildBridgePolling({
751
+ callTool: (name, args) => bridgeApp.callServerTool({ name, arguments: args }),
752
+ sessionId: meta.sessionId,
753
+ cursor: ladderCursor,
591
754
  }),
592
755
  }
593
756
  : {}),
@@ -626,12 +789,19 @@ export async function bootSequence(opts) {
626
789
  emitBootFailure('WS_HANDSHAKE_FAILED', message);
627
790
  return { ok: false, mountedRender };
628
791
  }
792
+ telemetry?.record('subscribe.resolved', JSON.stringify({ kind: handle.handle.kind, hasAck: handle.ack !== undefined }));
629
793
  // Attach the live transport handle to the renderer — flushes any
630
- // buffered outbound `action` frames that were queued
631
- // while the subscribe handshake completed. `handle.handle.send` is
632
- // the canonical send surface for the bound WS transport.
794
+ // buffered outbound `action` frames that were queued while the
795
+ // subscribe handshake completed. `send` narrows to the ws surface:
796
+ // a trio-less bridge-only bind has NO outbound channel (gestures
797
+ // ride app.callServerTool, not the transport), so its sends drop —
798
+ // the same posture a post-swap FailoverHandle already has.
799
+ const transportSend = (msg) => {
800
+ if (handle.handle.kind === 'ws')
801
+ handle.handle.send(msg);
802
+ };
633
803
  if (renderer !== null && rendererHooks?.attachManager !== undefined) {
634
- rendererHooks.attachManager(renderer, { send: (msg) => handle.handle.send(msg) });
804
+ rendererHooks.attachManager(renderer, { send: transportSend });
635
805
  }
636
806
  // seed the host-context emitter with the projection captured from
637
807
  // `app.getHostContext()` (the App class's spec-canonical
@@ -642,7 +812,7 @@ export async function bootSequence(opts) {
642
812
  if (parsed.hostContext !== undefined) {
643
813
  seedHostContext({
644
814
  sessionId: meta.sessionId,
645
- send: (msg) => handle.handle.send(msg),
815
+ send: transportSend,
646
816
  initial: parsed.hostContext,
647
817
  });
648
818
  // Bind via App's spec-canonical `hostcontextchanged` event surface.
@@ -655,8 +825,12 @@ export async function bootSequence(opts) {
655
825
  // `pinnedSessionId`) and mount it. Reuses the same `applyAck` helper
656
826
  // the reconnect-rebootstrap path uses — so a server-restart-driven
657
827
  // snapshot replay and the first-boot snapshot apply flow through
658
- // one implementation.
659
- await applyAck(handle.ack);
828
+ // one implementation. Trio-less bridge-only binds have no handshake
829
+ // and therefore no ack — the static seed already painted, and the
830
+ // bridge delivers deltas from the boot cursor.
831
+ if (handle.ack !== undefined) {
832
+ await applyAck(handle.ack);
833
+ }
660
834
  setConnectedStatus(refs);
661
835
  // Lifecycle `code-ready` — terminal happy state. Emitted ONCE per boot
662
836
  // (a static+live meta already fired it from the seed mount, so this is
@@ -942,6 +1116,26 @@ export function readPendingToolResults() {
942
1116
  }
943
1117
  return null;
944
1118
  }
1119
+ /**
1120
+ * Sibling of {@link readPendingToolResults} for the read-plane-only
1121
+ * posture (ggui#537): the NEWEST buffered tool result that carries a
1122
+ * `ui://ggui/render/…` locator but no bootstrap material. Consulted only
1123
+ * when the buffer yielded no inline slice; the autostart threads it to
1124
+ * `bootSequence` as `preResolvedLocator`, resolved after the handshake.
1125
+ */
1126
+ export function readPendingToolResultLocator() {
1127
+ if (typeof window === 'undefined')
1128
+ return null;
1129
+ const raw = window.__GGUI_PENDING_TOOL_RESULTS__;
1130
+ if (!Array.isArray(raw) || raw.length === 0)
1131
+ return null;
1132
+ for (let i = raw.length - 1; i >= 0; i--) {
1133
+ const locator = extractLocatorFromToolResult(raw[i]);
1134
+ if (locator !== null)
1135
+ return locator;
1136
+ }
1137
+ return null;
1138
+ }
945
1139
  /**
946
1140
  * Listen for a `ui/notifications/tool-result` notification via the
947
1141
  * App's spec-canonical event surface and resolve to the extracted
@@ -957,36 +1151,75 @@ export function readPendingToolResults() {
957
1151
  function awaitToolResultMetaFromApp(app, timeoutMs) {
958
1152
  return new Promise((resolve) => {
959
1153
  let settled = false;
960
- const handler = (params) => {
1154
+ const settle = (value) => {
961
1155
  if (settled)
962
1156
  return;
963
- const meta = extractMetaFromToolResult(params);
964
- if (meta === null)
965
- return;
966
1157
  settled = true;
967
1158
  app.removeEventListener('toolresult', handler);
968
1159
  clearTimeout(timer);
969
- resolve(meta);
1160
+ resolve(value);
970
1161
  };
971
- app.addEventListener('toolresult', handler);
972
- const timer = setTimeout(() => {
1162
+ const handler = (params) => {
973
1163
  if (settled)
974
1164
  return;
975
- settled = true;
976
- app.removeEventListener('toolresult', handler);
977
- resolve(null);
978
- }, timeoutMs);
1165
+ const meta = extractMetaFromToolResult(params);
1166
+ if (meta !== null) {
1167
+ settle(meta);
1168
+ return;
1169
+ }
1170
+ // Read-plane door (ggui#537): identity-only result → resolve the
1171
+ // locator through the host's `resources/read` proxy. Async, and
1172
+ // it never settles `null` — a later result carrying the slice
1173
+ // inline (or the timeout) still decides.
1174
+ const locator = extractLocatorFromToolResult(params);
1175
+ if (locator === null)
1176
+ return;
1177
+ void resolveMetaViaReadDoor(app, locator).then((resolved) => {
1178
+ if (resolved !== null)
1179
+ settle(resolved);
1180
+ });
1181
+ };
1182
+ app.addEventListener('toolresult', handler);
1183
+ const timer = setTimeout(() => settle(null), timeoutMs);
979
1184
  });
980
1185
  }
981
1186
  /**
982
- * Pre-handshake variant — listens to raw `window.message` for the
983
- * autostart layer. Used BEFORE the App is constructed, so this can't
984
- * route through `app.addEventListener`. Pairs with the minimal-shell
985
- * pattern: the shell buffers any tool-results that arrived BEFORE
986
- * runtime load (read via {@link readPendingToolResults}); this
987
- * listener catches the ones that arrive AFTER the bundle parses but
988
- * BEFORE bootSequence runs.
989
- */
1187
+ * The read-plane door (ggui#537). A server running the read-plane-only
1188
+ * posture publishes only the view's identity on the tool result — the
1189
+ * `ui://ggui/render/…` locator — and no bootstrap material; the
1190
+ * per-render resource that locator names is the self-contained shell,
1191
+ * whose document inlines the very envelope this runtime boots from.
1192
+ * Ask the HOST to read it (`app.readServerResource` → `resources/read`,
1193
+ * proxied by the host's own MCP client — no fetch from this sandbox, so
1194
+ * a host CSP without `connect-src` to the server is not in the way),
1195
+ * recover the envelope with the protocol's writer/reader pair
1196
+ * ({@link readGguiShellEnvelope}), and validate it exactly as an inline
1197
+ * slice would be. `null` on any miss (host cannot proxy reads, resource
1198
+ * gone, no envelope) — the caller's other tiers still decide.
1199
+ */
1200
+ export async function resolveMetaViaReadDoor(app, locator) {
1201
+ let text;
1202
+ try {
1203
+ const res = await app.readServerResource({ uri: locator });
1204
+ const first = res.contents[0];
1205
+ text = first !== undefined && 'text' in first && typeof first.text === 'string' ? first.text : undefined;
1206
+ }
1207
+ catch (err) {
1208
+ // eslint-disable-next-line no-console -- operator-visible: the door is a boot tier, its miss must be legible
1209
+ console.warn(`[ggui-runtime] read-plane door: host could not read ${locator} — ${err instanceof Error ? err.message : String(err)}`);
1210
+ return null;
1211
+ }
1212
+ if (text === undefined)
1213
+ return null;
1214
+ const envelope = readGguiShellEnvelope(text);
1215
+ if (envelope === undefined)
1216
+ return null;
1217
+ const parsed = parseMcpAppAiGguiRenderMeta(envelope);
1218
+ if (!parsed.ok || parsed.meta === undefined)
1219
+ return null;
1220
+ const validated = validateMeta(parsed.meta);
1221
+ return validated.ok ? validated.meta : null;
1222
+ }
990
1223
  function awaitToolResultMeta(timeoutMs) {
991
1224
  if (typeof window === 'undefined')
992
1225
  return Promise.resolve(null);
@@ -1009,8 +1242,17 @@ function awaitToolResultMeta(timeoutMs) {
1009
1242
  return;
1010
1243
  }
1011
1244
  const meta = extractMetaFromToolResult(m.params);
1012
- if (meta !== null)
1013
- settle(meta);
1245
+ if (meta !== null) {
1246
+ settle({ kind: 'meta', meta });
1247
+ return;
1248
+ }
1249
+ // Identity-only result (ggui#537): settle NOW with the locator so
1250
+ // boot starts immediately and the door runs after the handshake —
1251
+ // waiting the full timeout for a slice this posture never sends
1252
+ // would cost every spec-host view 30 s for nothing.
1253
+ const locator = extractLocatorFromToolResult(m.params);
1254
+ if (locator !== null)
1255
+ settle({ kind: 'locator', locator });
1014
1256
  };
1015
1257
  window.addEventListener('message', onMessage);
1016
1258
  const timer = setTimeout(() => settle(null), timeoutMs);
@@ -1307,6 +1549,23 @@ export function emitAudit(args) {
1307
1549
  * iframe-runtime then falls through to `ui/message` so the gesture
1308
1550
  * still reaches the agent on its next turn.
1309
1551
  */
1552
+ /**
1553
+ * Unwrap the payload record of a relayed submit-action tool result.
1554
+ * Envelope-level shim over the shared 3-tier unwrap
1555
+ * ({@link unwrapCallToolResult}: `structuredContent` →
1556
+ * `content[0].text` parsed as JSON → bare result) — this wrapper only
1557
+ * peels the JSON-RPC `result` field first. The tiers themselves are
1558
+ * shared with the bridge-pull rung's `fetchBody` carrier
1559
+ * (`events-polling.ts`) so both reads agree on which envelope tier
1560
+ * carries the payload for a given host shape.
1561
+ * Returns `null` when no tier yields an object — the caller treats
1562
+ * that as fallback/unknown.
1563
+ */
1564
+ function submitActionPayload(resp) {
1565
+ if (resp === null || typeof resp !== 'object')
1566
+ return null;
1567
+ return unwrapCallToolResult(resp.result);
1568
+ }
1310
1569
  function classifySubmitActionResponse(resp) {
1311
1570
  if (resp === null || typeof resp !== 'object')
1312
1571
  return 'fallback';
@@ -1315,15 +1574,9 @@ function classifySubmitActionResponse(resp) {
1315
1574
  resp.error !== null) {
1316
1575
  return 'fallback';
1317
1576
  }
1318
- const result = resp.result;
1319
- if (result === null || typeof result !== 'object')
1577
+ const inner = submitActionPayload(resp);
1578
+ if (inner === null)
1320
1579
  return 'fallback';
1321
- const r = result;
1322
- // Spec-canonical hosts wrap tool output under `structuredContent`;
1323
- // looser hosts may surface fields directly on `result`.
1324
- const inner = r.structuredContent && typeof r.structuredContent === 'object'
1325
- ? r.structuredContent
1326
- : r;
1327
1580
  return inner.ok === true ? 'success' : 'fallback';
1328
1581
  }
1329
1582
  /**
@@ -1355,25 +1608,65 @@ function isRelayShapedFailure(resp) {
1355
1608
  /**
1356
1609
  * Extract `consumerPresent` from a successful submit_action response.
1357
1610
  * Returns the boolean if present + well-typed; `undefined` otherwise
1358
- * (server didn't wire the registry, agnostic host stripped the field,
1359
- * or any non-`ok:true` shape — callers fall back to the 10s timer
1360
- * path on `undefined`). Mirrors the structuredContent → result
1361
- * unwrap of {@link classifySubmitActionResponse} so both reads agree
1362
- * on which envelope tier carries the success payload.
1611
+ * (agnostic host stripped the field, or any non-`ok:true` shape).
1612
+ * `undefined` is treated by the dispatch gate as "no confirmed
1613
+ * consumer" — the doorbell rings. Shares {@link submitActionPayload}
1614
+ * with {@link classifySubmitActionResponse} so both reads agree on
1615
+ * which envelope tier carries the payload.
1363
1616
  */
1364
1617
  function extractConsumerPresent(resp) {
1365
- if (resp === null || typeof resp !== 'object')
1366
- return undefined;
1367
- const result = resp.result;
1368
- if (result === null || typeof result !== 'object')
1618
+ const inner = submitActionPayload(resp);
1619
+ if (inner === null)
1369
1620
  return undefined;
1370
- const r = result;
1371
- const inner = r.structuredContent && typeof r.structuredContent === 'object'
1372
- ? r.structuredContent
1373
- : r;
1374
1621
  const flag = inner.consumerPresent;
1375
1622
  return typeof flag === 'boolean' ? flag : undefined;
1376
1623
  }
1624
+ /**
1625
+ * Freeze-cue overlay (#483). A superseded (history) card gets a
1626
+ * subtle, theme-aware "superseded" veil + a labelled pill, and its
1627
+ * content is made non-interactive so a stale-view click can't even
1628
+ * reach a control. Idempotent — the overlay id guards a second call.
1629
+ * A polite live-region announcement (reusing the #447 announcer)
1630
+ * tells a screen-reader user the card is now history.
1631
+ */
1632
+ const FREEZE_CUE_ID = '__ggui-superseded-cue__';
1633
+ function applyFreezeCue(root) {
1634
+ if (typeof document === 'undefined')
1635
+ return;
1636
+ if (root.querySelector(`#${FREEZE_CUE_ID}`) !== null)
1637
+ return;
1638
+ // Non-interactive + visually receded — inherits the mount's own
1639
+ // colors, so it reads correctly in any theme.
1640
+ root.style.pointerEvents = 'none';
1641
+ root.style.opacity = '0.55';
1642
+ root.style.filter = 'grayscale(0.4)';
1643
+ root.style.transition = 'opacity 160ms ease, filter 160ms ease';
1644
+ if (getComputedStyle(root).position === 'static') {
1645
+ root.style.position = 'relative';
1646
+ }
1647
+ const pill = document.createElement('div');
1648
+ pill.id = FREEZE_CUE_ID;
1649
+ pill.setAttribute('role', 'note');
1650
+ pill.textContent = 'Superseded — a newer version continues below';
1651
+ Object.assign(pill.style, {
1652
+ position: 'absolute',
1653
+ top: '8px',
1654
+ right: '8px',
1655
+ padding: '4px 10px',
1656
+ borderRadius: '9999px',
1657
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
1658
+ fontSize: '11px',
1659
+ fontWeight: '600',
1660
+ color: 'var(--ggui-color-onSurface, inherit)',
1661
+ background: 'rgba(128,128,128,0.16)',
1662
+ border: '1px solid currentColor',
1663
+ opacity: '0.85',
1664
+ pointerEvents: 'none',
1665
+ zIndex: '2',
1666
+ });
1667
+ root.appendChild(pill);
1668
+ announceToast('This card was superseded by a newer version below', 'fallback');
1669
+ }
1377
1670
  /** Container id for the announcer holding both live regions. */
1378
1671
  const TOAST_ANNOUNCER_ID = '__ggui-toast-announcer__';
1379
1672
  /**
@@ -1710,7 +2003,7 @@ function emitUserActionDoorbell(args) {
1710
2003
  ` <next_args>${nextArgs}</next_args>`,
1711
2004
  `</ggui_directive>`,
1712
2005
  '',
1713
- `The user interacted with render ${args.sessionId} while no ggui_consume long-poll was active. The gesture is queued on the consume pipe for that render — it is NOT in this message. After ggui_consume returns, react to the returned event with the appropriate domain tool, then call ggui_update on the SAME sessionId (${args.sessionId}).`,
2006
+ `The user interacted with render ${args.sessionId} while no ggui_consume long-poll was active. The gesture is queued on the consume pipe for that render — it is NOT in this message. After ggui_consume returns, react to the returned event with the appropriate domain tool, then call ggui_amend on the SAME sessionId (${args.sessionId}) — the card the user is looking at repaints in place. Use ggui_update instead only if this moment deserves a NEW card in the conversation (it advances the history number).`,
1714
2007
  ].join('\n');
1715
2008
  // Structured mirror of the directive for ggui-aware programmatic
1716
2009
  // consumers. OPTIONAL — nothing in the loop depends on a server-side
@@ -1754,6 +2047,7 @@ function emitUserActionDoorbell(args) {
1754
2047
  // record — the proper extension point). Namespaced under
1755
2048
  // `ai.ggui/userAction` to match our other protocol extensions
1756
2049
  // (`ai.ggui/render`, `ai.ggui/bootstrap`, etc.).
2050
+ currentTelemetrySink?.record('doorbell.ring', args.sessionId);
1757
2051
  postToParent({
1758
2052
  jsonrpc: '2.0',
1759
2053
  id: Math.floor(Math.random() * 1e9),
@@ -1785,6 +2079,23 @@ function emitUserActionDoorbell(args) {
1785
2079
  * nothing else about the latch is observable to the host.
1786
2080
  */
1787
2081
  let relayIncapabilityAnnounced = false;
2082
+ /**
2083
+ * Module-level handle to the CURRENT boot's telemetry sink so
2084
+ * module-scope paths (the doorbell emitter below) can report without
2085
+ * threading the sink through every call chain. Per-iframe
2086
+ * single-tenancy makes a single slot correct — same justification as
2087
+ * `getCurrentApp()`.
2088
+ */
2089
+ let currentTelemetrySink = null;
2090
+ /**
2091
+ * Freeze latch (#483): set true once a higher-epoch `ggui_update`
2092
+ * supersedes this mount. A frozen mount is HISTORY — its gestures no
2093
+ * longer target the live session, so `dispatchSubmitAction` drops them
2094
+ * (a stale-view submit against the live head is a prevented bug class).
2095
+ * Module-level for the same single-mount-per-iframe reason as
2096
+ * `currentTelemetrySink`.
2097
+ */
2098
+ let mountSuperseded = false;
1788
2099
  /**
1789
2100
  * Whether the user has manually dismissed the CURRENTLY-STANDING relay
1790
2101
  * notice (ggui#442).
@@ -2066,7 +2377,20 @@ function showPostDismissalCue(intent) {
2066
2377
  export function dispatchSubmitAction(args) {
2067
2378
  if (typeof window === 'undefined')
2068
2379
  return;
2380
+ // Freeze latch (#483): a superseded (history) mount does not drive
2381
+ // the live session — drop the gesture rather than submit a stale
2382
+ // view's action against the current head.
2383
+ if (mountSuperseded) {
2384
+ currentTelemetrySink?.record('gesture.dropped_superseded', args.intent);
2385
+ return;
2386
+ }
2069
2387
  const { toolName, intent, data, sessionId, appId } = args;
2388
+ // Gesture-path telemetry — the click's own autopsy trail. #471
2389
+ // round 12 hit a frame whose channels + beacons were fully healthy
2390
+ // while clicks produced NOTHING observable; without a record at the
2391
+ // dispatch entry there is no way to tell "handler never fired" from
2392
+ // "dispatch swallowed it" on a console-less host.
2393
+ currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({ intent, toolName }));
2070
2394
  const firedAt = new Date().toISOString();
2071
2395
  const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
2072
2396
  const inlineData = formatSubmitActionDataInline(data);
@@ -2151,6 +2475,15 @@ export function dispatchSubmitAction(args) {
2151
2475
  showActionToast(`⚠ ${intent} — transport error`, 'error');
2152
2476
  resp = null;
2153
2477
  }
2478
+ // Final hop of the gesture autopsy trail — what the relay
2479
+ // answered (or that it didn't).
2480
+ currentTelemetrySink?.record('gesture.result', JSON.stringify({
2481
+ intent,
2482
+ ok: resp !== null && resp.error === undefined,
2483
+ ...(resp?.error?.message !== undefined
2484
+ ? { error: resp.error.message.slice(0, 120) }
2485
+ : {}),
2486
+ }));
2154
2487
  // Self-healing (ggui#440): a result envelope arriving at all is
2155
2488
  // proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
2156
2489
  // (an otherwise-healthy relay, expired pipe) proves it exactly as
@@ -2192,16 +2525,25 @@ export function dispatchSubmitAction(args) {
2192
2525
  }
2193
2526
  if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
2194
2527
  const consumerPresent = extractConsumerPresent(resp);
2195
- if (consumerPresent === false) {
2196
- // No `ggui_consume` long-poll is draining this render's pipe,
2197
- // so the gesture needs a fresh agent turn. `ui/message` is the
2198
- // ONLY view→host method that can start one — a host that does
2199
- // not accept it cannot be woken by us at all, and the user has
2200
- // to send a message themselves (ggui#440).
2528
+ if (consumerPresent !== true) {
2529
+ // No `ggui_consume` long-poll is POSITIVELY confirmed to be
2530
+ // draining this render's pipe, so the gesture needs a fresh
2531
+ // agent turn. `ui/message` is the ONLY view→host method that
2532
+ // can start one — a host that does not accept it cannot be
2533
+ // woken by us at all, and the user has to send a message
2534
+ // themselves (ggui#440).
2201
2535
  //
2202
- // The doorbell is posted either way: capability absence is an
2203
- // unconfirmed signal, not a proven "cannot", and a dropped
2204
- // postMessage costs nothing.
2536
+ // `!== true` (not `=== false`) is load-bearing: a relay host
2537
+ // that normalizes the tool result and strips the
2538
+ // `consumerPresent` field (claude.ai's live behavior — the
2539
+ // first #471 retest click died exactly here) must ring the
2540
+ // doorbell, not wait forever for a drain_ack that cannot
2541
+ // arrive without a live channel. The doorbell is pointer-only
2542
+ // and the pipe pop is exactly-once, so a redundant ring on a
2543
+ // field-stripping host with a live consumer costs one empty
2544
+ // `ggui_consume`; servers that CAN answer always send an
2545
+ // explicit `true` (the factory wires the registry
2546
+ // unconditionally), so confirmed-consumer hosts stay quiet.
2205
2547
  showActionToast(hostCanReceiveMessages()
2206
2548
  ? `💬 ${intent}${dataPart} — agent not listening, sent to chat`
2207
2549
  : `💬 ${intent}${dataPart} — agent not listening. Send a message to continue.`, 'action_required');
@@ -2213,9 +2555,9 @@ export function dispatchSubmitAction(args) {
2213
2555
  });
2214
2556
  return;
2215
2557
  }
2216
- // consumerPresent is true (or undefined — agnostic host stripped
2217
- // the field). Toast stays `pending`; drain_ack listener dismisses
2218
- // it when ggui_consume drains the event.
2558
+ // consumerPresent === true — a live long-poll is confirmed
2559
+ // draining the pipe. Toast stays `pending`; the drain_ack
2560
+ // listener dismisses it when ggui_consume drains the event.
2219
2561
  return;
2220
2562
  }
2221
2563
  // Enqueue failed (pipe gone / transport error / host has no relay).
@@ -2759,7 +3101,7 @@ function readLiveBootstrapShape() {
2759
3101
  * re-await the same postMessage or re-parse the same global), saving
2760
3102
  * up to the 30s postMessage timeout for spec-strict hosts.
2761
3103
  */
2762
- function runBootProduction(preResolvedMeta) {
3104
+ function runBootProduction(preResolvedMeta, preResolvedLocator) {
2763
3105
  const { app, transport } = createDefaultApp();
2764
3106
  void bootProduction({
2765
3107
  doc: document,
@@ -2775,6 +3117,7 @@ function runBootProduction(preResolvedMeta) {
2775
3117
  onObserve: postObservabilityToParent,
2776
3118
  onLifecycle: postLifecycleToParent,
2777
3119
  ...(preResolvedMeta !== undefined ? { preResolvedMeta } : {}),
3120
+ ...(preResolvedLocator !== undefined ? { preResolvedLocator } : {}),
2778
3121
  });
2779
3122
  }
2780
3123
  if (shouldAutostart() && typeof window !== 'undefined') {
@@ -2805,15 +3148,26 @@ if (shouldAutostart() && typeof window !== 'undefined') {
2805
3148
  }
2806
3149
  else {
2807
3150
  const buffered = readPendingToolResults();
3151
+ const bufferedLocator = buffered === null ? readPendingToolResultLocator() : null;
2808
3152
  if (buffered !== null) {
2809
3153
  runBootProduction(buffered);
2810
3154
  }
3155
+ else if (bufferedLocator !== null) {
3156
+ // Read-plane-only posture (ggui#537): the buffered result named
3157
+ // the view; the door resolves it right after the handshake.
3158
+ runBootProduction(undefined, bufferedLocator);
3159
+ }
2811
3160
  else if (readLiveBootstrapShape()) {
2812
3161
  runBootProduction();
2813
3162
  }
2814
3163
  else {
2815
- void awaitToolResultMeta(POSTMESSAGE_BOOT_TIMEOUT_MS).then((postMessageMeta) => {
2816
- runBootProduction(postMessageMeta ?? undefined);
3164
+ void awaitToolResultMeta(POSTMESSAGE_BOOT_TIMEOUT_MS).then((pre) => {
3165
+ if (pre === null)
3166
+ runBootProduction();
3167
+ else if (pre.kind === 'meta')
3168
+ runBootProduction(pre.meta);
3169
+ else
3170
+ runBootProduction(undefined, pre.locator);
2817
3171
  });
2818
3172
  }
2819
3173
  }
@@ -2982,6 +3336,28 @@ async function bootProduction(opts) {
2982
3336
  // these lived only in the retired self-contained boot path; the
2983
3337
  // WS-driven path installed neither, so anchor clicks + fullscreen
2984
3338
  // requests in a live-rendered component silently no-op'd.
3339
+ // Root of the gesture autopsy trail: a capture-phase listener
3340
+ // proving pointer events reach this document AT ALL. #471
3341
+ // round 12: a healthy frame (beacons + pulls flowing) whose
3342
+ // clicks produced nothing observable — without this record,
3343
+ // "host swallows pointer events" and "component handler never
3344
+ // attached" are indistinguishable from outside a console-less
3345
+ // host. Bounded: only the first few clicks record (the sink
3346
+ // throttles + caps flushes regardless).
3347
+ if (typeof document !== 'undefined') {
3348
+ let recordedClicks = 0;
3349
+ document.addEventListener('click', (ev) => {
3350
+ if (recordedClicks >= 5)
3351
+ return;
3352
+ recordedClicks += 1;
3353
+ const target = ev.target instanceof Element ? ev.target : null;
3354
+ currentTelemetrySink?.record('gesture.dom_click', JSON.stringify({
3355
+ tag: target?.tagName ?? 'unknown',
3356
+ role: target?.getAttribute('role') ?? undefined,
3357
+ trusted: ev.isTrusted,
3358
+ }));
3359
+ }, { capture: true });
3360
+ }
2985
3361
  installAnchorClickInterceptor({
2986
3362
  dispatchToolName,
2987
3363
  sessionId: meta.sessionId,
@@ -2999,6 +3375,10 @@ async function bootProduction(opts) {
2999
3375
  manager,
3000
3376
  streamBus,
3001
3377
  onDispatchEnvelope: (envelope) => {
3378
+ // First hop of the gesture autopsy trail (see
3379
+ // `dispatchSubmitAction`'s sibling record): proves the wire
3380
+ // layer received the component's action envelope at all.
3381
+ currentTelemetrySink?.record('gesture.envelope', JSON.stringify({ type: envelope.type }));
3002
3382
  if (envelope.type !== 'data:submit')
3003
3383
  return;
3004
3384
  const payload = envelope.payload;
@@ -3066,10 +3446,30 @@ async function bootProduction(opts) {
3066
3446
  ...(meta.gadgets !== undefined
3067
3447
  ? { gadgetPackages: meta.gadgets.map((g) => g.package) }
3068
3448
  : {}),
3069
- ...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
3070
- ...(meta.themeMode !== undefined
3071
- ? { themeMode: meta.themeMode }
3449
+ // Strict-CSP module variant (ggui#522 slice 2) — attached to
3450
+ // the INLINE SEED mount only, detected by the absence of the
3451
+ // server-assigned ledger fields (`buildGguiSessionSeedInput`
3452
+ // omits them; every wire-delivered GguiSession carries them).
3453
+ // The variant URL names the SEEDED bytes: a WS-delivered
3454
+ // body may differ, and importing a stale variant would paint
3455
+ // the WRONG component rather than fail. A WS redelivery of
3456
+ // the SAME bytes loses nothing — same code ⇒ the renderer
3457
+ // skips re-evaluation entirely.
3458
+ ...(meta.codeModuleUrl !== undefined &&
3459
+ render.type !== 'mcpApps' &&
3460
+ render.type !== 'system' &&
3461
+ !('createdAt' in render)
3462
+ ? { codeModuleUrl: meta.codeModuleUrl }
3072
3463
  : {}),
3464
+ ...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
3465
+ // Mode: the slice's stamped `themeMode` (every operator layer
3466
+ // resolves into it) wins; when absent, the HOST's announced
3467
+ // theme fills the gap (ggui#551 — see `hostAnnouncedThemeMode`).
3468
+ // Absent-in-slice means "no operator opinion", not "light".
3469
+ ...(() => {
3470
+ const themeMode = meta.themeMode ?? hostAnnouncedThemeMode();
3471
+ return themeMode !== undefined ? { themeMode } : {};
3472
+ })(),
3073
3473
  // Per-app theme overlay (St3 M2.2). Threaded straight from the
3074
3474
  // bootstrap's `_meta["ai.ggui/render"].theme` (typed `AppTheme`,
3075
3475
  // already injection-validated by the wire parser) onto the mount
@@ -3185,9 +3585,28 @@ async function bootProduction(opts) {
3185
3585
  streamBus,
3186
3586
  validatorCtx,
3187
3587
  }));
3588
+ // Freeze latch (#483). This mount's own history epoch, read once
3589
+ // from its boot meta: render → 0, an update-minted card → its N,
3590
+ // a pinned `#N` read → N. When a `props_update` frame carries a
3591
+ // higher epoch, a newer card superseded this one.
3592
+ const selfEpoch = typeof meta.epoch === 'number' ? meta.epoch : 0;
3593
+ let superseded = false;
3594
+ const supersede = () => {
3595
+ if (superseded)
3596
+ return;
3597
+ superseded = true;
3598
+ // Module-level dispatch guard: a history card's gestures no
3599
+ // longer target the live session.
3600
+ mountSuperseded = true;
3601
+ currentTelemetrySink?.record('epoch.frozen', String(selfEpoch));
3602
+ applyFreezeCue(renderInto);
3603
+ };
3188
3604
  channelRegistry.register(createPropsUpdateHandler({
3189
3605
  getCurrentGguiSession: () => currentRender,
3190
3606
  applyRender,
3607
+ getSelfEpoch: () => selfEpoch,
3608
+ onSuperseded: supersede,
3609
+ isSuperseded: () => superseded,
3191
3610
  }));
3192
3611
  channelRegistry.register(createDrainAckHandler({ dispatch: dispatchDrainAck }));
3193
3612
  channelRegistry.register(createChannelPayloadHandler({
@@ -3238,5 +3657,8 @@ async function bootProduction(opts) {
3238
3657
  ...(opts.preResolvedMeta !== undefined
3239
3658
  ? { preResolvedMeta: opts.preResolvedMeta }
3240
3659
  : {}),
3660
+ ...(opts.preResolvedLocator !== undefined
3661
+ ? { preResolvedLocator: opts.preResolvedLocator }
3662
+ : {}),
3241
3663
  });
3242
3664
  }