@ggui-ai/iframe-runtime 0.24.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 (41) 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 -93
  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 +38 -11
  13. package/dist/react-renderer.d.ts +22 -0
  14. package/dist/react-renderer.js +118 -3
  15. package/dist/registry-subscribe.d.ts +53 -2
  16. package/dist/registry-subscribe.js +63 -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 +6 -1
  20. package/dist/render-item.js +7 -2
  21. package/dist/runtime-telemetry.d.ts +28 -18
  22. package/dist/runtime-telemetry.js +68 -29
  23. package/dist/runtime.d.ts +157 -39
  24. package/dist/runtime.js +724 -368
  25. package/dist/shims/wire.js +1 -1
  26. package/dist/stream-seq.d.ts +56 -0
  27. package/dist/stream-seq.js +46 -0
  28. package/dist/system-cards/ProtocolProbeCard.d.ts +16 -0
  29. package/dist/system-cards/ProtocolProbeCard.js +17 -14
  30. package/dist/tool-result-error.d.ts +6 -0
  31. package/dist/tool-result-error.js +32 -0
  32. package/dist/types.d.ts +49 -1
  33. package/dist/view-origin.d.ts +29 -0
  34. package/dist/view-origin.js +33 -0
  35. package/dist/view-proof-signer.d.ts +31 -0
  36. package/dist/view-proof-signer.js +99 -0
  37. package/dist/view-root.d.ts +51 -0
  38. package/dist/view-root.js +56 -0
  39. package/dist/wire-config.d.ts +15 -1
  40. package/dist/wire-config.js +42 -1
  41. 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
48
  import { connectViaRegistry, } from './registry-subscribe.js';
46
- import { buildBridgePolling, buildEventsPolling, createSequenceCursor, } from './events-polling.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';
@@ -185,12 +189,10 @@ let activeThemeUnsubscribe = null;
185
189
  * the spec's `'light' | 'dark'` literals (dropped, never guessed —
186
190
  * the same tolerant posture as `projectHostContext`).
187
191
  *
188
- * This is the INPUT leg of host-theme adaptation
189
- * (rnd/gen-ui/beauty/experiments/002-host-theme-adaptation.md, ggui#551):
192
+ * This is the INPUT leg of host-theme adaptation (ggui#551):
190
193
  * `hostContext.theme` alone reaches only `data-theme` +
191
194
  * `color-scheme` via `applyDocumentTheme` — nothing in the token
192
- * pipeline keys off it, so a dark host painted the light ladder
193
- * (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
195
+ * pipeline keys off it, so a dark host painted the light ladder.
194
196
  * Under ggui#987 §4 the embedding host OWNS runtime mode: when it
195
197
  * announces one, `buildOpts` paints it over every stamped opinion; the
196
198
  * stamped layers decide only when the host is silent. Read live from
@@ -416,10 +418,10 @@ export async function bootSequence(opts) {
416
418
  // App handshake settles — whichever yields first wins; the listener
417
419
  // is removed on resolve.
418
420
  //
419
- // Skipped entirely when `preResolvedMeta` is set (autostart caught
421
+ // Skipped entirely when `preResolved` is set (autostart caught
420
422
  // the toolresult or read the global early) — no listener installed,
421
423
  // no race to run.
422
- const toolResultPromise = opts.preResolvedMeta !== undefined
424
+ const toolResultPromise = opts.preResolved !== undefined
423
425
  ? Promise.resolve(null)
424
426
  : awaitToolResultMetaFromApp(app, toolResultTimeoutMs);
425
427
  // Persistent re-mount listener — ALSO pre-connect (ggui#586). On a
@@ -429,7 +431,8 @@ export async function bootSequence(opts) {
429
431
  // notice and leaning on SDK replay for correctness. Registering here
430
432
  // covers every boot path; arrivals before a renderer publishes
431
433
  // `applyRender` no-op inside the listener.
432
- installPersistentToolResultListener(app);
434
+ const viewRoots = opts.viewRoots ?? documentViewRoots;
435
+ installPersistentToolResultListener(app, viewRoots);
433
436
  // Fresh boot ⇒ the relay latch and the connection store start
434
437
  // aligned (ggui#670): a standing latch from a prior mount in a
435
438
  // re-booted document must not outlive the store it was written to.
@@ -476,7 +479,7 @@ export async function bootSequence(opts) {
476
479
  // Reading-B (retired Phase 1.19b.3 with the App-class swap; the
477
480
  // McpUiInitializeResult schema doesn't define `toolOutput`).
478
481
  //
479
- // Tier 0 preResolvedMeta — autostart-layer pre-resolution. When
482
+ // Tier 0 preResolved — autostart-layer pre-resolution. When
480
483
  // the autostart's own toolresult race or inline `__GGUI_META__`
481
484
  // parse caught the meta before bootSequence ran, it threads
482
485
  // the parsed slice meta here so we don't re-await the same
@@ -500,14 +503,18 @@ export async function bootSequence(opts) {
500
503
  // When both tiers fail, we surface the Tier 2 timeout/failure reason
501
504
  // (`MISSING_META_GGUI_BOOTSTRAP`) — it's the spec-canonical channel
502
505
  // and its absence is the diagnostic worth showing.
503
- let parsed;
504
- if (opts.preResolvedMeta !== undefined) {
505
- 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;
506
513
  }
507
514
  else {
508
- const inline = parseMetaFromGlobal();
509
- if (inline.ok) {
510
- parsed = inline;
515
+ const inline = readSelfContainedMeta();
516
+ if (inline !== null) {
517
+ resolved = inline;
511
518
  }
512
519
  else {
513
520
  // Tier 1.5 — the read-plane door (ggui#537). A pre-boot tool
@@ -519,18 +526,17 @@ export async function bootSequence(opts) {
519
526
  const viaDoor = opts.preResolvedLocator !== undefined
520
527
  ? await resolveMetaViaReadDoor(app, opts.preResolvedLocator)
521
528
  : null;
522
- const fromToolResult = viaDoor ?? (await toolResultPromise);
523
- if (fromToolResult !== null) {
524
- parsed = { ok: true, meta: fromToolResult };
525
- }
526
- else {
527
- parsed = {
528
- ok: false,
529
- reason: 'MISSING_META_GGUI_BOOTSTRAP',
530
- };
531
- }
529
+ resolved = viaDoor ?? (await toolResultPromise);
532
530
  }
533
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;
534
540
  // hostContext is captured opportunistically from
535
541
  // `app.getHostContext()` — populated by App's `ui/initialize`
536
542
  // response capture + kept fresh by the `hostcontextchanged`
@@ -563,6 +569,9 @@ export async function bootSequence(opts) {
563
569
  // `ai.ggui/render` slice. The parser surfaces it directly on
564
570
  // `parsed.meta`.
565
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);
566
575
  // Install the precompiled, eval-free contract validators shipped on
567
576
  // the bootstrap BEFORE any wire traffic is validated. Self-contained
568
577
  // ESM modules — no `__ggui__` dependency, so this need not wait for
@@ -576,6 +585,22 @@ export async function bootSequence(opts) {
576
585
  // is the pin; the render handler drops any frame addressed
577
586
  // elsewhere.
578
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;
579
604
  // Renderer wiring — when supplied, the handler routes frames through
580
605
  // the single-render mount surface + WireConfig + StreamBus. When
581
606
  // absent, the placeholder path runs (boot.test.ts relies on the
@@ -618,20 +643,13 @@ export async function bootSequence(opts) {
618
643
  });
619
644
  }
620
645
  setStatus(refs, `Connecting to ${meta.sessionId}…`, 'connecting');
621
- // Boot-without-renderer path: we still need a ChannelRegistry to
622
- // receive frames, because the registry is the only dispatch
623
- // surface. Build a minimal one with just the `render` placeholder
624
- // handler so non-renderer consumers (boot.test.ts) can observe
625
- // bootstrap-orchestration outcomes without paying React import
626
- // cost. The handler logs status but does not mount React.
627
- const placeholderRegistry = renderer === null
628
- ? createPlaceholderRegistry({
629
- meta,
630
- statusRefs: refs,
631
- pinnedSessionId,
632
- })
633
- : null;
634
- 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 });
635
653
  /**
636
654
  * Apply an ack's render snapshot to the runtime — when the ack
637
655
  * carries a render matching `pinnedSessionId`, mount it through the
@@ -723,10 +741,9 @@ export async function bootSequence(opts) {
723
741
  // A bootstrap with NEITHER has nothing to show and nowhere to
724
742
  // subscribe.
725
743
  const hasStaticContent = hasStaticContentMeta(meta);
726
- const hasLiveTrio = typeof meta.wsUrl === 'string' &&
727
- meta.wsUrl.length > 0 &&
728
- typeof meta.wsToken === 'string' &&
729
- 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);
730
747
  // Transport-telemetry sink — the iframe's self-report channel (see
731
748
  // runtime-telemetry.ts). Created for EVERY boot with an App bridge;
732
749
  // the first batch carries the boot-path decision so operators can
@@ -745,9 +762,14 @@ export async function bootSequence(opts) {
745
762
  mountSuperseded = false;
746
763
  telemetry?.record('boot.path', JSON.stringify({
747
764
  hasStaticContent,
748
- hasLiveTrio,
765
+ hasLiveTrio: hasLiveTrio(meta),
766
+ heldCredential: held !== undefined,
749
767
  bridgeCapable: telemetryApp !== null,
750
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;
751
773
  // ── Static seed mount — zero-round-trip paint, no WS required. ──────
752
774
  // The ONLY mount path for spec-compliant MCP-Apps hosts that expose no
753
775
  // ggui live channel (claude.ai / ChatGPT / Claude Desktop), AND the
@@ -769,12 +791,13 @@ export async function bootSequence(opts) {
769
791
  }
770
792
  catch (err) {
771
793
  const message = err instanceof Error ? err.message : String(err);
772
- if (!hasLiveTrio) {
794
+ if (!liveTrioPending) {
773
795
  // No WS fallback — a failed static fetch is terminal.
774
796
  setStatus(refs, `static mount failed: ${message}`, 'error');
775
797
  emitBootFailure('UI_INITIALIZE_FAILED', message);
776
798
  return { ok: false, mountedRender };
777
799
  }
800
+ staticMountFailure = message;
778
801
  // A live trio is present — the WS ack will deliver the render.
779
802
  // The seed was best-effort; fall through to subscribe. Warn so a
780
803
  // degraded boot (slower first paint, ack-only delivery) is visible
@@ -802,8 +825,33 @@ export async function bootSequence(opts) {
802
825
  // ladder at its tail). Gating the terminal rung behind the WS
803
826
  // trio's presence disabled it on precisely the host class it was
804
827
  // built for.
805
- const staticOnlyNoBridge = !hasLiveTrio && getCurrentApp() === null;
806
- 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
+ }
807
855
  const message = 'bootstrap carries neither static content (codeUrl/codeB64/kind) nor a live trio (wsUrl/wsToken)';
808
856
  setStatus(refs, message, 'error');
809
857
  retireShellLoadingIndicator(doc);
@@ -839,11 +887,11 @@ export async function bootSequence(opts) {
839
887
  // `/api/sessions/<id>/stream` URL; SSE `id:` = ledger sequence = the
840
888
  // SAME cursor model as the WS `sinceSequence` replay and polling
841
889
  // ticks — switching rungs does not lose events.
842
- const sseUrl = typeof meta.sseUrl === 'string' && meta.sseUrl.length > 0
843
- ? meta.sseUrl
890
+ const sseUrl = typeof liveMeta.sseUrl === 'string' && liveMeta.sseUrl.length > 0
891
+ ? liveMeta.sseUrl
844
892
  : undefined;
845
- const pollingBaseUrl = typeof meta.pollingUrl === 'string' && meta.pollingUrl.length > 0
846
- ? meta.pollingUrl
893
+ const pollingBaseUrl = typeof liveMeta.pollingUrl === 'string' && liveMeta.pollingUrl.length > 0
894
+ ? liveMeta.pollingUrl
847
895
  : undefined;
848
896
  // One shared cursor for the whole ladder — SSE deliveries advance it
849
897
  // (via onSequence), the polling + bridge descriptors read it per
@@ -857,87 +905,111 @@ export async function bootSequence(opts) {
857
905
  const ladderCursor = sseUrl !== undefined || pollingBaseUrl !== undefined || bridgeApp !== null
858
906
  ? createSequenceCursor(meta.lastSequence ?? 0)
859
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;
860
1007
  let handle;
861
1008
  try {
862
- handle = await connectFn({
863
- meta,
864
- registry: activeRegistry,
865
- // Live-channel diagnostics tap — every channel_* event the
866
- // transports emit (failover swaps, polling budget exhaustion,
867
- // SSE lifecycle) lands in the telemetry buffer. Without this the
868
- // ladder's story on sandboxed hosts is unobservable.
869
- ...(telemetry !== null ? { logger: telemetry.channelLogger } : {}),
870
- onStatusChange: (status) => {
871
- telemetry?.record(`status.${status}`);
872
- setStatus(refs, status === 'connected' ? 'Connected.' : `Connection ${status}…`, status);
873
- // Propagate WS status to the per-channel transport router so
874
- // it can flip WS-bound channels into polling
875
- // fallback (on disconnect) and re-send `channel_subscribe`
876
- // on reconnect. No-op when renderer wiring is absent.
877
- if (renderer !== null) {
878
- renderer.channelTransport.onWsStatusChange(status);
879
- }
880
- },
881
- // Forward typed errors from subscribe through the runtime's
882
- // own emitter — connectViaRegistry classifies transport + auth
883
- // + version + protocol errors on its own, we just plumb them
884
- // along.
885
- ...(onProtocolError !== undefined ? { onProtocolError } : {}),
886
- // Forward observability emissions — connectFn owns the
887
- // schema-version-mismatch + subscribe-failed kinds.
888
- ...(onObserve !== undefined ? { onObserve } : {}),
889
- // Reconnect-with-rebootstrap — on every ack received AFTER the
890
- // initial handshake settled, reapply the server's authoritative
891
- // `render` snapshot. A render or update that landed during a WS
892
- // dropout window restores here without an agent re-prompt.
893
- onResubscribeAck: (ack) => {
894
- void applyAck(ack);
895
- },
896
- // Failover-ladder fallback rungs (WS → SSE → polling →
897
- // bridge-pull), composed once at bind time from the
898
- // server-stamped wsToken-gated URLs + `meta.lastSequence`
899
- // (shared cursor seed). FailoverHandle descends a rung on each
900
- // 'failed'; the bridge rung is terminal (never emits 'failed').
901
- //
902
- // Same cursor model as the WS subscribe `sinceSequence` replay
903
- // path — switching transports does not lose events.
904
- ...(sseUrl !== undefined && ladderCursor !== undefined
905
- ? {
906
- sse: {
907
- url: sseUrl,
908
- initialSinceSequence: ladderCursor.get(),
909
- onSequence: (seq) => {
910
- ladderCursor.advance(seq);
911
- },
912
- },
913
- }
914
- : {}),
915
- ...(pollingBaseUrl !== undefined && ladderCursor !== undefined
916
- ? {
917
- polling: buildEventsPolling({
918
- baseUrl: pollingBaseUrl,
919
- cursor: ladderCursor,
920
- }),
921
- }
922
- : {}),
923
- // Bridge-pull terminal rung — composed whenever the App handle
924
- // is bound (it always is by this point in the boot: connectApp
925
- // succeeded and setCurrentApp ran above), INDEPENDENT of
926
- // sseUrl/pollingUrl presence. This is the universal floor for
927
- // CSP-jailed hosts (claude.ai) where the iframe has no network
928
- // path at all: the ledger is pulled via `ggui_runtime_pull`
929
- // `tools/call`s over the host's postMessage bridge. Shares the
930
- // SAME ladder cursor — a demotion into the bridge resumes from
931
- // whatever the rungs above already delivered.
932
- ...(bridgeApp !== null && ladderCursor !== undefined
933
- ? {
934
- bridge: buildBridgePolling({
935
- callTool: (name, args) => bridgeApp.callServerTool({ name, arguments: args }),
936
- sessionId: meta.sessionId,
937
- cursor: ladderCursor,
938
- }),
939
- }
940
- : {}),
1009
+ handle = await ladders.connectBoot({
1010
+ credential: bootCredential,
1011
+ sseUrl,
1012
+ pollingUrl: pollingBaseUrl,
941
1013
  });
942
1014
  }
943
1015
  catch (err) {
@@ -956,24 +1028,21 @@ export async function bootSequence(opts) {
956
1028
  emitBootFailure('UPGRADE_REQUIRED', message);
957
1029
  return { ok: false, mountedRender };
958
1030
  }
959
- const message = err instanceof Error ? err.message : String(err);
960
- if (mountedRender !== null) {
961
- // DEGRADE — a transport/auth failure AFTER a static seed already
962
- // painted. Keep the mounted content, skip live updates (props_update
963
- // simply stops arriving), and do NOT tear down or surface a
964
- // bootstrap failure. connectFn already emitted the typed
965
- // `subscribe-failed` observability event on its own onObserve path.
966
- setStatus(refs, `live updates unavailable: ${message}`, 'connecting');
967
- emitCodeReadyOnce();
968
- return { ok: true, mountedRender };
969
- }
970
- if (renderer !== null)
971
- rendererHooks?.teardown?.(renderer);
972
- setStatus(refs, `WS handshake failed: ${message}`, 'error');
973
- emitBootFailure('WS_HANDSHAKE_FAILED', message);
974
- return { ok: false, mountedRender };
1031
+ return endOnSubscribeFailure(err instanceof Error ? err.message : String(err));
975
1032
  }
976
- telemetry?.record('subscribe.resolved', JSON.stringify({ kind: handle.handle.kind, hasAck: handle.ack !== undefined }));
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);
1043
+ }
1044
+ // Health (#1383): booleans only; the handle's kind string stays home.
1045
+ telemetry?.record('subscribe.resolved', JSON.stringify({ hasAck: handle.ack !== undefined }));
977
1046
  // Attach the live transport handle to the renderer — flushes any
978
1047
  // buffered outbound `action` frames that were queued while the
979
1048
  // subscribe handshake completed. `send` narrows to the ws surface:
@@ -981,8 +1050,8 @@ export async function bootSequence(opts) {
981
1050
  // ride app.callServerTool, not the transport), so its sends drop —
982
1051
  // the same posture a post-swap FailoverHandle already has.
983
1052
  const transportSend = (msg) => {
984
- if (handle.handle.kind === 'ws')
985
- handle.handle.send(msg);
1053
+ // The active ladder's WS (ggui#1496): after a rebind, the new one's.
1054
+ ladders.send(msg);
986
1055
  };
987
1056
  if (renderer !== null && rendererHooks?.attachManager !== undefined) {
988
1057
  rendererHooks.attachManager(renderer, { send: transportSend });
@@ -1015,6 +1084,14 @@ export async function bootSequence(opts) {
1015
1084
  if (handle.ack !== undefined) {
1016
1085
  await applyAck(handle.ack);
1017
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
+ }
1018
1095
  setConnectedStatus(refs);
1019
1096
  // Lifecycle `code-ready` — terminal happy state. Emitted ONCE per boot
1020
1097
  // (a static+live meta already fired it from the seed mount, so this is
@@ -1025,32 +1102,20 @@ export async function bootSequence(opts) {
1025
1102
  return { ok: true, mountedRender };
1026
1103
  }
1027
1104
  /**
1028
- * Build a minimal `ChannelRegistry` for boot paths without renderer
1029
- * wiring (boot.test.ts + the C7a placeholder-only spec). The registry
1030
- * carries just the `render` handler — which logs status but does not
1031
- * mount React — so consumers can observe bootstrap-orchestration
1032
- * outcomes without paying React import cost. Every other frame type
1033
- * silently drops. Production boots through `bootProduction` which
1034
- * 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.
1035
1111
  */
1036
- function createPlaceholderRegistry(params) {
1037
- const registry = new ChannelRegistry({
1038
- subscribeFrameBuilder: () => ({
1039
- type: 'subscribe',
1040
- payload: {
1041
- sessionId: params.meta.sessionId,
1042
- appId: params.meta.appId,
1043
- ...(params.meta.wsToken !== undefined
1044
- ? { wsToken: params.meta.wsToken }
1045
- : {}),
1046
- },
1112
+ function createPlaceholderHandlers(params) {
1113
+ return [
1114
+ createRenderHandler({
1115
+ statusRefs: params.statusRefs,
1116
+ pinnedSessionId: params.pinnedSessionId,
1047
1117
  }),
1048
- });
1049
- registry.register(createRenderHandler({
1050
- statusRefs: params.statusRefs,
1051
- pinnedSessionId: params.pinnedSessionId,
1052
- }));
1053
- return registry;
1118
+ ];
1054
1119
  }
1055
1120
  /**
1056
1121
  * Frame dispatch lives inside `@ggui-ai/live-channel`'s
@@ -1083,52 +1148,58 @@ function shouldAutostart() {
1083
1148
  return true;
1084
1149
  return process.env?.GGUI_RENDERER_AUTOSTART !== 'false';
1085
1150
  }
1086
- // =============================================================================
1087
- // Self-contained bootstrap (`window.__GGUI_META__`).
1088
- //
1089
- // The default boot path (postMessage `ui/initialize` → parse the
1090
- // `_meta["ai.ggui/render"]` slice → open WebSocket → subscribe →
1091
- // render from frames) is strictly first-party: it requires the host to
1092
- // speak ggui's custom postMessage protocol AND a reachable live-channel
1093
- // WebSocket the renderer can subscribe against. MCP Apps hosts in the
1094
- // wild (Claude Desktop, claude.ai web) speak only the canonical MCP
1095
- // Apps lifecycle and have no commitment to forward the slice key back
1096
- // through `ui/initialize`. The full first-party path stays intact for
1097
- // callers that own both ends; this self-contained path is what makes
1098
- // the same runtime bundle work in third-party MCP Apps hosts.
1099
- //
1100
- // Contract: when the embedding HTML inlines a global of shape
1101
- // { sessionId: string, appId: string, componentCode: string }
1102
- // (where `componentCode` is base64-encoded compiled ES module source
1103
- // of a React component) BEFORE this bundle's `<script type="module">`
1104
- // executes, the runtime takes over synchronously, mounts the compiled
1105
- // component, and never speaks postMessage / opens a WebSocket. The
1106
- // global is read at module load — a global set later (via deferred
1107
- // scripts, async imports) is too late and the runtime falls through
1108
- // to the legacy postMessage path.
1109
- //
1110
- // `componentCode` carries base64 because the embedding HTML inlines
1111
- // it as a JS string literal next to other `<script>` content; raw
1112
- // JS source contains every character that breaks string-literal
1113
- // embedding (quotes, backticks, `</script>`, newlines, backslash
1114
- // escapes). Base64 sidesteps every escape concern with a 4/3 size
1115
- // overhead that's negligible compared to the network/round-trip
1116
- // savings of skipping postMessage + WS bootstrap.
1117
- // =============================================================================
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
+ }
1118
1192
  /**
1119
1193
  * Read `globalThis.__GGUI_META__` synchronously, validate against
1120
- * 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
1121
1195
  * on absence / malformation).
1122
1196
  *
1123
- * Thin wrapper around {@link parseMetaFromGlobal}; preserved for
1124
- * back-compat (downstream consumers + tests). Returns `null` instead
1125
- * of {@link McpAppAiGguiMetaParseResult} to match the historical
1126
- * reader contract — the autostart resolver only needs the "valid
1127
- * 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.
1128
1200
  */
1129
1201
  export function readSelfContainedMeta() {
1130
- const result = parseMetaFromGlobal();
1131
- return result.ok ? result.meta : null;
1202
+ return bootMetaOf(parseMetaFromGlobal());
1132
1203
  }
1133
1204
  /**
1134
1205
  * Parse the bootstrap meta's `propsJson` string into a {@link JsonObject}.
@@ -1251,6 +1322,43 @@ export function hasStaticContentMeta(meta) {
1251
1322
  (typeof meta.codeB64 === 'string' && meta.codeB64.length > 0) ||
1252
1323
  (typeof meta.kind === 'string' && meta.kind.length > 0));
1253
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
+ }
1254
1362
  /**
1255
1363
  * Decode a `codeB64` slice field to UTF-8 component source. `atob`
1256
1364
  * yields a byte string; routing through `TextDecoder` restores
@@ -1286,8 +1394,7 @@ function decodeCodeB64(codeB64) {
1286
1394
  * impossible because the shell never inspects the slice-meta shape.
1287
1395
  */
1288
1396
  export function extractMetaFromToolResult(params) {
1289
- const result = parseMetaFromToolResult(params);
1290
- return result.ok ? result.meta : null;
1397
+ return bootMetaOf(parseMetaFromToolResult(params));
1291
1398
  }
1292
1399
  /**
1293
1400
  * Drain `window.__GGUI_PENDING_TOOL_RESULTS__` — the buffer the
@@ -1314,9 +1421,9 @@ export function readPendingToolResults() {
1314
1421
  if (!Array.isArray(raw) || raw.length === 0)
1315
1422
  return null;
1316
1423
  for (let i = raw.length - 1; i >= 0; i--) {
1317
- const meta = extractMetaFromToolResult(raw[i]);
1318
- if (meta !== null)
1319
- return meta;
1424
+ const boot = extractMetaFromToolResult(raw[i]);
1425
+ if (boot !== null)
1426
+ return boot;
1320
1427
  }
1321
1428
  return null;
1322
1429
  }
@@ -1366,9 +1473,9 @@ function awaitToolResultMetaFromApp(app, timeoutMs) {
1366
1473
  const handler = (params) => {
1367
1474
  if (settled)
1368
1475
  return;
1369
- const meta = extractMetaFromToolResult(params);
1370
- if (meta !== null) {
1371
- settle(meta);
1476
+ const boot = extractMetaFromToolResult(params);
1477
+ if (boot !== null) {
1478
+ settle(boot);
1372
1479
  return;
1373
1480
  }
1374
1481
  // Read-plane door (ggui#537): identity-only result → resolve the
@@ -1421,8 +1528,7 @@ export async function resolveMetaViaReadDoor(app, locator) {
1421
1528
  const parsed = parseMcpAppAiGguiRenderMeta(envelope, READ_DOOR_REPORTERS);
1422
1529
  if (!parsed.ok || parsed.meta === undefined)
1423
1530
  return null;
1424
- const validated = validateMeta(parsed.meta);
1425
- return validated.ok ? validated.meta : null;
1531
+ return bootMetaOf(validateMeta(parsed.meta));
1426
1532
  }
1427
1533
  function awaitToolResultMeta(timeoutMs) {
1428
1534
  if (typeof window === 'undefined')
@@ -1445,9 +1551,9 @@ function awaitToolResultMeta(timeoutMs) {
1445
1551
  m.method !== 'ui/notifications/tool-result') {
1446
1552
  return;
1447
1553
  }
1448
- const meta = extractMetaFromToolResult(m.params);
1449
- if (meta !== null) {
1450
- settle({ kind: 'meta', meta });
1554
+ const boot = extractMetaFromToolResult(m.params);
1555
+ if (boot !== null) {
1556
+ settle({ kind: 'meta', boot });
1451
1557
  return;
1452
1558
  }
1453
1559
  // Identity-only result (ggui#537): settle NOW with the locator so
@@ -1516,56 +1622,6 @@ function fnv1aHex(payload) {
1516
1622
  }
1517
1623
  return (hash >>> 0).toString(16).padStart(8, '0');
1518
1624
  }
1519
- /**
1520
- * Render a submit-action's `data` payload as a short inline string
1521
- * for embedding in a `ui/message` consent prompt. Goal: human-
1522
- * readable, not a JSON dump. Falls back to truncated JSON for
1523
- * nested values so the prompt doesn't drop information silently.
1524
- *
1525
- * Exported for unit-testing — an earlier implementation returned `''`
1526
- * for primitive payloads (strings/numbers/booleans), which silently
1527
- * vaporised the chip's actual text from the consent prompt and made
1528
- * the LLM think every dispatch was a contentless "Please proceed
1529
- * with **<intent>**" request.
1530
- */
1531
- export function formatSubmitActionDataInline(data) {
1532
- if (data === null || data === undefined)
1533
- return '';
1534
- // Bare primitives: render verbatim. Strings unquoted (most legible
1535
- // in a "Please proceed with X (foo)" sentence). Numbers / booleans
1536
- // stringified.
1537
- if (typeof data === 'string')
1538
- return data;
1539
- if (typeof data === 'number' || typeof data === 'boolean')
1540
- return String(data);
1541
- // Arrays: short JSON, truncated. Length cap mirrors the per-entry
1542
- // cap below so the consent line stays a single human-readable phrase.
1543
- if (Array.isArray(data)) {
1544
- const json = JSON.stringify(data);
1545
- return json.length > 60 ? `${json.slice(0, 57)}…` : json;
1546
- }
1547
- if (typeof data !== 'object')
1548
- return '';
1549
- const entries = Object.entries(data);
1550
- if (entries.length === 0)
1551
- return '';
1552
- const parts = entries.map(([k, v]) => {
1553
- if (v === null)
1554
- return `${k}: null`;
1555
- if (v === undefined)
1556
- return `${k}: undefined`;
1557
- if (typeof v === 'string')
1558
- return `${k}: ${v}`;
1559
- if (typeof v === 'number' || typeof v === 'boolean')
1560
- return `${k}: ${v}`;
1561
- // `JSON.stringify` returns `undefined` for unrepresentable values
1562
- // (e.g. a `function` field on a form payload object) — guard so
1563
- // the subsequent `.length` access doesn't crash the dispatch path.
1564
- const json = JSON.stringify(v) ?? String(v);
1565
- return `${k}: ${json.length > 40 ? `${json.slice(0, 37)}…` : json}`;
1566
- });
1567
- return parts.join(', ');
1568
- }
1569
1625
  /**
1570
1626
  * Post an arbitrary JSON-RPC envelope to the iframe's parent
1571
1627
  * window. Internal helper shared across {@link emitAudit} (the
@@ -1646,7 +1702,8 @@ function callAppRequestDisplayMode(params) {
1646
1702
  * migrated to `app.callServerTool` — see emitAudit for the same
1647
1703
  * posture).
1648
1704
  */
1649
- const productionContextSnapshotPoster = {
1705
+ /** @internal — exported for unit tests. */
1706
+ export const productionContextSnapshotPoster = {
1650
1707
  postUpdateModelContext: (params) => {
1651
1708
  callAppUpdateModelContext(params);
1652
1709
  },
@@ -1655,14 +1712,11 @@ const productionContextSnapshotPoster = {
1655
1712
  jsonrpc: '2.0',
1656
1713
  id: Math.floor(Math.random() * 1e9),
1657
1714
  method: 'tools/call',
1658
- params: {
1659
- name: 'ggui_runtime_sync_context',
1660
- arguments: {
1661
- sessionId: params.sessionId,
1662
- appId: params.appId,
1663
- snapshot: params.snapshot,
1664
- },
1665
- },
1715
+ params: withViewProof('ggui_runtime_sync_context', {
1716
+ sessionId: params.sessionId,
1717
+ appId: params.appId,
1718
+ snapshot: params.snapshot,
1719
+ }),
1666
1720
  });
1667
1721
  },
1668
1722
  };
@@ -1681,10 +1735,10 @@ const productionContextSnapshotPoster = {
1681
1735
  * `CallToolResult` in `{result: ...}`.
1682
1736
  *
1683
1737
  * Drops with an error envelope when no App is bound — the dispatch
1684
- * pipeline classifies that as a transport error and routes to the
1685
- * `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.
1686
1740
  */
1687
- async function callServerToolSpec(toolName, args) {
1741
+ async function callServerToolSpec(toolName, args, proof) {
1688
1742
  const app = getCurrentApp();
1689
1743
  if (app === null) {
1690
1744
  return {
@@ -1694,10 +1748,7 @@ async function callServerToolSpec(toolName, args) {
1694
1748
  };
1695
1749
  }
1696
1750
  try {
1697
- const result = await app.callServerTool({
1698
- name: toolName,
1699
- arguments: args,
1700
- });
1751
+ const result = await app.callServerTool(withViewProof(toolName, args, proof));
1701
1752
  return { jsonrpc: '2.0', result: result };
1702
1753
  }
1703
1754
  catch (err) {
@@ -1784,17 +1835,14 @@ export function emitAudit(args) {
1784
1835
  jsonrpc: '2.0',
1785
1836
  id: Math.floor(Math.random() * 1e9),
1786
1837
  method: 'tools/call',
1787
- params: {
1788
- name: args.toolName,
1789
- arguments: {
1790
- kind: args.kind,
1791
- payload: args.payload,
1792
- sessionId: args.sessionId,
1793
- appId: args.appId,
1794
- actionId: args.actionId,
1795
- firedAt: args.firedAt,
1796
- },
1797
- },
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
+ }),
1798
1846
  });
1799
1847
  }
1800
1848
  /**
@@ -2097,6 +2145,191 @@ function hideToast(el) {
2097
2145
  }
2098
2146
  /** The single action-toast element, reused for every message. */
2099
2147
  const ACTION_TOAST_ID = '__ggui-action-toast__';
2148
+ /**
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.
2161
+ *
2162
+ * Without it, a failed gesture would be silent: the visitor could not tell
2163
+ * "the agent is busy" from "the tap was lost".
2164
+ *
2165
+ * Direct DOM (not React-managed) because:
2166
+ * - Works during boot before React mounts.
2167
+ * - Doesn't interfere with the component tree the generator owns.
2168
+ * - Survives React mount transitions inside the iframe.
2169
+ *
2170
+ * Single global toast (per iframe). Auto-dismisses after 2.5s on
2171
+ * `success` / `fallback` outcomes. A successful dispatch draws nothing
2172
+ * here (ggui#1444): its "sending" is spoken only (see
2173
+ * {@link GESTURE_COPY}).
2174
+ *
2175
+ * The visible element is only half the surface (ggui#447). Every
2176
+ * message is also spoken through {@link ensureToastAnnouncer}'s live
2177
+ * regions, and the element itself is hidden from the accessibility
2178
+ * tree so the sentence is not read twice. The one exception is
2179
+ * `action_required`, the only toast the user must OPERATE: it re-enters
2180
+ * the tree as a named button with a tab stop, because a dismissal that
2181
+ * needs a mouse is a dismissal some users cannot perform.
2182
+ *
2183
+ * @internal — runtime-layer concern.
2184
+ */
2185
+ /**
2186
+ * The `label` an action declares on the render's `actionSpec`, trimmed, or
2187
+ * `undefined` when the render declares none (or is a system / MCP-Apps
2188
+ * render, which carries no actionSpec). By contract the label is the
2189
+ * control's own copy, so it is the one name for a tap a visitor may read
2190
+ * (see {@link GESTURE_COPY}).
2191
+ *
2192
+ * @internal — exported for unit tests.
2193
+ */
2194
+ export function declaredActionLabel(render, actionName) {
2195
+ if (render === null || render.type === 'mcpApps' || render.type === 'system')
2196
+ return undefined;
2197
+ const declared = render.actionSpec?.[actionName]?.label;
2198
+ return typeof declared === 'string' && declared.trim() !== '' ? declared.trim() : undefined;
2199
+ }
2200
+ /**
2201
+ * Everything the runtime says to a VISITOR about a gesture (ggui#1444).
2202
+ *
2203
+ * A card's action NAME and the data it sends are the author's and the
2204
+ * agent's vocabulary, never the visitor's: a staging rehearsal drew
2205
+ * "→ chooseReply (id: pricing)" over a card after a chip tap. So no text
2206
+ * here names the action or carries its data. When the action declares a
2207
+ * `label` (by contract the control's own copy, already on screen) the text
2208
+ * names the tap by it, so two failed taps on different controls read as two
2209
+ * different sentences to a screen reader (ggui#447); without one it says
2210
+ * the same thing unnamed. Diagnostics keep the name and data (the
2211
+ * `gesture.*` telemetry records); the screen and the live regions do not.
2212
+ *
2213
+ * `sending` and `sentToChat` are spoken, never drawn: a tap the relay
2214
+ * accepted, or a wake-up the host took, needs no chrome over the card — the
2215
+ * pending look belongs to the card's own control (`useActionPending`), and a
2216
+ * host that takes a wake-up shows the conversation continuing in its own UI.
2217
+ * The rest are drawn because each one tells the visitor something they have
2218
+ * to know or do.
2219
+ */
2220
+ const named = (label, rest, unnamed) => label === undefined ? unnamed : `${label} — ${rest}`;
2221
+ const GESTURE_COPY = {
2222
+ sending: (label) => named(label, 'sending…', 'Sending…'),
2223
+ notDelivered: (label) => `⚠ ${named(label, 'not delivered', 'Not delivered')}`,
2224
+ transportError: (label) => `⚠ ${named(label, 'could not send, try again', 'Could not send — try again')}`,
2225
+ unreachable: (label) => `⚠ ${named(label, 'could not reach the agent', 'Could not reach the agent')}`,
2226
+ sentToChat: (label) => named(label, 'sent to the chat', 'Sent to the chat'),
2227
+ sendToContinue: (label) => `💬 ${named(label, 'agent not listening. Send a message to continue.', 'Agent not listening. Send a message to continue.')}`,
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')}`,
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
+ }
2100
2333
  function showActionToast(text, kind, onDismiss) {
2101
2334
  if (typeof document === 'undefined')
2102
2335
  return;
@@ -2338,9 +2571,14 @@ function emitUserActionDoorbell(args) {
2338
2571
  // `ai.ggui/userAction` to match our other protocol extensions
2339
2572
  // (`ai.ggui/render`, `ai.ggui/bootstrap`, etc.).
2340
2573
  currentTelemetrySink?.record('doorbell.ring', args.sessionId);
2574
+ const doorbellId = Math.floor(Math.random() * 1e9);
2575
+ watchDoorbellReply(doorbellId, (code) => {
2576
+ currentTelemetrySink?.record('doorbell.refused', `${args.sessionId} code=${code}`);
2577
+ args.onRefused();
2578
+ });
2341
2579
  postToParent({
2342
2580
  jsonrpc: '2.0',
2343
- id: Math.floor(Math.random() * 1e9),
2581
+ id: doorbellId,
2344
2582
  method: 'ui/message',
2345
2583
  params: {
2346
2584
  role: 'user',
@@ -2356,6 +2594,54 @@ function emitUserActionDoorbell(args) {
2356
2594
  },
2357
2595
  });
2358
2596
  }
2597
+ /**
2598
+ * How long the view listens for the host's answer to a doorbell. A host
2599
+ * may ask the user before sending the message, so the answer can take a
2600
+ * while; past this the listener is removed and a late answer is ignored.
2601
+ */
2602
+ const DOORBELL_REPLY_WAIT_MS = 5 * 60_000;
2603
+ /**
2604
+ * Listen for the host's JSON-RPC reply to the doorbell `id` (ggui#1314).
2605
+ * The doorbell is posted raw, outside the `App`'s request table, so no
2606
+ * one else correlates it. Only a frame from `window.parent` carrying this
2607
+ * `id` and a `result` or `error` counts; the first such frame ends the
2608
+ * watch. An `error` is the host refusing the doorbell in-band (#1309:
2609
+ * a host delivers a view's `ui/message` or answers it with an error,
2610
+ * never a success), reported with its numeric code (`unknown` when the
2611
+ * host sent none).
2612
+ */
2613
+ function watchDoorbellReply(id, onRefused) {
2614
+ if (typeof window === 'undefined')
2615
+ return;
2616
+ const onMessage = (ev) => {
2617
+ if (ev.source !== window.parent)
2618
+ return;
2619
+ const frame = ev.data;
2620
+ if (frame === null || typeof frame !== 'object')
2621
+ return;
2622
+ if (!('jsonrpc' in frame) || frame.jsonrpc !== '2.0')
2623
+ return;
2624
+ if (!('id' in frame) || frame.id !== id)
2625
+ return;
2626
+ const refused = 'error' in frame;
2627
+ if (!refused && !('result' in frame))
2628
+ return;
2629
+ stop();
2630
+ if (!refused)
2631
+ return;
2632
+ const error = frame.error;
2633
+ const code = error !== null && typeof error === 'object' && 'code' in error && typeof error.code === 'number'
2634
+ ? String(error.code)
2635
+ : 'unknown';
2636
+ onRefused(code);
2637
+ };
2638
+ const stop = () => {
2639
+ window.removeEventListener('message', onMessage);
2640
+ clearTimeout(timer);
2641
+ };
2642
+ window.addEventListener('message', onMessage);
2643
+ const timer = setTimeout(stop, DOORBELL_REPLY_WAIT_MS);
2644
+ }
2359
2645
  /**
2360
2646
  * Latch for the relay-incapability notice (ggui#440). A host that
2361
2647
  * cannot relay fails EVERY gesture, so the explanation is stated once
@@ -2604,6 +2890,14 @@ function resetRelayCueThrottles() {
2604
2890
  cancelRelayCueRetraction();
2605
2891
  }
2606
2892
  /** @internal — exported for unit tests to reset module state. */
2893
+ /**
2894
+ * Test-only: install the telemetry sink the runtime records into (normally
2895
+ * set once at boot), so a test can observe the events a code path posts.
2896
+ * `null` restores the no-sink state.
2897
+ */
2898
+ export function __setTelemetrySinkForTest(sink) {
2899
+ currentTelemetrySink = sink;
2900
+ }
2607
2901
  export function __resetRelayNoticeForTest() {
2608
2902
  relayIncapabilityAnnounced = false;
2609
2903
  runtimeConnectionWriter().set(true);
@@ -2687,7 +2981,7 @@ function resolveRelayCueTarget() {
2687
2981
  * the rest of the session is the same stale-announcement defect the
2688
2982
  * toast half avoids by clearing on hide.
2689
2983
  */
2690
- function announceRelayCue(intent) {
2984
+ function announceRelayCue(intent, label) {
2691
2985
  const now = Date.now();
2692
2986
  if (intent === lastRelayCueAnnouncedIntent &&
2693
2987
  now - lastRelayCueAnnouncedAt < RELAY_CUE_THROTTLE_MS) {
@@ -2697,7 +2991,7 @@ function announceRelayCue(intent) {
2697
2991
  lastRelayCueAnnouncedIntent = intent;
2698
2992
  // `announceToast` cancels any retraction still pending, so the timer
2699
2993
  // armed here is always the only one in flight.
2700
- announceToast(`⚠ ${intent} — not delivered`, 'error');
2994
+ announceToast(GESTURE_COPY.notDelivered(label), 'error');
2701
2995
  relayCueAnnounceTimer = window.setTimeout(() => {
2702
2996
  relayCueAnnounceTimer = undefined;
2703
2997
  retractRelayCueAnnouncement();
@@ -2738,7 +3032,7 @@ function retractRelayCueAnnouncement() {
2738
3032
  * (ggui#447) — the pulse via {@link announceRelayCue}, the fallback via
2739
3033
  * the toast primitive's own announcement.
2740
3034
  */
2741
- function showDeadZoneCue(intent) {
3035
+ function showDeadZoneCue(intent, label) {
2742
3036
  if (typeof document === 'undefined')
2743
3037
  return;
2744
3038
  const target = resolveRelayCueTarget();
@@ -2751,7 +3045,7 @@ function showDeadZoneCue(intent) {
2751
3045
  return;
2752
3046
  ensureRelayCueStyle();
2753
3047
  target.classList.add(RELAY_CUE_CLASS);
2754
- announceRelayCue(intent);
3048
+ announceRelayCue(intent, label);
2755
3049
  // Both cleanup routes cancel the other. Without the `clearTimeout`,
2756
3050
  // a pulse ended early by `animationend` leaves its timer armed, and
2757
3051
  // that timer later strips whatever class is on the element THEN —
@@ -2780,7 +3074,7 @@ function showDeadZoneCue(intent) {
2780
3074
  // a second explanation, so it must clear itself. Throttled because a
2781
3075
  // per-click toast on a host that fails every click is the #426
2782
3076
  // failure mode the latch exists to prevent.
2783
- showActionToast(`⚠ ${intent} — not delivered`, 'error');
3077
+ showActionToast(GESTURE_COPY.notDelivered(label), 'error');
2784
3078
  }
2785
3079
  /**
2786
3080
  * Dispatch a submit-action via the empirically-validated bridge
@@ -2802,20 +3096,21 @@ export function dispatchSubmitAction(args) {
2802
3096
  // the live session — drop the gesture rather than submit a stale
2803
3097
  // view's action against the current head.
2804
3098
  if (mountSuperseded) {
2805
- currentTelemetrySink?.record('gesture.dropped_superseded', args.intent);
3099
+ currentTelemetrySink?.record('gesture.dropped_superseded');
2806
3100
  return;
2807
3101
  }
2808
- const { toolName, intent, data, sessionId, appId } = args;
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();
2809
3106
  // Gesture-path telemetry — the click's own autopsy trail. #471
2810
3107
  // round 12 hit a frame whose channels + beacons were fully healthy
2811
3108
  // while clicks produced NOTHING observable; without a record at the
2812
3109
  // dispatch entry there is no way to tell "handler never fired" from
2813
3110
  // "dispatch swallowed it" on a console-less host.
2814
- currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({ intent, toolName }));
3111
+ currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({ toolName }));
2815
3112
  const firedAt = new Date().toISOString();
2816
3113
  const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
2817
- const inlineData = formatSubmitActionDataInline(data);
2818
- const dataPart = inlineData === '' ? '' : ` (${inlineData})`;
2819
3114
  const uiContext = readLocalUiContext();
2820
3115
  // (1) Silent context update — fires FIRST and ALWAYS. Primes the
2821
3116
  // LLM's widget-context surface regardless of which downstream path
@@ -2857,10 +3152,13 @@ export function dispatchSubmitAction(args) {
2857
3152
  // the user has focused NOW; by the time the relay response settles,
2858
3153
  // focus may have moved on.
2859
3154
  if (!relayIncapabilityAnnounced) {
2860
- showActionToast(`→ ${intent}${dataPart}`, 'pending');
3155
+ // No pill over the card (ggui#1444): the previous gesture's notice goes,
3156
+ // and "sending" is spoken for assistive tech only.
3157
+ dismissActionToast();
3158
+ announceToast(GESTURE_COPY.sending(label), 'pending');
2861
3159
  }
2862
3160
  else if (!isRelayNoticeVisible()) {
2863
- showDeadZoneCue(intent);
3161
+ showDeadZoneCue(intent, label);
2864
3162
  }
2865
3163
  // (2) Try submit_action via host relay. Spec-compliant hosts
2866
3164
  // forward the tools/call to the MCP server's submit_action handler;
@@ -2894,20 +3192,19 @@ export function dispatchSubmitAction(args) {
2894
3192
  appId,
2895
3193
  actionId,
2896
3194
  firedAt,
2897
- });
3195
+ }, { userActivation });
2898
3196
  }
2899
3197
  catch {
2900
- showActionToast(`⚠ ${intent} — transport error`, 'error');
3198
+ showActionToast(GESTURE_COPY.transportError(label), 'error');
2901
3199
  resp = null;
2902
3200
  }
2903
3201
  // Final hop of the gesture autopsy trail — what the relay
2904
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.
2905
3205
  currentTelemetrySink?.record('gesture.result', JSON.stringify({
2906
- intent,
2907
3206
  ok: resp !== null && resp.error === undefined,
2908
- ...(resp?.error?.message !== undefined
2909
- ? { error: resp.error.message.slice(0, 120) }
2910
- : {}),
3207
+ ...(typeof resp?.error?.code === 'number' ? { code: resp.error.code } : {}),
2911
3208
  }));
2912
3209
  // Self-healing (ggui#440): a result envelope arriving at all is
2913
3210
  // proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
@@ -2940,7 +3237,7 @@ export function dispatchSubmitAction(args) {
2940
3237
  // through to the terminal "could not reach the agent" toast a
2941
3238
  // few lines down, which already replaces the stale notice.
2942
3239
  if (classifySubmitActionResponse(resp) === 'success') {
2943
- showActionToast(`→ ${intent}${dataPart}`, 'pending');
3240
+ dismissActionToast();
2944
3241
  }
2945
3242
  }
2946
3243
  if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
@@ -2964,14 +3261,29 @@ export function dispatchSubmitAction(args) {
2964
3261
  // `ggui_consume`; servers that CAN answer always send an
2965
3262
  // explicit `true` (the factory wires the registry
2966
3263
  // unconditionally), so confirmed-consumer hosts stay quiet.
2967
- showActionToast(hostCanReceiveMessages()
2968
- ? `💬 ${intent}${dataPart} — agent not listening, sent to chat`
2969
- : `💬 ${intent}${dataPart} — agent not listening. Send a message to continue.`, 'action_required');
3264
+ // A host that takes the wake-up carries it in its own UI (the
3265
+ // conversation visibly continues, or a prepared message lands in
3266
+ // its input), so nothing is drawn over the card (ggui#1444): the tap
3267
+ // is spoken to assistive tech only. The card draws only when the
3268
+ // visitor must act — this host cannot receive a message at all, or
3269
+ // it refuses this one (`onRefused` below).
3270
+ if (hostCanReceiveMessages()) {
3271
+ announceToast(GESTURE_COPY.sentToChat(label), 'pending');
3272
+ }
3273
+ else {
3274
+ showActionToast(GESTURE_COPY.sendToContinue(label), 'action_required');
3275
+ }
2970
3276
  emitUserActionDoorbell({
2971
3277
  intent,
2972
3278
  sessionId,
2973
3279
  actionId,
2974
3280
  submittedAt: firedAt,
3281
+ // The host refused the wake-up in-band (ggui#1314), so the
3282
+ // visitor has to act: say what. The gesture itself is safe on
3283
+ // the pipe; only the wake-up failed.
3284
+ onRefused: () => {
3285
+ showActionToast(GESTURE_COPY.chatRefused(label), 'action_required');
3286
+ },
2975
3287
  });
2976
3288
  return;
2977
3289
  }
@@ -2980,6 +3292,17 @@ export function dispatchSubmitAction(args) {
2980
3292
  // listener dismisses it when ggui_consume drains the event.
2981
3293
  return;
2982
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
+ }
2983
3306
  // Enqueue failed (pipe gone / transport error / host has no relay).
2984
3307
  // The gesture is not on any pipe, so a doorbell would point at an
2985
3308
  // empty queue; no `ui/message` is emitted either way.
@@ -3053,7 +3376,7 @@ export function dispatchSubmitAction(args) {
3053
3376
  announceRelayIncapability(confirmedRefusal ? 'confirmed-refusal' : 'advert-silent', { sessionId, appId });
3054
3377
  return;
3055
3378
  }
3056
- showActionToast(`⚠ ${intent} — could not reach the agent`, 'error');
3379
+ showActionToast(GESTURE_COPY.unreachable(label), 'error');
3057
3380
  })();
3058
3381
  }
3059
3382
  /**
@@ -3170,13 +3493,14 @@ export function resolveDispatchToolName() {
3170
3493
  * @internal — exported for unit tests + production reuse.
3171
3494
  */
3172
3495
  export function routeDispatch(args) {
3173
- const { actionName, data, meta, dispatchToolName } = args;
3496
+ const { actionName, data, meta, dispatchToolName, label } = args;
3174
3497
  dispatchSubmitAction({
3175
3498
  toolName: dispatchToolName,
3176
3499
  intent: actionName,
3177
3500
  data,
3178
3501
  sessionId: meta.sessionId,
3179
3502
  appId: meta.appId,
3503
+ ...(label !== undefined ? { label } : {}),
3180
3504
  });
3181
3505
  }
3182
3506
  /**
@@ -3432,15 +3756,22 @@ export function __resetInterceptorsForTest() {
3432
3756
  * its event system. The App handle is passed by the boot path
3433
3757
  * directly (pre-connect, so `getCurrentApp()` is not yet set).
3434
3758
  */
3435
- function installPersistentToolResultListener(app) {
3759
+ function installPersistentToolResultListener(app, viewRoots) {
3436
3760
  if (persistentToolResultListenerApps.has(app))
3437
3761
  return;
3438
3762
  persistentToolResultListenerApps.add(app);
3439
3763
  let lastMetaKey = null;
3440
3764
  app.addEventListener('toolresult', (params) => {
3441
- const meta = extractMetaFromToolResult(params);
3442
- 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)
3443
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);
3444
3775
  // Cheap dedupe — the host may emit the same tool-result more
3445
3776
  // than once (claude.ai re-broadcasts on iframe re-attach).
3446
3777
  // Re-mounting the same slice meta would flicker without changing
@@ -3552,7 +3883,7 @@ function readLiveBootstrapShape() {
3552
3883
  * fallback share one call site — keeps the WS-driven boot semantics
3553
3884
  * single-sourced.
3554
3885
  *
3555
- * `preResolvedMeta` is threaded in from the autostart resolver when
3886
+ * `preResolved` is threaded in from the autostart resolver when
3556
3887
  * it has already discovered slice meta — via inline `__GGUI_META__`
3557
3888
  * global, a buffered `__GGUI_PENDING_TOOL_RESULTS__` entry, or an
3558
3889
  * early `ui/notifications/tool-result` postMessage. Threading skips
@@ -3560,7 +3891,7 @@ function readLiveBootstrapShape() {
3560
3891
  * re-await the same postMessage or re-parse the same global), saving
3561
3892
  * up to the 30s postMessage timeout for spec-strict hosts.
3562
3893
  */
3563
- function runBootProduction(preResolvedMeta, preResolvedLocator) {
3894
+ function runBootProduction(preResolved, preResolvedLocator) {
3564
3895
  const { app, transport } = createDefaultApp();
3565
3896
  void bootProduction({
3566
3897
  doc: document,
@@ -3575,7 +3906,7 @@ function runBootProduction(preResolvedMeta, preResolvedLocator) {
3575
3906
  },
3576
3907
  onObserve: postObservabilityToParent,
3577
3908
  onLifecycle: postLifecycleToParent,
3578
- ...(preResolvedMeta !== undefined ? { preResolvedMeta } : {}),
3909
+ ...(preResolved !== undefined ? { preResolved } : {}),
3579
3910
  ...(preResolvedLocator !== undefined ? { preResolvedLocator } : {}),
3580
3911
  });
3581
3912
  }
@@ -3595,7 +3926,7 @@ if (shouldAutostart() && typeof window !== 'undefined') {
3595
3926
  // 3. Live `ui/notifications/tool-result` postMessage — the
3596
3927
  // spec-canonical channel for spec-strict hosts (`<AppRenderer>`,
3597
3928
  // ChatGPT, claude.ai). Caught meta threads through as
3598
- // `preResolvedMeta` so `bootSequence` doesn't re-await it.
3929
+ // `preResolved` so `bootSequence` doesn't re-await it.
3599
3930
  //
3600
3931
  // `readLiveBootstrapShape` still short-circuits the 30s tool-result
3601
3932
  // wait when a live-channel envelope is already inlined — OSS embedded
@@ -3624,7 +3955,7 @@ if (shouldAutostart() && typeof window !== 'undefined') {
3624
3955
  if (pre === null)
3625
3956
  runBootProduction();
3626
3957
  else if (pre.kind === 'meta')
3627
- runBootProduction(pre.meta);
3958
+ runBootProduction(pre.boot);
3628
3959
  else
3629
3960
  runBootProduction(undefined, pre.locator);
3630
3961
  });
@@ -3803,7 +4134,30 @@ async function bootProduction(opts) {
3803
4134
  renderListeners.delete(listener);
3804
4135
  };
3805
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);
3806
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
+ });
3807
4161
  const dispatchToolName = resolveDispatchToolName();
3808
4162
  // Native-idiom interceptors — the replacement for the retired
3809
4163
  // openLink / requestDisplayMode wire primitives. Installed here
@@ -3850,7 +4204,15 @@ async function bootProduction(opts) {
3850
4204
  sessionId: meta.sessionId,
3851
4205
  appId: meta.appId,
3852
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)),
3853
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
+ },
3854
4216
  manager,
3855
4217
  streamBus,
3856
4218
  onDispatchEnvelope: (envelope) => {
@@ -3866,6 +4228,8 @@ async function bootProduction(opts) {
3866
4228
  || payload.action.length === 0) {
3867
4229
  return;
3868
4230
  }
4231
+ // The declared label names the tap in visitor copy (ggui#1444).
4232
+ const label = declaredActionLabel(currentRender, payload.action);
3869
4233
  routeDispatch({
3870
4234
  actionName: payload.action,
3871
4235
  data: payload.data,
@@ -3874,6 +4238,7 @@ async function bootProduction(opts) {
3874
4238
  appId: meta.appId,
3875
4239
  },
3876
4240
  dispatchToolName,
4241
+ ...(label !== undefined ? { label } : {}),
3877
4242
  });
3878
4243
  },
3879
4244
  });
@@ -3976,6 +4341,8 @@ async function bootProduction(opts) {
3976
4341
  return hostPalette !== undefined ? { hostPalette } : {};
3977
4342
  })(),
3978
4343
  ...(wrapOuter !== undefined ? { wrapOuter } : {}),
4344
+ // The boundary's give-up is reported to the server (ggui#1679).
4345
+ onRenderFailure: renderFailureReporter.report,
3979
4346
  };
3980
4347
  };
3981
4348
  /**
@@ -4090,38 +4457,28 @@ async function bootProduction(opts) {
4090
4457
  }
4091
4458
  : {}),
4092
4459
  });
4093
- // B3b — live-channel registry owns dispatch for every routable
4094
- // WS frame type. Each handler closes over the renderer state it
4095
- // needs. `bootSequence` calls `connectFn` (default
4096
- // `connectViaRegistry`) which registers the `ack` + `error`
4097
- // handshake handlers and binds the WS transport — frames then
4098
- // arrive directly through the registered handlers without an
4099
- // 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.
4100
4466
  //
4101
4467
  // CLIENT_SUPPORTED_VERSIONS handshake is enforced inside
4102
4468
  // `connectViaRegistry`'s ack-handler closure (NOT here).
4103
- const channelRegistry = new ChannelRegistry({
4104
- subscribeFrameBuilder: () => ({
4105
- type: 'subscribe',
4106
- payload: {
4107
- sessionId: meta.sessionId,
4108
- appId: meta.appId,
4109
- ...(meta.wsToken !== undefined
4110
- ? { wsToken: meta.wsToken }
4111
- : {}),
4112
- },
4113
- }),
4114
- });
4115
- channelRegistry.register(createRenderHandler({
4469
+ const channelHandlers = [];
4470
+ channelHandlers.push(createRenderHandler({
4116
4471
  statusRefs,
4117
4472
  pinnedSessionId: meta.sessionId,
4118
4473
  applyRender,
4119
4474
  getChannelTransport: () => channelTransport,
4120
4475
  }));
4121
- channelRegistry.register(createDataHandler({
4476
+ const streamSeq = createStreamSeqTracker();
4477
+ channelHandlers.push(createDataHandler({
4122
4478
  getCurrentGguiSession: () => currentRender,
4123
4479
  streamBus,
4124
4480
  validatorCtx,
4481
+ streamSeq,
4125
4482
  }));
4126
4483
  // Freeze latch (#483). This mount's own history epoch, read once
4127
4484
  // from its boot meta: render → 0, an update-minted card → its N,
@@ -4139,18 +4496,18 @@ async function bootProduction(opts) {
4139
4496
  currentTelemetrySink?.record('epoch.frozen', String(selfEpoch));
4140
4497
  applyFreezeCue(renderInto);
4141
4498
  };
4142
- channelRegistry.register(createPropsUpdateHandler({
4499
+ channelHandlers.push(createPropsUpdateHandler({
4143
4500
  getCurrentGguiSession: () => currentRender,
4144
4501
  applyRender,
4145
4502
  getSelfEpoch: () => selfEpoch,
4146
4503
  onSuperseded: supersede,
4147
4504
  isSuperseded: () => superseded,
4148
4505
  }));
4149
- channelRegistry.register(createDrainAckHandler({ dispatch: dispatchDrainAck }));
4150
- channelRegistry.register(createChannelPayloadHandler({
4506
+ channelHandlers.push(createDrainAckHandler({ dispatch: dispatchDrainAck }));
4507
+ channelHandlers.push(createChannelPayloadHandler({
4151
4508
  getChannelTransport: () => channelTransport,
4152
4509
  }));
4153
- channelRegistry.register(createChannelErrorHandler({
4510
+ channelHandlers.push(createChannelErrorHandler({
4154
4511
  getChannelTransport: () => channelTransport,
4155
4512
  }));
4156
4513
  return {
@@ -4162,7 +4519,8 @@ async function bootProduction(opts) {
4162
4519
  validatorCtx,
4163
4520
  manager,
4164
4521
  channelTransport,
4165
- channelRegistry,
4522
+ channelHandlers,
4523
+ streamSeq,
4166
4524
  composedGadgets,
4167
4525
  };
4168
4526
  },
@@ -4193,9 +4551,7 @@ async function bootProduction(opts) {
4193
4551
  renderer,
4194
4552
  ...(opts.onObserve !== undefined ? { onObserve: opts.onObserve } : {}),
4195
4553
  ...(opts.onLifecycle !== undefined ? { onLifecycle: opts.onLifecycle } : {}),
4196
- ...(opts.preResolvedMeta !== undefined
4197
- ? { preResolvedMeta: opts.preResolvedMeta }
4198
- : {}),
4554
+ ...(opts.preResolved !== undefined ? { preResolved: opts.preResolved } : {}),
4199
4555
  ...(opts.preResolvedLocator !== undefined
4200
4556
  ? { preResolvedLocator: opts.preResolvedLocator }
4201
4557
  : {}),