@ggui-ai/iframe-runtime 0.25.0 → 0.26.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 (40) hide show
  1. package/README.md +2 -2
  2. package/dist/channels/data.d.ts +8 -0
  3. package/dist/channels/data.js +2 -0
  4. package/dist/credential-controller.d.ts +71 -0
  5. package/dist/credential-controller.js +135 -0
  6. package/dist/iframe-runtime.js +97 -94
  7. package/dist/iframe-runtime.meta.json +1 -1
  8. package/dist/index.d.ts +2 -2
  9. package/dist/ladders.d.ts +77 -0
  10. package/dist/ladders.js +383 -0
  11. package/dist/meta-parse.js +38 -8
  12. package/dist/observability.d.ts +22 -1
  13. package/dist/react-renderer.d.ts +20 -0
  14. package/dist/react-renderer.js +22 -0
  15. package/dist/registry-subscribe.d.ts +37 -1
  16. package/dist/registry-subscribe.js +34 -8
  17. package/dist/render-failure-report.d.ts +15 -0
  18. package/dist/render-failure-report.js +61 -0
  19. package/dist/render-item.d.ts +3 -0
  20. package/dist/render-item.js +2 -0
  21. package/dist/runtime-telemetry.d.ts +28 -18
  22. package/dist/runtime-telemetry.js +68 -29
  23. package/dist/runtime.d.ts +114 -35
  24. package/dist/runtime.js +556 -309
  25. package/dist/stream-seq.d.ts +56 -0
  26. package/dist/stream-seq.js +46 -0
  27. package/dist/system-cards/ProtocolProbeCard.d.ts +16 -0
  28. package/dist/system-cards/ProtocolProbeCard.js +17 -14
  29. package/dist/tool-result-error.d.ts +6 -0
  30. package/dist/tool-result-error.js +32 -0
  31. package/dist/types.d.ts +49 -1
  32. package/dist/view-origin.d.ts +29 -0
  33. package/dist/view-origin.js +33 -0
  34. package/dist/view-proof-signer.d.ts +31 -0
  35. package/dist/view-proof-signer.js +99 -0
  36. package/dist/view-root.d.ts +51 -0
  37. package/dist/view-root.js +56 -0
  38. package/dist/wire-config.d.ts +13 -0
  39. package/dist/wire-config.js +39 -1
  40. package/package.json +8 -7
package/dist/runtime.js CHANGED
@@ -28,9 +28,13 @@
28
28
  // schema-defining dependency initializes (see zod-jitless.ts).
29
29
  import './zod-jitless.js';
30
30
  import { runtimeConnectionWriter } from './connection.js';
31
- import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, } from '@ggui-ai/protocol/integrations/mcp-apps';
31
+ import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, GGUI_SHELL_META_MARKER, } from '@ggui-ai/protocol/integrations/mcp-apps';
32
32
  import { effectiveThemeId, effectiveThemeMode, } from '@ggui-ai/protocol/integrations/theme-binding';
33
33
  import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, READ_DOOR_REPORTERS, } from './meta-parse.js';
34
+ import { documentViewRoots, userActivationIsActive, withViewProof } from './view-origin.js';
35
+ import { createStreamSeqTracker } from './stream-seq.js';
36
+ import { createLadderSet } from './ladders.js';
37
+ import { BOOT_REFRESH_RETRIES, createCredentialController, } from './credential-controller.js';
34
38
  import { projectHostContext } from '@ggui-ai/protocol/wire';
35
39
  import { installDismissIntentListener } from './dismiss-intent.js';
36
40
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
@@ -39,17 +43,17 @@ import { mapHostPaletteToGguiVars } from './host-palette-bridge.js';
39
43
  import { buildRootWireConfig, StreamBus, } from './wire-config.js';
40
44
  import { createChannelTransportRouter, } from './channel-transport.js';
41
45
  import { RelayIncapableError } from './relay-incapability.js';
42
- import { ChannelRegistry } from '@ggui-ai/live-channel';
43
46
  import { createTelemetrySink, } from './runtime-telemetry.js';
44
47
  import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createPropsUpdateHandler, createRenderHandler, } from './channels/index.js';
45
- import { classifyChannelFrame, connectViaRegistry, } from './registry-subscribe.js';
46
- import { buildBridgePolling, buildEventsPolling, createSequenceCursor, } from './events-polling.js';
48
+ import { connectViaRegistry, } from './registry-subscribe.js';
49
+ import { createSequenceCursor } from './events-polling.js';
47
50
  import { unwrapCallToolResult } from './call-tool-unwrap.js';
48
51
  import { ensureStatusDom, setStatus, setConnectedStatus, retireShellLoadingIndicator, } from './status-dom.js';
49
52
  import { mergeReservedValidators, setActiveValidatorSet, } from './validation.js';
50
53
  import { loadCompiledValidatorsFromUrl } from './compiled-validators.js';
51
54
  import { mountRender, } from './render-item.js';
52
- import { fromBootstrapFailure, } from './protocol-error.js';
55
+ import { createRenderFailureReporter } from './render-failure-report.js';
56
+ import { fromBootstrapFailure, fromTransportFailure, } from './protocol-error.js';
53
57
  import { postObservabilityToParent, } from './observability.js';
54
58
  import { ACTION_TOAST_Z_INDEX, mountUiFeedbackChrome, shouldMountUiFeedbackChrome, } from './ui-feedback-chrome.js';
55
59
  import { hostCanReceiveMessages, hostCanRelayToolCalls, hostCapabilitiesCaptured, setHostCapabilities, } from './host-capabilities.js';
@@ -414,10 +418,10 @@ export async function bootSequence(opts) {
414
418
  // App handshake settles — whichever yields first wins; the listener
415
419
  // is removed on resolve.
416
420
  //
417
- // Skipped entirely when `preResolvedMeta` is set (autostart caught
421
+ // Skipped entirely when `preResolved` is set (autostart caught
418
422
  // the toolresult or read the global early) — no listener installed,
419
423
  // no race to run.
420
- const toolResultPromise = opts.preResolvedMeta !== undefined
424
+ const toolResultPromise = opts.preResolved !== undefined
421
425
  ? Promise.resolve(null)
422
426
  : awaitToolResultMetaFromApp(app, toolResultTimeoutMs);
423
427
  // Persistent re-mount listener — ALSO pre-connect (ggui#586). On a
@@ -427,7 +431,8 @@ export async function bootSequence(opts) {
427
431
  // notice and leaning on SDK replay for correctness. Registering here
428
432
  // covers every boot path; arrivals before a renderer publishes
429
433
  // `applyRender` no-op inside the listener.
430
- installPersistentToolResultListener(app);
434
+ const viewRoots = opts.viewRoots ?? documentViewRoots;
435
+ installPersistentToolResultListener(app, viewRoots);
431
436
  // Fresh boot ⇒ the relay latch and the connection store start
432
437
  // aligned (ggui#670): a standing latch from a prior mount in a
433
438
  // re-booted document must not outlive the store it was written to.
@@ -474,7 +479,7 @@ export async function bootSequence(opts) {
474
479
  // Reading-B (retired Phase 1.19b.3 with the App-class swap; the
475
480
  // McpUiInitializeResult schema doesn't define `toolOutput`).
476
481
  //
477
- // Tier 0 preResolvedMeta — autostart-layer pre-resolution. When
482
+ // Tier 0 preResolved — autostart-layer pre-resolution. When
478
483
  // the autostart's own toolresult race or inline `__GGUI_META__`
479
484
  // parse caught the meta before bootSequence ran, it threads
480
485
  // the parsed slice meta here so we don't re-await the same
@@ -498,14 +503,18 @@ export async function bootSequence(opts) {
498
503
  // When both tiers fail, we surface the Tier 2 timeout/failure reason
499
504
  // (`MISSING_META_GGUI_BOOTSTRAP`) — it's the spec-canonical channel
500
505
  // and its absence is the diagnostic worth showing.
501
- let parsed;
502
- if (opts.preResolvedMeta !== undefined) {
503
- parsed = { ok: true, meta: opts.preResolvedMeta };
506
+ //
507
+ // Every tier resolves a {@link BootMeta}: a slice whose live credential
508
+ // expired still boots, holding that credential for the one boot refresh
509
+ // below (ggui#1496 R1), instead of reading as no slice at all.
510
+ let resolved;
511
+ if (opts.preResolved !== undefined) {
512
+ resolved = opts.preResolved;
504
513
  }
505
514
  else {
506
- const inline = parseMetaFromGlobal();
507
- if (inline.ok) {
508
- parsed = inline;
515
+ const inline = readSelfContainedMeta();
516
+ if (inline !== null) {
517
+ resolved = inline;
509
518
  }
510
519
  else {
511
520
  // Tier 1.5 — the read-plane door (ggui#537). A pre-boot tool
@@ -517,18 +526,17 @@ export async function bootSequence(opts) {
517
526
  const viaDoor = opts.preResolvedLocator !== undefined
518
527
  ? await resolveMetaViaReadDoor(app, opts.preResolvedLocator)
519
528
  : null;
520
- const fromToolResult = viaDoor ?? (await toolResultPromise);
521
- if (fromToolResult !== null) {
522
- parsed = { ok: true, meta: fromToolResult };
523
- }
524
- else {
525
- parsed = {
526
- ok: false,
527
- reason: 'MISSING_META_GGUI_BOOTSTRAP',
528
- };
529
- }
529
+ resolved = viaDoor ?? (await toolResultPromise);
530
530
  }
531
531
  }
532
+ // Every reader of the inline envelope copies has run by now, and no card
533
+ // code has loaded yet (ggui#1415).
534
+ forgetInlineEnvelopes(doc);
535
+ let parsed = resolved !== null
536
+ ? { ok: true, meta: resolved.meta }
537
+ : { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
538
+ const held = resolved?.held;
539
+ const viewRoot = resolved?.viewRoot;
532
540
  // hostContext is captured opportunistically from
533
541
  // `app.getHostContext()` — populated by App's `ui/initialize`
534
542
  // response capture + kept fresh by the `hostcontextchanged`
@@ -561,6 +569,9 @@ export async function bootSequence(opts) {
561
569
  // `ai.ggui/render` slice. The parser surfaces it directly on
562
570
  // `parsed.meta`.
563
571
  const meta = parsed.meta;
572
+ // The boot slice's view key root (ggui#1415), adopted only when it decodes
573
+ // to this slice's session.
574
+ viewRoots.offer(meta.sessionId, viewRoot);
564
575
  // Install the precompiled, eval-free contract validators shipped on
565
576
  // the bootstrap BEFORE any wire traffic is validated. Self-contained
566
577
  // ESM modules — no `__ggui__` dependency, so this need not wait for
@@ -574,6 +585,22 @@ export async function bootSequence(opts) {
574
585
  // is the pin; the render handler drops any frame addressed
575
586
  // elsewhere.
576
587
  const pinnedSessionId = meta.sessionId;
588
+ // The view's live credential (ggui#1496). One controller owns it and its
589
+ // one-refresh budget, over the same host `tools/call` relay the bridge
590
+ // rung uses; it exists when the view holds a credential and has that
591
+ // relay. Every subscribe frame reads the CURRENT token when it is built,
592
+ // so an adopted credential reaches the next subscribe payload (F5).
593
+ const credentialApp = getCurrentApp();
594
+ const initialCredential = held ?? heldCredentialOf(meta);
595
+ const credentialController = credentialApp !== null && initialCredential !== undefined
596
+ ? createCredentialController({
597
+ initial: initialCredential,
598
+ callTool: (name, args) => credentialApp.callServerTool({ name, arguments: args }),
599
+ ...(opts.credentialTiming?.sleep !== undefined ? { sleep: opts.credentialTiming.sleep } : {}),
600
+ ...(opts.credentialTiming?.random !== undefined ? { random: opts.credentialTiming.random } : {}),
601
+ })
602
+ : null;
603
+ let liveMeta = meta;
577
604
  // Renderer wiring — when supplied, the handler routes frames through
578
605
  // the single-render mount surface + WireConfig + StreamBus. When
579
606
  // absent, the placeholder path runs (boot.test.ts relies on the
@@ -616,20 +643,13 @@ export async function bootSequence(opts) {
616
643
  });
617
644
  }
618
645
  setStatus(refs, `Connecting to ${meta.sessionId}…`, 'connecting');
619
- // Boot-without-renderer path: we still need a ChannelRegistry to
620
- // receive frames, because the registry is the only dispatch
621
- // surface. Build a minimal one with just the `render` placeholder
622
- // handler so non-renderer consumers (boot.test.ts) can observe
623
- // bootstrap-orchestration outcomes without paying React import
624
- // cost. The handler logs status but does not mount React.
625
- const placeholderRegistry = renderer === null
626
- ? createPlaceholderRegistry({
627
- meta,
628
- statusRefs: refs,
629
- pinnedSessionId,
630
- })
631
- : null;
632
- const activeRegistry = renderer?.channelRegistry ?? placeholderRegistry;
646
+ // The handlers every ladder delivers through. Boot-without-renderer
647
+ // (boot.test.ts) gets just the `render` placeholder handler, so
648
+ // non-renderer consumers observe bootstrap-orchestration outcomes
649
+ // without paying React import cost; it logs status but does not mount
650
+ // React.
651
+ const channelHandlers = renderer?.channelHandlers ??
652
+ createPlaceholderHandlers({ statusRefs: refs, pinnedSessionId });
633
653
  /**
634
654
  * Apply an ack's render snapshot to the runtime — when the ack
635
655
  * carries a render matching `pinnedSessionId`, mount it through the
@@ -721,10 +741,9 @@ export async function bootSequence(opts) {
721
741
  // A bootstrap with NEITHER has nothing to show and nowhere to
722
742
  // subscribe.
723
743
  const hasStaticContent = hasStaticContentMeta(meta);
724
- const hasLiveTrio = typeof meta.wsUrl === 'string' &&
725
- meta.wsUrl.length > 0 &&
726
- typeof meta.wsToken === 'string' &&
727
- meta.wsToken.length > 0;
744
+ // A held credential counts as a pending live trio on a bridge-capable
745
+ // boot: the boot refresh below may make it one (ggui#1496 F7).
746
+ const liveTrioPending = hasLiveTrio(meta) || (held !== undefined && credentialController !== null);
728
747
  // Transport-telemetry sink — the iframe's self-report channel (see
729
748
  // runtime-telemetry.ts). Created for EVERY boot with an App bridge;
730
749
  // the first batch carries the boot-path decision so operators can
@@ -743,9 +762,14 @@ export async function bootSequence(opts) {
743
762
  mountSuperseded = false;
744
763
  telemetry?.record('boot.path', JSON.stringify({
745
764
  hasStaticContent,
746
- hasLiveTrio,
765
+ hasLiveTrio: hasLiveTrio(meta),
766
+ heldCredential: held !== undefined,
747
767
  bridgeCapable: telemetryApp !== null,
748
768
  }));
769
+ // Set when the static fetch failed while a live trio was pending: if no
770
+ // live channel comes of it, the boot ends on this failure as it would
771
+ // have with no trio at all.
772
+ let staticMountFailure;
749
773
  // ── Static seed mount — zero-round-trip paint, no WS required. ──────
750
774
  // The ONLY mount path for spec-compliant MCP-Apps hosts that expose no
751
775
  // ggui live channel (claude.ai / ChatGPT / Claude Desktop), AND the
@@ -767,12 +791,13 @@ export async function bootSequence(opts) {
767
791
  }
768
792
  catch (err) {
769
793
  const message = err instanceof Error ? err.message : String(err);
770
- if (!hasLiveTrio) {
794
+ if (!liveTrioPending) {
771
795
  // No WS fallback — a failed static fetch is terminal.
772
796
  setStatus(refs, `static mount failed: ${message}`, 'error');
773
797
  emitBootFailure('UI_INITIALIZE_FAILED', message);
774
798
  return { ok: false, mountedRender };
775
799
  }
800
+ staticMountFailure = message;
776
801
  // A live trio is present — the WS ack will deliver the render.
777
802
  // The seed was best-effort; fall through to subscribe. Warn so a
778
803
  // degraded boot (slower first paint, ack-only delivery) is visible
@@ -800,8 +825,33 @@ export async function bootSequence(opts) {
800
825
  // ladder at its tail). Gating the terminal rung behind the WS
801
826
  // trio's presence disabled it on precisely the host class it was
802
827
  // built for.
803
- const staticOnlyNoBridge = !hasLiveTrio && getCurrentApp() === null;
804
- if (!hasLiveTrio && mountedRender === null) {
828
+ // ── The boot refresh (ggui#1496 R1). ─────────────────────────────────
829
+ // A slice whose live credential expired, on either shape, refreshes it
830
+ // once, now, through the host, before any ladder binds; an adopted
831
+ // credential makes this a live boot. A live-only slice has no other way
832
+ // to a live channel, so a relay error there is re-sent (F6); a slice with
833
+ // static content goes on with whatever the refresh answered.
834
+ if (held !== undefined && credentialController !== null) {
835
+ const outcome = await credentialController.onExpired(held, 'boot', hasStaticContent ? undefined : { retries: BOOT_REFRESH_RETRIES });
836
+ telemetry?.record('boot.credential_refresh', outcome.kind === 'refused' ? `refused:${outcome.code}` : outcome.kind);
837
+ if (outcome.kind === 'adopted')
838
+ liveMeta = withLiveCredential(meta, outcome.credential);
839
+ }
840
+ const live = hasLiveTrio(liveMeta);
841
+ const staticOnlyNoBridge = !live && getCurrentApp() === null;
842
+ if (!live && mountedRender === null) {
843
+ if (staticMountFailure !== undefined) {
844
+ setStatus(refs, `static mount failed: ${staticMountFailure}`, 'error');
845
+ emitBootFailure('UI_INITIALIZE_FAILED', staticMountFailure);
846
+ return { ok: false, mountedRender };
847
+ }
848
+ if (held !== undefined) {
849
+ const message = 'the live credential expired and was not refreshed';
850
+ setStatus(refs, message, 'error');
851
+ retireShellLoadingIndicator(doc);
852
+ emitBootFailure('EXPIRED_BOOTSTRAP', message);
853
+ return { ok: false, mountedRender };
854
+ }
805
855
  const message = 'bootstrap carries neither static content (codeUrl/codeB64/kind) nor a live trio (wsUrl/wsToken)';
806
856
  setStatus(refs, message, 'error');
807
857
  retireShellLoadingIndicator(doc);
@@ -837,11 +887,11 @@ export async function bootSequence(opts) {
837
887
  // `/api/sessions/<id>/stream` URL; SSE `id:` = ledger sequence = the
838
888
  // SAME cursor model as the WS `sinceSequence` replay and polling
839
889
  // ticks — switching rungs does not lose events.
840
- const sseUrl = typeof meta.sseUrl === 'string' && meta.sseUrl.length > 0
841
- ? meta.sseUrl
890
+ const sseUrl = typeof liveMeta.sseUrl === 'string' && liveMeta.sseUrl.length > 0
891
+ ? liveMeta.sseUrl
842
892
  : undefined;
843
- const pollingBaseUrl = typeof meta.pollingUrl === 'string' && meta.pollingUrl.length > 0
844
- ? meta.pollingUrl
893
+ const pollingBaseUrl = typeof liveMeta.pollingUrl === 'string' && liveMeta.pollingUrl.length > 0
894
+ ? liveMeta.pollingUrl
845
895
  : undefined;
846
896
  // One shared cursor for the whole ladder — SSE deliveries advance it
847
897
  // (via onSequence), the polling + bridge descriptors read it per
@@ -855,87 +905,111 @@ export async function bootSequence(opts) {
855
905
  const ladderCursor = sseUrl !== undefined || pollingBaseUrl !== undefined || bridgeApp !== null
856
906
  ? createSequenceCursor(meta.lastSequence ?? 0)
857
907
  : undefined;
908
+ // How a boot ends when its subscribe fails short of UPGRADE_REQUIRED:
909
+ // a transport or auth failure.
910
+ function endOnSubscribeFailure(message) {
911
+ if (mountedRender !== null) {
912
+ // DEGRADE — a transport/auth failure AFTER a static seed already
913
+ // painted. Keep the mounted content, skip live updates (props_update
914
+ // simply stops arriving), and do NOT tear down or surface a
915
+ // bootstrap failure. connectFn already emitted the typed
916
+ // `subscribe-failed` observability event on its own onObserve path.
917
+ setStatus(refs, `live updates unavailable: ${message}`, 'connecting');
918
+ emitCodeReadyOnce();
919
+ return { ok: true, mountedRender };
920
+ }
921
+ if (renderer !== null)
922
+ rendererHooks?.teardown?.(renderer);
923
+ setStatus(refs, `WS handshake failed: ${message}`, 'error');
924
+ emitBootFailure('WS_HANDSHAKE_FAILED', message);
925
+ return { ok: false, mountedRender };
926
+ }
927
+ // The ladder set (ggui#1496) owns every ladder this view binds: the boot
928
+ // ladder below, and each one an adopted credential builds after it. It
929
+ // composes a ladder's fallback rungs (WS → SSE → polling → bridge-pull)
930
+ // from the credential's server-stamped wsToken-gated URLs and the shared
931
+ // cursor seeded from `meta.lastSequence` — same cursor model as the WS
932
+ // `sinceSequence` replay, so switching transports loses no event. The
933
+ // bridge rung, the universal floor for CSP-jailed hosts (claude.ai),
934
+ // exists whenever the App handle is bound.
935
+ const ladders = createLadderSet({
936
+ meta,
937
+ handlers: channelHandlers,
938
+ cursor: ladderCursor,
939
+ streamSeq: renderer?.streamSeq,
940
+ controller: credentialController,
941
+ connectFn,
942
+ bridgeCallTool: bridgeApp !== null ? bridgeCallToolVia(bridgeApp) : undefined,
943
+ onStatus: (status) => {
944
+ telemetry?.record(`status.${status}`);
945
+ setStatus(refs, status === 'connected' ? 'Connected.' : `Connection ${status}…`, status);
946
+ // Propagate WS status to the per-channel transport router so
947
+ // it can flip WS-bound channels into polling
948
+ // fallback (on disconnect) and re-send `channel_subscribe`
949
+ // on reconnect. No-op when renderer wiring is absent.
950
+ if (renderer !== null) {
951
+ renderer.channelTransport.onWsStatusChange(status);
952
+ }
953
+ },
954
+ // Reconnect-with-rebootstrap — every ack after the boot's first one,
955
+ // from the ladder holding the data (a WS reconnect's, or a rebuilt
956
+ // ladder's), reapplies the server's authoritative `render` snapshot:
957
+ // a render or update that landed while the live channel was down
958
+ // restores without an agent re-prompt. The first render a refreshing
959
+ // boot receives this way is its code-ready.
960
+ onAck: (ack) => {
961
+ void applyAck(ack).then(() => {
962
+ if (mountedRender !== null)
963
+ emitCodeReadyOnce();
964
+ });
965
+ },
966
+ // ggui#1424 — the channel door's refusal of a spent one-shot reads as done.
967
+ onPostAckError: (payload) => handlePostAckErrorFrame(meta.sessionId, payload),
968
+ onRefresh: (source, outcome) => {
969
+ telemetry?.record('credential.refresh', `${source}:${outcome.kind === 'refused' ? `refused:${outcome.code}` : outcome.kind}`);
970
+ },
971
+ // The server's stream counter restarted (a new epoch, or a count below
972
+ // this view's cursor); the cursor was forgotten so new envelopes are
973
+ // not dropped as repeats.
974
+ onStreamRestart: (streamSeq, streamEpoch) => {
975
+ telemetry?.record('stream.counter_restart', `${streamSeq ?? '-'}:${streamEpoch ?? '-'}`);
976
+ },
977
+ // An honest end (R4, ggui#1496): a bridge pull confirmed the live
978
+ // channel cannot come back — the host refuses to relay it, or the
979
+ // session is gone. Every ladder is already disposed; a painted card
980
+ // stays, under a status that says so, with one typed error and one
981
+ // observability event naming the confirmation.
982
+ isConfirmedRelayRefusalCode: (code) => CONFIRMED_RELAY_REFUSAL_CODES.has(code),
983
+ onEnded: (end) => {
984
+ const why = end.confirmation === 'relay-refusal'
985
+ ? `the host refused to relay the live channel (${end.code})`
986
+ : `the session is gone (session_not_found, ${end.after === 'a-successful-pull' ? 'after a successful pull' : 'twice in a row'})`;
987
+ telemetry?.record('live.ended', JSON.stringify(end));
988
+ setStatus(refs, `Live updates ended: ${why}.`, 'error');
989
+ onProtocolError?.(fromTransportFailure('DISCONNECTED', false, why));
990
+ onObserve?.({ kind: 'subscribe-failed', reason: 'live-channel-ended', message: why });
991
+ },
992
+ // Live-channel diagnostics tap — every channel_* event the
993
+ // transports emit (failover swaps, polling budget exhaustion,
994
+ // SSE lifecycle) lands in the telemetry buffer. Without this the
995
+ // ladder's story on sandboxed hosts is unobservable.
996
+ ...(telemetry !== null ? { logger: telemetry.channelLogger } : {}),
997
+ // Typed errors and observability from each subscribe, forwarded
998
+ // through the runtime's own emitters.
999
+ ...(onProtocolError !== undefined ? { onProtocolError } : {}),
1000
+ ...(onObserve !== undefined ? { onObserve } : {}),
1001
+ });
1002
+ // The boot ladder carries the controller's credential object itself, so
1003
+ // an expiry it reports is recognised as the current credential's.
1004
+ const bootCredential = hasLiveTrio(liveMeta)
1005
+ ? (credentialController?.current() ?? heldCredentialOf(liveMeta))
1006
+ : undefined;
858
1007
  let handle;
859
1008
  try {
860
- handle = await connectFn({
861
- meta,
862
- registry: activeRegistry,
863
- // Live-channel diagnostics tap — every channel_* event the
864
- // transports emit (failover swaps, polling budget exhaustion,
865
- // SSE lifecycle) lands in the telemetry buffer. Without this the
866
- // ladder's story on sandboxed hosts is unobservable.
867
- ...(telemetry !== null ? { logger: telemetry.channelLogger } : {}),
868
- onStatusChange: (status) => {
869
- telemetry?.record(`status.${status}`);
870
- setStatus(refs, status === 'connected' ? 'Connected.' : `Connection ${status}…`, status);
871
- // Propagate WS status to the per-channel transport router so
872
- // it can flip WS-bound channels into polling
873
- // fallback (on disconnect) and re-send `channel_subscribe`
874
- // on reconnect. No-op when renderer wiring is absent.
875
- if (renderer !== null) {
876
- renderer.channelTransport.onWsStatusChange(status);
877
- }
878
- },
879
- // Forward typed errors from subscribe through the runtime's
880
- // own emitter — connectViaRegistry classifies transport + auth
881
- // + version + protocol errors on its own, we just plumb them
882
- // along.
883
- ...(onProtocolError !== undefined ? { onProtocolError } : {}),
884
- // Forward observability emissions — connectFn owns the
885
- // schema-version-mismatch + subscribe-failed kinds.
886
- ...(onObserve !== undefined ? { onObserve } : {}),
887
- // Reconnect-with-rebootstrap — on every ack received AFTER the
888
- // initial handshake settled, reapply the server's authoritative
889
- // `render` snapshot. A render or update that landed during a WS
890
- // dropout window restores here without an agent re-prompt.
891
- onResubscribeAck: (ack) => {
892
- void applyAck(ack);
893
- },
894
- // Failover-ladder fallback rungs (WS → SSE → polling →
895
- // bridge-pull), composed once at bind time from the
896
- // server-stamped wsToken-gated URLs + `meta.lastSequence`
897
- // (shared cursor seed). FailoverHandle descends a rung on each
898
- // 'failed'; the bridge rung is terminal (never emits 'failed').
899
- //
900
- // Same cursor model as the WS subscribe `sinceSequence` replay
901
- // path — switching transports does not lose events.
902
- ...(sseUrl !== undefined && ladderCursor !== undefined
903
- ? {
904
- sse: {
905
- url: sseUrl,
906
- initialSinceSequence: ladderCursor.get(),
907
- onSequence: (seq) => {
908
- ladderCursor.advance(seq);
909
- },
910
- },
911
- }
912
- : {}),
913
- ...(pollingBaseUrl !== undefined && ladderCursor !== undefined
914
- ? {
915
- polling: buildEventsPolling({
916
- baseUrl: pollingBaseUrl,
917
- cursor: ladderCursor,
918
- }),
919
- }
920
- : {}),
921
- // Bridge-pull terminal rung — composed whenever the App handle
922
- // is bound (it always is by this point in the boot: connectApp
923
- // succeeded and setCurrentApp ran above), INDEPENDENT of
924
- // sseUrl/pollingUrl presence. This is the universal floor for
925
- // CSP-jailed hosts (claude.ai) where the iframe has no network
926
- // path at all: the ledger is pulled via `ggui_runtime_pull`
927
- // `tools/call`s over the host's postMessage bridge. Shares the
928
- // SAME ladder cursor — a demotion into the bridge resumes from
929
- // whatever the rungs above already delivered.
930
- ...(bridgeApp !== null && ladderCursor !== undefined
931
- ? {
932
- bridge: buildBridgePolling({
933
- callTool: (name, args) => bridgeApp.callServerTool({ name, arguments: args }),
934
- sessionId: meta.sessionId,
935
- cursor: ladderCursor,
936
- }),
937
- }
938
- : {}),
1009
+ handle = await ladders.connectBoot({
1010
+ credential: bootCredential,
1011
+ sseUrl,
1012
+ pollingUrl: pollingBaseUrl,
939
1013
  });
940
1014
  }
941
1015
  catch (err) {
@@ -954,24 +1028,21 @@ export async function bootSequence(opts) {
954
1028
  emitBootFailure('UPGRADE_REQUIRED', message);
955
1029
  return { ok: false, mountedRender };
956
1030
  }
957
- const message = err instanceof Error ? err.message : String(err);
958
- if (mountedRender !== null) {
959
- // DEGRADE — a transport/auth failure AFTER a static seed already
960
- // painted. Keep the mounted content, skip live updates (props_update
961
- // simply stops arriving), and do NOT tear down or surface a
962
- // bootstrap failure. connectFn already emitted the typed
963
- // `subscribe-failed` observability event on its own onObserve path.
964
- setStatus(refs, `live updates unavailable: ${message}`, 'connecting');
965
- emitCodeReadyOnce();
966
- return { ok: true, mountedRender };
967
- }
968
- if (renderer !== null)
969
- rendererHooks?.teardown?.(renderer);
970
- setStatus(refs, `WS handshake failed: ${message}`, 'error');
971
- emitBootFailure('WS_HANDSHAKE_FAILED', message);
972
- return { ok: false, mountedRender };
1031
+ return endOnSubscribeFailure(err instanceof Error ? err.message : String(err));
1032
+ }
1033
+ // A pre-ack auth-class refusal resolves with the ladder instead of
1034
+ // rejecting (ggui#1496 fact 3). For an expired credential the boot
1035
+ // ladder has already asked for a refresh; when it adopts a new one, the
1036
+ // ladder that credential builds takes the view over, so the boot goes on
1037
+ // wiring. A refresh that is skipped or refused, and any other refusal,
1038
+ // end the boot as the rejection did.
1039
+ const bootRefresh = handle.refused?.code === 'BOOTSTRAP_EXPIRED' ? await ladders.bootRefresh() : undefined;
1040
+ const refreshing = bootRefresh?.kind === 'adopted';
1041
+ if (handle.refused !== undefined && !refreshing) {
1042
+ return endOnSubscribeFailure(handle.refused.message ?? handle.refused.code);
973
1043
  }
974
- telemetry?.record('subscribe.resolved', JSON.stringify({ kind: handle.handle.kind, hasAck: handle.ack !== undefined }));
1044
+ // Health (#1383): booleans only; the handle's kind string stays home.
1045
+ telemetry?.record('subscribe.resolved', JSON.stringify({ hasAck: handle.ack !== undefined }));
975
1046
  // Attach the live transport handle to the renderer — flushes any
976
1047
  // buffered outbound `action` frames that were queued while the
977
1048
  // subscribe handshake completed. `send` narrows to the ws surface:
@@ -979,8 +1050,8 @@ export async function bootSequence(opts) {
979
1050
  // ride app.callServerTool, not the transport), so its sends drop —
980
1051
  // the same posture a post-swap FailoverHandle already has.
981
1052
  const transportSend = (msg) => {
982
- if (handle.handle.kind === 'ws')
983
- handle.handle.send(msg);
1053
+ // The active ladder's WS (ggui#1496): after a rebind, the new one's.
1054
+ ladders.send(msg);
984
1055
  };
985
1056
  if (renderer !== null && rendererHooks?.attachManager !== undefined) {
986
1057
  rendererHooks.attachManager(renderer, { send: transportSend });
@@ -1013,6 +1084,14 @@ export async function bootSequence(opts) {
1013
1084
  if (handle.ack !== undefined) {
1014
1085
  await applyAck(handle.ack);
1015
1086
  }
1087
+ if (refreshing) {
1088
+ // A refreshed credential was adopted and its ladder is binding; it takes
1089
+ // the view over at its ack, which applies the render (and code-ready
1090
+ // with it). Until then the active ladder's own status stands.
1091
+ if (mountedRender !== null)
1092
+ emitCodeReadyOnce();
1093
+ return { ok: true, mountedRender };
1094
+ }
1016
1095
  setConnectedStatus(refs);
1017
1096
  // Lifecycle `code-ready` — terminal happy state. Emitted ONCE per boot
1018
1097
  // (a static+live meta already fired it from the seed mount, so this is
@@ -1023,33 +1102,20 @@ export async function bootSequence(opts) {
1023
1102
  return { ok: true, mountedRender };
1024
1103
  }
1025
1104
  /**
1026
- * Build a minimal `ChannelRegistry` for boot paths without renderer
1027
- * wiring (boot.test.ts + the C7a placeholder-only spec). The registry
1028
- * carries just the `render` handler — which logs status but does not
1029
- * mount React — so consumers can observe bootstrap-orchestration
1030
- * outcomes without paying React import cost. Every other frame type
1031
- * silently drops. Production boots through `bootProduction` which
1032
- * supplies a fully-populated renderer with the rich handler set.
1105
+ * The handler set for boot paths without renderer wiring (boot.test.ts +
1106
+ * the C7a placeholder-only spec): just the `render` handler — which logs
1107
+ * status but does not mount React — so consumers can observe
1108
+ * bootstrap-orchestration outcomes without paying React import cost.
1109
+ * Every other frame type silently drops. Production boots through
1110
+ * `bootProduction`, whose renderer supplies the rich handler set.
1033
1111
  */
1034
- function createPlaceholderRegistry(params) {
1035
- const registry = new ChannelRegistry({
1036
- subscribeFrameBuilder: () => ({
1037
- type: 'subscribe',
1038
- payload: {
1039
- sessionId: params.meta.sessionId,
1040
- appId: params.meta.appId,
1041
- ...(params.meta.wsToken !== undefined
1042
- ? { wsToken: params.meta.wsToken }
1043
- : {}),
1044
- },
1112
+ function createPlaceholderHandlers(params) {
1113
+ return [
1114
+ createRenderHandler({
1115
+ statusRefs: params.statusRefs,
1116
+ pinnedSessionId: params.pinnedSessionId,
1045
1117
  }),
1046
- classifyFrame: classifyChannelFrame,
1047
- });
1048
- registry.register(createRenderHandler({
1049
- statusRefs: params.statusRefs,
1050
- pinnedSessionId: params.pinnedSessionId,
1051
- }));
1052
- return registry;
1118
+ ];
1053
1119
  }
1054
1120
  /**
1055
1121
  * Frame dispatch lives inside `@ggui-ai/live-channel`'s
@@ -1082,52 +1148,58 @@ function shouldAutostart() {
1082
1148
  return true;
1083
1149
  return process.env?.GGUI_RENDERER_AUTOSTART !== 'false';
1084
1150
  }
1085
- // =============================================================================
1086
- // Self-contained bootstrap (`window.__GGUI_META__`).
1087
- //
1088
- // The default boot path (postMessage `ui/initialize` → parse the
1089
- // `_meta["ai.ggui/render"]` slice → open WebSocket → subscribe →
1090
- // render from frames) is strictly first-party: it requires the host to
1091
- // speak ggui's custom postMessage protocol AND a reachable live-channel
1092
- // WebSocket the renderer can subscribe against. MCP Apps hosts in the
1093
- // wild (Claude Desktop, claude.ai web) speak only the canonical MCP
1094
- // Apps lifecycle and have no commitment to forward the slice key back
1095
- // through `ui/initialize`. The full first-party path stays intact for
1096
- // callers that own both ends; this self-contained path is what makes
1097
- // the same runtime bundle work in third-party MCP Apps hosts.
1098
- //
1099
- // Contract: when the embedding HTML inlines a global of shape
1100
- // { sessionId: string, appId: string, componentCode: string }
1101
- // (where `componentCode` is base64-encoded compiled ES module source
1102
- // of a React component) BEFORE this bundle's `<script type="module">`
1103
- // executes, the runtime takes over synchronously, mounts the compiled
1104
- // component, and never speaks postMessage / opens a WebSocket. The
1105
- // global is read at module load — a global set later (via deferred
1106
- // scripts, async imports) is too late and the runtime falls through
1107
- // to the legacy postMessage path.
1108
- //
1109
- // `componentCode` carries base64 because the embedding HTML inlines
1110
- // it as a JS string literal next to other `<script>` content; raw
1111
- // JS source contains every character that breaks string-literal
1112
- // embedding (quotes, backticks, `</script>`, newlines, backslash
1113
- // escapes). Base64 sidesteps every escape concern with a 4/3 size
1114
- // overhead that's negligible compared to the network/round-trip
1115
- // savings of skipping postMessage + WS bootstrap.
1116
- // =============================================================================
1151
+ /**
1152
+ * The bridge rung's `tools/call` over the host relay (ggui#1415): every
1153
+ * `ggui_runtime_pull` it sends goes through {@link withViewProof}.
1154
+ *
1155
+ * @internal — exported for unit tests + production reuse.
1156
+ */
1157
+ export function bridgeCallToolVia(app) {
1158
+ return (name, args) => app.callServerTool(withViewProof(name, args));
1159
+ }
1160
+ /**
1161
+ * Remove the inline copies of the boot envelope the shell left behind
1162
+ * (ggui#1415): the `__GGUI_META__` global, the pre-load tool-result buffer,
1163
+ * and the self-contained shell's envelope `<script>`, whose text outlives
1164
+ * the global. Called once the boot slice is resolved, after every reader and
1165
+ * before any card code loads; the key root is already held in this module.
1166
+ *
1167
+ * Hygiene, not a boundary: card code runs in this realm and can still ask
1168
+ * the host to read its own locator. This removes the free copies only.
1169
+ */
1170
+ function forgetInlineEnvelopes(doc) {
1171
+ Reflect.deleteProperty(globalThis, '__GGUI_META__');
1172
+ Reflect.deleteProperty(globalThis, '__GGUI_PENDING_TOOL_RESULTS__');
1173
+ for (const script of Array.from(doc.querySelectorAll('script:not([src])'))) {
1174
+ if ((script.textContent ?? '').trimStart().startsWith(GGUI_SHELL_META_MARKER))
1175
+ script.remove();
1176
+ }
1177
+ }
1178
+ /**
1179
+ * A parse result as a boot slice: the ok arm, or an expired live-only
1180
+ * slice with its held credential (the boot refresh may still make it
1181
+ * live); `null` for every other failure.
1182
+ */
1183
+ function bootMetaOf(result) {
1184
+ if (!result.ok && result.reason !== 'EXPIRED_BOOTSTRAP')
1185
+ return null;
1186
+ return {
1187
+ meta: result.meta,
1188
+ ...(result.held !== undefined ? { held: result.held } : {}),
1189
+ ...(result.viewRoot !== undefined ? { viewRoot: result.viewRoot } : {}),
1190
+ };
1191
+ }
1117
1192
  /**
1118
1193
  * Read `globalThis.__GGUI_META__` synchronously, validate against
1119
- * the unified slice-meta shape, and return the typed meta (or null
1194
+ * the unified slice-meta shape, and return the boot slice (or null
1120
1195
  * on absence / malformation).
1121
1196
  *
1122
- * Thin wrapper around {@link parseMetaFromGlobal}; preserved for
1123
- * back-compat (downstream consumers + tests). Returns `null` instead
1124
- * of {@link McpAppAiGguiMetaParseResult} to match the historical
1125
- * reader contract — the autostart resolver only needs the "valid
1126
- * meta or fall through" signal.
1197
+ * Thin wrapper around {@link parseMetaFromGlobal}. Returns `null`
1198
+ * instead of {@link McpAppAiGguiMetaParseResult} because the autostart
1199
+ * resolver only needs the "a slice to boot, or fall through" signal.
1127
1200
  */
1128
1201
  export function readSelfContainedMeta() {
1129
- const result = parseMetaFromGlobal();
1130
- return result.ok ? result.meta : null;
1202
+ return bootMetaOf(parseMetaFromGlobal());
1131
1203
  }
1132
1204
  /**
1133
1205
  * Parse the bootstrap meta's `propsJson` string into a {@link JsonObject}.
@@ -1250,6 +1322,43 @@ export function hasStaticContentMeta(meta) {
1250
1322
  (typeof meta.codeB64 === 'string' && meta.codeB64.length > 0) ||
1251
1323
  (typeof meta.kind === 'string' && meta.kind.length > 0));
1252
1324
  }
1325
+ /** Whether the meta carries a live trio a ladder can subscribe with (`wsUrl` + `wsToken`). */
1326
+ function hasLiveTrio(meta) {
1327
+ return (typeof meta.wsUrl === 'string' &&
1328
+ meta.wsUrl.length > 0 &&
1329
+ typeof meta.wsToken === 'string' &&
1330
+ meta.wsToken.length > 0);
1331
+ }
1332
+ /** The live credential a meta's trio carries, as the view holds it (ggui#1496); `undefined` without a trio. */
1333
+ function heldCredentialOf(meta) {
1334
+ if (!hasLiveTrio(meta) || meta.wsUrl === undefined || meta.wsToken === undefined)
1335
+ return undefined;
1336
+ return {
1337
+ wsToken: meta.wsToken,
1338
+ wsUrl: meta.wsUrl,
1339
+ ...(meta.expiresAt !== undefined ? { expiresAt: meta.expiresAt } : {}),
1340
+ ...(meta.sseUrl !== undefined ? { sseUrl: meta.sseUrl } : {}),
1341
+ ...(meta.pollingUrl !== undefined ? { pollingUrl: meta.pollingUrl } : {}),
1342
+ origin: 'root',
1343
+ };
1344
+ }
1345
+ /**
1346
+ * The meta a ladder binds with once a credential is adopted (ggui#1496):
1347
+ * the boot meta with its live trio replaced by the credential's. A URL the
1348
+ * credential does not carry (its token could not be re-derived) is left
1349
+ * off, so that rung is not armed with a dead token.
1350
+ */
1351
+ function withLiveCredential(meta, credential) {
1352
+ const { sseUrl: _sse, pollingUrl: _polling, ...rest } = meta;
1353
+ return {
1354
+ ...rest,
1355
+ wsUrl: credential.wsUrl,
1356
+ wsToken: credential.wsToken,
1357
+ ...(credential.expiresAt !== undefined ? { expiresAt: credential.expiresAt } : {}),
1358
+ ...(credential.sseUrl !== undefined ? { sseUrl: credential.sseUrl } : {}),
1359
+ ...(credential.pollingUrl !== undefined ? { pollingUrl: credential.pollingUrl } : {}),
1360
+ };
1361
+ }
1253
1362
  /**
1254
1363
  * Decode a `codeB64` slice field to UTF-8 component source. `atob`
1255
1364
  * yields a byte string; routing through `TextDecoder` restores
@@ -1285,8 +1394,7 @@ function decodeCodeB64(codeB64) {
1285
1394
  * impossible because the shell never inspects the slice-meta shape.
1286
1395
  */
1287
1396
  export function extractMetaFromToolResult(params) {
1288
- const result = parseMetaFromToolResult(params);
1289
- return result.ok ? result.meta : null;
1397
+ return bootMetaOf(parseMetaFromToolResult(params));
1290
1398
  }
1291
1399
  /**
1292
1400
  * Drain `window.__GGUI_PENDING_TOOL_RESULTS__` — the buffer the
@@ -1313,9 +1421,9 @@ export function readPendingToolResults() {
1313
1421
  if (!Array.isArray(raw) || raw.length === 0)
1314
1422
  return null;
1315
1423
  for (let i = raw.length - 1; i >= 0; i--) {
1316
- const meta = extractMetaFromToolResult(raw[i]);
1317
- if (meta !== null)
1318
- return meta;
1424
+ const boot = extractMetaFromToolResult(raw[i]);
1425
+ if (boot !== null)
1426
+ return boot;
1319
1427
  }
1320
1428
  return null;
1321
1429
  }
@@ -1365,9 +1473,9 @@ function awaitToolResultMetaFromApp(app, timeoutMs) {
1365
1473
  const handler = (params) => {
1366
1474
  if (settled)
1367
1475
  return;
1368
- const meta = extractMetaFromToolResult(params);
1369
- if (meta !== null) {
1370
- settle(meta);
1476
+ const boot = extractMetaFromToolResult(params);
1477
+ if (boot !== null) {
1478
+ settle(boot);
1371
1479
  return;
1372
1480
  }
1373
1481
  // Read-plane door (ggui#537): identity-only result → resolve the
@@ -1420,8 +1528,7 @@ export async function resolveMetaViaReadDoor(app, locator) {
1420
1528
  const parsed = parseMcpAppAiGguiRenderMeta(envelope, READ_DOOR_REPORTERS);
1421
1529
  if (!parsed.ok || parsed.meta === undefined)
1422
1530
  return null;
1423
- const validated = validateMeta(parsed.meta);
1424
- return validated.ok ? validated.meta : null;
1531
+ return bootMetaOf(validateMeta(parsed.meta));
1425
1532
  }
1426
1533
  function awaitToolResultMeta(timeoutMs) {
1427
1534
  if (typeof window === 'undefined')
@@ -1444,9 +1551,9 @@ function awaitToolResultMeta(timeoutMs) {
1444
1551
  m.method !== 'ui/notifications/tool-result') {
1445
1552
  return;
1446
1553
  }
1447
- const meta = extractMetaFromToolResult(m.params);
1448
- if (meta !== null) {
1449
- settle({ kind: 'meta', meta });
1554
+ const boot = extractMetaFromToolResult(m.params);
1555
+ if (boot !== null) {
1556
+ settle({ kind: 'meta', boot });
1450
1557
  return;
1451
1558
  }
1452
1559
  // Identity-only result (ggui#537): settle NOW with the locator so
@@ -1595,7 +1702,8 @@ function callAppRequestDisplayMode(params) {
1595
1702
  * migrated to `app.callServerTool` — see emitAudit for the same
1596
1703
  * posture).
1597
1704
  */
1598
- const productionContextSnapshotPoster = {
1705
+ /** @internal — exported for unit tests. */
1706
+ export const productionContextSnapshotPoster = {
1599
1707
  postUpdateModelContext: (params) => {
1600
1708
  callAppUpdateModelContext(params);
1601
1709
  },
@@ -1604,14 +1712,11 @@ const productionContextSnapshotPoster = {
1604
1712
  jsonrpc: '2.0',
1605
1713
  id: Math.floor(Math.random() * 1e9),
1606
1714
  method: 'tools/call',
1607
- params: {
1608
- name: 'ggui_runtime_sync_context',
1609
- arguments: {
1610
- sessionId: params.sessionId,
1611
- appId: params.appId,
1612
- snapshot: params.snapshot,
1613
- },
1614
- },
1715
+ params: withViewProof('ggui_runtime_sync_context', {
1716
+ sessionId: params.sessionId,
1717
+ appId: params.appId,
1718
+ snapshot: params.snapshot,
1719
+ }),
1615
1720
  });
1616
1721
  },
1617
1722
  };
@@ -1630,10 +1735,10 @@ const productionContextSnapshotPoster = {
1630
1735
  * `CallToolResult` in `{result: ...}`.
1631
1736
  *
1632
1737
  * Drops with an error envelope when no App is bound — the dispatch
1633
- * pipeline classifies that as a transport error and routes to the
1634
- * `ui/message` fallback.
1738
+ * pipeline classifies that as a non-success outcome and drops the
1739
+ * gesture with local feedback (a toast); no `ui/message` fires.
1635
1740
  */
1636
- async function callServerToolSpec(toolName, args) {
1741
+ async function callServerToolSpec(toolName, args, proof) {
1637
1742
  const app = getCurrentApp();
1638
1743
  if (app === null) {
1639
1744
  return {
@@ -1643,10 +1748,7 @@ async function callServerToolSpec(toolName, args) {
1643
1748
  };
1644
1749
  }
1645
1750
  try {
1646
- const result = await app.callServerTool({
1647
- name: toolName,
1648
- arguments: args,
1649
- });
1751
+ const result = await app.callServerTool(withViewProof(toolName, args, proof));
1650
1752
  return { jsonrpc: '2.0', result: result };
1651
1753
  }
1652
1754
  catch (err) {
@@ -1733,17 +1835,14 @@ export function emitAudit(args) {
1733
1835
  jsonrpc: '2.0',
1734
1836
  id: Math.floor(Math.random() * 1e9),
1735
1837
  method: 'tools/call',
1736
- params: {
1737
- name: args.toolName,
1738
- arguments: {
1739
- kind: args.kind,
1740
- payload: args.payload,
1741
- sessionId: args.sessionId,
1742
- appId: args.appId,
1743
- actionId: args.actionId,
1744
- firedAt: args.firedAt,
1745
- },
1746
- },
1838
+ params: withViewProof(args.toolName, {
1839
+ kind: args.kind,
1840
+ payload: args.payload,
1841
+ sessionId: args.sessionId,
1842
+ appId: args.appId,
1843
+ actionId: args.actionId,
1844
+ firedAt: args.firedAt,
1845
+ }),
1747
1846
  });
1748
1847
  }
1749
1848
  /**
@@ -2047,17 +2146,21 @@ function hideToast(el) {
2047
2146
  /** The single action-toast element, reused for every message. */
2048
2147
  const ACTION_TOAST_ID = '__ggui-action-toast__';
2049
2148
  /**
2050
- * Lightweight toast UX surface for dispatched actions. Renders a
2051
- * fixed-position element at the bottom of the iframe so the user
2052
- * gets immediate visual feedback that their gesture was registered
2053
- * — "→ Sending action: archive" → "✓ Queued — agent will react"
2054
- * (or "💬 Sent to chat" on ui/message fallback).
2149
+ * The drawn toast for a dispatched action: a fixed-position element at
2150
+ * the bottom of the iframe, drawn only when the visitor has to know or do
2151
+ * something (the copy is {@link GESTURE_COPY}). Two kinds reach it:
2152
+ * - `'error'` — the gesture did not go through: the relay refused it or
2153
+ * could not be reached, or the card's own contract refused it. Nothing
2154
+ * reached the pipe, so no `ui/message` doorbell fires (SPEC §4.7
2155
+ * "Failure-soft client posture");
2156
+ * - `'action_required'` — the gesture is queued but no consumer is
2157
+ * listening and this host cannot take (or refused) the wake-up, so the
2158
+ * visitor must send a message to continue.
2159
+ * A tap that went through, or a wake-up the host took, is spoken, never
2160
+ * drawn.
2055
2161
  *
2056
- * Without this, the iframe is silent during dispatch. User clicks
2057
- * and waits, with no way to distinguish "click was received but the
2058
- * agent is busy" from "click was lost". Especially load-bearing in
2059
- * the consume-pipe vs ui/message dual-path era — the toast tells
2060
- * the user which path the gesture actually took.
2162
+ * Without it, a failed gesture would be silent: the visitor could not tell
2163
+ * "the agent is busy" from "the tap was lost".
2061
2164
  *
2062
2165
  * Direct DOM (not React-managed) because:
2063
2166
  * - Works during boot before React mounts.
@@ -2123,7 +2226,110 @@ const GESTURE_COPY = {
2123
2226
  sentToChat: (label) => named(label, 'sent to the chat', 'Sent to the chat'),
2124
2227
  sendToContinue: (label) => `💬 ${named(label, 'agent not listening. Send a message to continue.', 'Agent not listening. Send a message to continue.')}`,
2125
2228
  chatRefused: (label) => `💬 ${named(label, 'the chat did not take the message. Send a message to continue.', 'The chat did not take the message. Send a message to continue.')}`,
2229
+ // ggui#1536 — the card's own action contract refused what the tap would
2230
+ // send, so nothing left the card. The visitor cannot fix that, but a tap
2231
+ // that visibly failed is better than a dead one.
2232
+ refused: (label) => `⚠ ${named(label, 'could not be sent', 'Could not be sent')}`,
2233
+ // ggui#1424 — the server refused the gesture because this one-shot action
2234
+ // already fired on this card: the honest state is "done", not an error.
2235
+ alreadyDone: (label) => `✓ ${named(label, 'already done', 'Already done')}`,
2126
2236
  };
2237
+ /**
2238
+ * ggui#1424 — the `oneShot` names the server refused as already spent, per
2239
+ * render, so the wire guard (`refusedSpentOneShots`) reads them beside the
2240
+ * card's persisted record and the control reads done from now on. Keyed by
2241
+ * render id because a `ggui_update` mints a new card whose actions are
2242
+ * re-armed, and that card has a new id.
2243
+ */
2244
+ const refusedSpentOneShotsByRender = new Map();
2245
+ /** The mounted document's "spent inputs changed" notifier, set at mount; `null` before one. */
2246
+ let notifySpentInputsChanged = null;
2247
+ /** @internal — exported for unit tests. */
2248
+ export function isActionSpentByRefusal(renderId, actionName) {
2249
+ return refusedSpentOneShotsByRender.get(renderId)?.has(actionName) === true;
2250
+ }
2251
+ function markSpentByRefusal(renderId, actionName) {
2252
+ let set = refusedSpentOneShotsByRender.get(renderId);
2253
+ if (set === undefined) {
2254
+ set = new Set();
2255
+ refusedSpentOneShotsByRender.set(renderId, set);
2256
+ }
2257
+ set.add(actionName);
2258
+ notifySpentInputsChanged?.();
2259
+ }
2260
+ /** The mounted document's label resolver for an action name, set at mount; `null` before one. */
2261
+ let labelForAction = null;
2262
+ /**
2263
+ * ggui#1424 — the one answer to a server refusal of a spent one-shot, from
2264
+ * either door (the tool answer or the channel's error frame): the guard
2265
+ * reads the action spent from now on, the host is told on the
2266
+ * `action-refused` channel with the field (never the payload), and the
2267
+ * visitor sees the action as already done — never "could not reach the
2268
+ * agent". Nothing is on the pipe, so no doorbell.
2269
+ */
2270
+ function settleOneShotRefusal(renderId, actionName, label) {
2271
+ markSpentByRefusal(renderId, actionName);
2272
+ postObservabilityToParent({
2273
+ kind: 'action-refused',
2274
+ renderId,
2275
+ actionName,
2276
+ violations: [{ field: `actionSpec.${actionName}.oneShot`, keyword: 'oneShot' }],
2277
+ });
2278
+ showActionToast(GESTURE_COPY.alreadyDone(label), 'success');
2279
+ }
2280
+ /** The action a refusal's violations name at `actionSpec.<name>.oneShot`, or `null`. */
2281
+ function oneShotRefusedAction(violations) {
2282
+ if (!Array.isArray(violations))
2283
+ return null;
2284
+ for (const v of violations) {
2285
+ const field = typeof v === 'object' && v !== null ? v.field : undefined;
2286
+ const m = typeof field === 'string' ? /^actionSpec\.(.+)\.oneShot$/.exec(field) : null;
2287
+ if (m?.[1] !== undefined)
2288
+ return m[1];
2289
+ }
2290
+ return null;
2291
+ }
2292
+ /**
2293
+ * ggui#1424 — a post-ack `error` frame from the live channel. The channel
2294
+ * door refuses a second gesture on a spent one-shot with its
2295
+ * CONTRACT_VIOLATION frame and a violation at `actionSpec.<name>.oneShot`;
2296
+ * that one means done, the same as the tool answer. Any other error frame
2297
+ * changes nothing here.
2298
+ *
2299
+ * @internal — exported for unit tests.
2300
+ */
2301
+ export function handlePostAckErrorFrame(renderId, payload) {
2302
+ if (payload.code !== 'CONTRACT_VIOLATION')
2303
+ return;
2304
+ const details = typeof payload.details === 'object' && payload.details !== null ? payload.details : undefined;
2305
+ const actionName = oneShotRefusedAction(details?.violations);
2306
+ if (actionName === null)
2307
+ return;
2308
+ settleOneShotRefusal(renderId, actionName, labelForAction?.(actionName));
2309
+ }
2310
+ /**
2311
+ * Does a submit_action answer refuse THIS gesture because its one-shot action
2312
+ * already fired on the card (ggui#1424)? The discriminator is the violation's
2313
+ * `field`, `actionSpec.<name>.oneShot` — never the code alone, which a bad
2314
+ * payload shares (`CONTRACT_VIOLATION`, ggui#1358).
2315
+ */
2316
+ function readsOneShotRefusal(resp, actionName) {
2317
+ if (resp === null)
2318
+ return false;
2319
+ const inner = submitActionPayload(resp);
2320
+ if (inner === null || inner.ok !== false || inner.code !== 'CONTRACT_VIOLATION')
2321
+ return false;
2322
+ return oneShotRefusedAction(inner.violations) === actionName;
2323
+ }
2324
+ /**
2325
+ * The toast for a tap the outbound contract check refused (ggui#1536): the
2326
+ * ordinary error toast, named by the action's declared label.
2327
+ *
2328
+ * @internal — exported for unit tests + production reuse.
2329
+ */
2330
+ export function showRefusedDispatchToast(label) {
2331
+ showActionToast(GESTURE_COPY.refused(label), 'error');
2332
+ }
2127
2333
  function showActionToast(text, kind, onDismiss) {
2128
2334
  if (typeof document === 'undefined')
2129
2335
  return;
@@ -2890,16 +3096,19 @@ export function dispatchSubmitAction(args) {
2890
3096
  // the live session — drop the gesture rather than submit a stale
2891
3097
  // view's action against the current head.
2892
3098
  if (mountSuperseded) {
2893
- currentTelemetrySink?.record('gesture.dropped_superseded', args.intent);
3099
+ currentTelemetrySink?.record('gesture.dropped_superseded');
2894
3100
  return;
2895
3101
  }
2896
3102
  const { toolName, intent, data, sessionId, appId, label } = args;
3103
+ // Read at entry, inside the gesture's own task (ggui#1415): the dispatch
3104
+ // signs it as the proof's user-activation flag, measured, never enforced.
3105
+ const userActivation = userActivationIsActive();
2897
3106
  // Gesture-path telemetry — the click's own autopsy trail. #471
2898
3107
  // round 12 hit a frame whose channels + beacons were fully healthy
2899
3108
  // while clicks produced NOTHING observable; without a record at the
2900
3109
  // dispatch entry there is no way to tell "handler never fired" from
2901
3110
  // "dispatch swallowed it" on a console-less host.
2902
- currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({ intent, toolName }));
3111
+ currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({ toolName }));
2903
3112
  const firedAt = new Date().toISOString();
2904
3113
  const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
2905
3114
  const uiContext = readLocalUiContext();
@@ -2983,7 +3192,7 @@ export function dispatchSubmitAction(args) {
2983
3192
  appId,
2984
3193
  actionId,
2985
3194
  firedAt,
2986
- });
3195
+ }, { userActivation });
2987
3196
  }
2988
3197
  catch {
2989
3198
  showActionToast(GESTURE_COPY.transportError(label), 'error');
@@ -2991,12 +3200,11 @@ export function dispatchSubmitAction(args) {
2991
3200
  }
2992
3201
  // Final hop of the gesture autopsy trail — what the relay
2993
3202
  // answered (or that it didn't).
3203
+ // No free text (#1383): the relay's error CODE rides, never its
3204
+ // message (a host's words), and the intent string stays home.
2994
3205
  currentTelemetrySink?.record('gesture.result', JSON.stringify({
2995
- intent,
2996
3206
  ok: resp !== null && resp.error === undefined,
2997
- ...(resp?.error?.message !== undefined
2998
- ? { error: resp.error.message.slice(0, 120) }
2999
- : {}),
3207
+ ...(typeof resp?.error?.code === 'number' ? { code: resp.error.code } : {}),
3000
3208
  }));
3001
3209
  // Self-healing (ggui#440): a result envelope arriving at all is
3002
3210
  // proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
@@ -3084,6 +3292,17 @@ export function dispatchSubmitAction(args) {
3084
3292
  // listener dismisses it when ggui_consume drains the event.
3085
3293
  return;
3086
3294
  }
3295
+ // ggui#1424 — the server refused the gesture because this one-shot
3296
+ // action already fired on the card (a tap that got past the guard: a
3297
+ // re-served card before its spent record arrived, a forced click). The
3298
+ // honest state is DONE: the guard reads the action spent from now on,
3299
+ // the host is told on the `action-refused` channel with the field, and
3300
+ // the visitor sees the action as already done — never "could not reach
3301
+ // the agent". Nothing is on the pipe, so no doorbell.
3302
+ if (readsOneShotRefusal(resp, intent)) {
3303
+ settleOneShotRefusal(sessionId, intent, label);
3304
+ return;
3305
+ }
3087
3306
  // Enqueue failed (pipe gone / transport error / host has no relay).
3088
3307
  // The gesture is not on any pipe, so a doorbell would point at an
3089
3308
  // empty queue; no `ui/message` is emitted either way.
@@ -3537,15 +3756,22 @@ export function __resetInterceptorsForTest() {
3537
3756
  * its event system. The App handle is passed by the boot path
3538
3757
  * directly (pre-connect, so `getCurrentApp()` is not yet set).
3539
3758
  */
3540
- function installPersistentToolResultListener(app) {
3759
+ function installPersistentToolResultListener(app, viewRoots) {
3541
3760
  if (persistentToolResultListenerApps.has(app))
3542
3761
  return;
3543
3762
  persistentToolResultListenerApps.add(app);
3544
3763
  let lastMetaKey = null;
3545
3764
  app.addEventListener('toolresult', (params) => {
3546
- const meta = extractMetaFromToolResult(params);
3547
- if (meta === null)
3765
+ // A re-mount re-applies the static seed only; the one live channel is
3766
+ // the boot's, so a credential held here has nothing to refresh.
3767
+ const resolved = extractMetaFromToolResult(params);
3768
+ if (resolved === null)
3548
3769
  return;
3770
+ const meta = resolved.meta;
3771
+ // A later slice's view key root (ggui#1415) is offered whether or not it
3772
+ // re-mounts anything: the holder keeps it only for its own session and
3773
+ // only when it is newer, and ignores a slice that carries none.
3774
+ viewRoots.offer(meta.sessionId, resolved.viewRoot);
3549
3775
  // Cheap dedupe — the host may emit the same tool-result more
3550
3776
  // than once (claude.ai re-broadcasts on iframe re-attach).
3551
3777
  // Re-mounting the same slice meta would flicker without changing
@@ -3657,7 +3883,7 @@ function readLiveBootstrapShape() {
3657
3883
  * fallback share one call site — keeps the WS-driven boot semantics
3658
3884
  * single-sourced.
3659
3885
  *
3660
- * `preResolvedMeta` is threaded in from the autostart resolver when
3886
+ * `preResolved` is threaded in from the autostart resolver when
3661
3887
  * it has already discovered slice meta — via inline `__GGUI_META__`
3662
3888
  * global, a buffered `__GGUI_PENDING_TOOL_RESULTS__` entry, or an
3663
3889
  * early `ui/notifications/tool-result` postMessage. Threading skips
@@ -3665,7 +3891,7 @@ function readLiveBootstrapShape() {
3665
3891
  * re-await the same postMessage or re-parse the same global), saving
3666
3892
  * up to the 30s postMessage timeout for spec-strict hosts.
3667
3893
  */
3668
- function runBootProduction(preResolvedMeta, preResolvedLocator) {
3894
+ function runBootProduction(preResolved, preResolvedLocator) {
3669
3895
  const { app, transport } = createDefaultApp();
3670
3896
  void bootProduction({
3671
3897
  doc: document,
@@ -3680,7 +3906,7 @@ function runBootProduction(preResolvedMeta, preResolvedLocator) {
3680
3906
  },
3681
3907
  onObserve: postObservabilityToParent,
3682
3908
  onLifecycle: postLifecycleToParent,
3683
- ...(preResolvedMeta !== undefined ? { preResolvedMeta } : {}),
3909
+ ...(preResolved !== undefined ? { preResolved } : {}),
3684
3910
  ...(preResolvedLocator !== undefined ? { preResolvedLocator } : {}),
3685
3911
  });
3686
3912
  }
@@ -3700,7 +3926,7 @@ if (shouldAutostart() && typeof window !== 'undefined') {
3700
3926
  // 3. Live `ui/notifications/tool-result` postMessage — the
3701
3927
  // spec-canonical channel for spec-strict hosts (`<AppRenderer>`,
3702
3928
  // ChatGPT, claude.ai). Caught meta threads through as
3703
- // `preResolvedMeta` so `bootSequence` doesn't re-await it.
3929
+ // `preResolved` so `bootSequence` doesn't re-await it.
3704
3930
  //
3705
3931
  // `readLiveBootstrapShape` still short-circuits the 30s tool-result
3706
3932
  // wait when a live-channel envelope is already inlined — OSS embedded
@@ -3729,7 +3955,7 @@ if (shouldAutostart() && typeof window !== 'undefined') {
3729
3955
  if (pre === null)
3730
3956
  runBootProduction();
3731
3957
  else if (pre.kind === 'meta')
3732
- runBootProduction(pre.meta);
3958
+ runBootProduction(pre.boot);
3733
3959
  else
3734
3960
  runBootProduction(undefined, pre.locator);
3735
3961
  });
@@ -3908,7 +4134,30 @@ async function bootProduction(opts) {
3908
4134
  renderListeners.delete(listener);
3909
4135
  };
3910
4136
  };
4137
+ // ggui#1424 — a server refusal marks an action spent; the guard re-reads
4138
+ // through the same listeners a replaced render wakes.
4139
+ notifySpentInputsChanged = () => {
4140
+ for (const listener of renderListeners)
4141
+ listener();
4142
+ };
4143
+ labelForAction = (actionName) => declaredActionLabel(currentRender, actionName);
3911
4144
  let renderHandle = null;
4145
+ // ggui#1679 — the card's render failure reaches the server once per
4146
+ // session, in the tool's terms, over the same proof-carrying bridge
4147
+ // every runtime tool call rides. The App handle is read when the
4148
+ // failure happens, not now: a boot with no bridge has nowhere to
4149
+ // report, and the reporter then spends its one report on nothing,
4150
+ // which is the "not reported" outcome SPEC §4.9 allows.
4151
+ const renderFailureReporter = createRenderFailureReporter({
4152
+ sessionId: meta.sessionId,
4153
+ appId: meta.appId,
4154
+ callTool: (name, args) => {
4155
+ const bridge = getCurrentApp();
4156
+ if (bridge === null)
4157
+ return Promise.reject(new Error('no App bridge bound: the report has no relay'));
4158
+ return bridgeCallToolVia(bridge)(name, args);
4159
+ },
4160
+ });
3912
4161
  const dispatchToolName = resolveDispatchToolName();
3913
4162
  // Native-idiom interceptors — the replacement for the retired
3914
4163
  // openLink / requestDisplayMode wire primitives. Installed here
@@ -3955,7 +4204,15 @@ async function bootProduction(opts) {
3955
4204
  sessionId: meta.sessionId,
3956
4205
  appId: meta.appId,
3957
4206
  getCurrentGguiSession: () => currentRender,
4207
+ // ggui#1536 — the adapter posted `action-refused` to the host; the
4208
+ // visitor sees the tap failed, named by its declared label.
4209
+ onDispatchRefused: (actionName) => showRefusedDispatchToast(declaredActionLabel(currentRender, actionName)),
3958
4210
  renderChanges: onRenderChange,
4211
+ // ggui#1424 — the names the server refused as already spent on this card.
4212
+ refusedSpentOneShots: () => {
4213
+ const set = currentRender === null ? undefined : refusedSpentOneShotsByRender.get(currentRender.id);
4214
+ return set === undefined ? undefined : [...set];
4215
+ },
3959
4216
  manager,
3960
4217
  streamBus,
3961
4218
  onDispatchEnvelope: (envelope) => {
@@ -4084,6 +4341,8 @@ async function bootProduction(opts) {
4084
4341
  return hostPalette !== undefined ? { hostPalette } : {};
4085
4342
  })(),
4086
4343
  ...(wrapOuter !== undefined ? { wrapOuter } : {}),
4344
+ // The boundary's give-up is reported to the server (ggui#1679).
4345
+ onRenderFailure: renderFailureReporter.report,
4087
4346
  };
4088
4347
  };
4089
4348
  /**
@@ -4198,39 +4457,28 @@ async function bootProduction(opts) {
4198
4457
  }
4199
4458
  : {}),
4200
4459
  });
4201
- // B3b — live-channel registry owns dispatch for every routable
4202
- // WS frame type. Each handler closes over the renderer state it
4203
- // needs. `bootSequence` calls `connectFn` (default
4204
- // `connectViaRegistry`) which registers the `ack` + `error`
4205
- // handshake handlers and binds the WS transport — frames then
4206
- // arrive directly through the registered handlers without an
4207
- // intermediate `onMessage` fan-out.
4460
+ // B3b — the handler for every routable frame type, each closing
4461
+ // over the renderer state it needs. The runtime's ladder set
4462
+ // (ggui#1496) registers these same handler objects on every ladder
4463
+ // it binds, one registry per credential; `connectViaRegistry` adds
4464
+ // the `ack` + `error` handshake handlers and binds the transport,
4465
+ // and frames arrive directly through them.
4208
4466
  //
4209
4467
  // CLIENT_SUPPORTED_VERSIONS handshake is enforced inside
4210
4468
  // `connectViaRegistry`'s ack-handler closure (NOT here).
4211
- const channelRegistry = new ChannelRegistry({
4212
- subscribeFrameBuilder: () => ({
4213
- type: 'subscribe',
4214
- payload: {
4215
- sessionId: meta.sessionId,
4216
- appId: meta.appId,
4217
- ...(meta.wsToken !== undefined
4218
- ? { wsToken: meta.wsToken }
4219
- : {}),
4220
- },
4221
- }),
4222
- classifyFrame: classifyChannelFrame,
4223
- });
4224
- channelRegistry.register(createRenderHandler({
4469
+ const channelHandlers = [];
4470
+ channelHandlers.push(createRenderHandler({
4225
4471
  statusRefs,
4226
4472
  pinnedSessionId: meta.sessionId,
4227
4473
  applyRender,
4228
4474
  getChannelTransport: () => channelTransport,
4229
4475
  }));
4230
- channelRegistry.register(createDataHandler({
4476
+ const streamSeq = createStreamSeqTracker();
4477
+ channelHandlers.push(createDataHandler({
4231
4478
  getCurrentGguiSession: () => currentRender,
4232
4479
  streamBus,
4233
4480
  validatorCtx,
4481
+ streamSeq,
4234
4482
  }));
4235
4483
  // Freeze latch (#483). This mount's own history epoch, read once
4236
4484
  // from its boot meta: render → 0, an update-minted card → its N,
@@ -4248,18 +4496,18 @@ async function bootProduction(opts) {
4248
4496
  currentTelemetrySink?.record('epoch.frozen', String(selfEpoch));
4249
4497
  applyFreezeCue(renderInto);
4250
4498
  };
4251
- channelRegistry.register(createPropsUpdateHandler({
4499
+ channelHandlers.push(createPropsUpdateHandler({
4252
4500
  getCurrentGguiSession: () => currentRender,
4253
4501
  applyRender,
4254
4502
  getSelfEpoch: () => selfEpoch,
4255
4503
  onSuperseded: supersede,
4256
4504
  isSuperseded: () => superseded,
4257
4505
  }));
4258
- channelRegistry.register(createDrainAckHandler({ dispatch: dispatchDrainAck }));
4259
- channelRegistry.register(createChannelPayloadHandler({
4506
+ channelHandlers.push(createDrainAckHandler({ dispatch: dispatchDrainAck }));
4507
+ channelHandlers.push(createChannelPayloadHandler({
4260
4508
  getChannelTransport: () => channelTransport,
4261
4509
  }));
4262
- channelRegistry.register(createChannelErrorHandler({
4510
+ channelHandlers.push(createChannelErrorHandler({
4263
4511
  getChannelTransport: () => channelTransport,
4264
4512
  }));
4265
4513
  return {
@@ -4271,7 +4519,8 @@ async function bootProduction(opts) {
4271
4519
  validatorCtx,
4272
4520
  manager,
4273
4521
  channelTransport,
4274
- channelRegistry,
4522
+ channelHandlers,
4523
+ streamSeq,
4275
4524
  composedGadgets,
4276
4525
  };
4277
4526
  },
@@ -4302,9 +4551,7 @@ async function bootProduction(opts) {
4302
4551
  renderer,
4303
4552
  ...(opts.onObserve !== undefined ? { onObserve: opts.onObserve } : {}),
4304
4553
  ...(opts.onLifecycle !== undefined ? { onLifecycle: opts.onLifecycle } : {}),
4305
- ...(opts.preResolvedMeta !== undefined
4306
- ? { preResolvedMeta: opts.preResolvedMeta }
4307
- : {}),
4554
+ ...(opts.preResolved !== undefined ? { preResolved: opts.preResolved } : {}),
4308
4555
  ...(opts.preResolvedLocator !== undefined
4309
4556
  ? { preResolvedLocator: opts.preResolvedLocator }
4310
4557
  : {}),