@ggui-ai/iframe-runtime 0.7.0 → 0.9.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.
package/dist/runtime.js CHANGED
@@ -7,9 +7,11 @@ import { buildRootWireConfig, StreamBus, } from './wire-config.js';
7
7
  import { createChannelTransportRouter, } from './channel-transport.js';
8
8
  import { RelayIncapableError } from './relay-incapability.js';
9
9
  import { ChannelRegistry } from '@ggui-ai/live-channel';
10
+ import { createTelemetrySink, } from './runtime-telemetry.js';
10
11
  import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createPropsUpdateHandler, createRenderHandler, } from './channels/index.js';
11
12
  import { connectViaRegistry, } from './registry-subscribe.js';
12
- import { buildEventsPolling } from './events-polling.js';
13
+ import { buildBridgePolling, buildEventsPolling, createSequenceCursor, } from './events-polling.js';
14
+ import { unwrapCallToolResult } from './call-tool-unwrap.js';
13
15
  import { ensureStatusDom, setStatus, setConnectedStatus, } from './status-dom.js';
14
16
  import { mergeReservedValidators, setActiveValidatorSet, } from './validation.js';
15
17
  import { loadCompiledValidatorsFromUrl } from './compiled-validators.js';
@@ -386,16 +388,24 @@ export async function bootSequence(opts) {
386
388
  // In-iframe UI-feedback affordance (ggui#244) — mounted adjacent to
387
389
  // the session root, gated inside the helper on `window.parent !==
388
390
  // window` (top-level `/r/<shortCode>` tabs have no `ggui:observe`
389
- // egress, so they get NO affordance). Interaction emits a
390
- // `ui-feedback` ObservabilityEvent through the boot path's emitter;
391
- // when no emitter was injected the postMessage-to-parent default
392
- // still gives the mounted affordance a live sink (the gate already
393
- // proved a parent exists). Fire-and-forget: chrome never blocks or
394
- // fails the boot.
395
- void mountUiFeedbackChrome(doc, {
396
- emit: onObserve ?? postObservabilityToParent,
397
- sessionId: meta.sessionId,
398
- });
391
+ // egress, so they get NO affordance) AND here on a LIVE SINK: an
392
+ // injected `onObserve` emitter, or a first-party embed host — the
393
+ // only parent that consumes the `ggui:observe` envelope
394
+ // (mcp-app-iframe-host answers `ui/initialize` with exactly this
395
+ // hostInfo name). Third-party MCP-Apps hosts (claude.ai, ChatGPT,
396
+ // Claude Desktop) drop the envelope on the floor, so rendering the
397
+ // affordance there is a dead control — the first #471 claude.ai
398
+ // retest surfaced it as unstyled clutter that could never deliver
399
+ // feedback anywhere. Fire-and-forget: chrome never blocks or fails
400
+ // the boot.
401
+ const feedbackSinkLive = onObserve !== undefined ||
402
+ app.getHostVersion()?.name === 'ggui-iframe-runtime-embed-host';
403
+ if (feedbackSinkLive) {
404
+ void mountUiFeedbackChrome(doc, {
405
+ emit: onObserve ?? postObservabilityToParent,
406
+ sessionId: meta.sessionId,
407
+ });
408
+ }
399
409
  setStatus(refs, `Connecting to ${meta.sessionId}…`, 'connecting');
400
410
  // Boot-without-renderer path: we still need a ChannelRegistry to
401
411
  // receive frames, because the registry is the only dispatch
@@ -461,16 +471,37 @@ export async function bootSequence(opts) {
461
471
  onLifecycle?.(makeLifecycleEvent('code-ready', { sessionId: meta.sessionId }));
462
472
  };
463
473
  // Mode discriminators. The mount surface is DECOUPLED from the live
464
- // channel: static content (codeUrl/kind) paints immediately with no WS;
465
- // the live trio (wsUrl+wsToken) is an OPTIONAL enhancement that
466
- // delivers props_update / data / re-render frames. A bootstrap with
467
- // NEITHER has nothing to show and nowhere to subscribe.
468
- const hasStaticContent = (typeof meta.codeUrl === 'string' && meta.codeUrl.length > 0) ||
469
- (typeof meta.kind === 'string' && meta.kind.length > 0);
474
+ // channel: static content (codeUrl/codeB64/kind) paints immediately
475
+ // with no WS; the live trio (wsUrl+wsToken) is an OPTIONAL
476
+ // enhancement that delivers props_update / data / re-render frames.
477
+ // A bootstrap with NEITHER has nothing to show and nowhere to
478
+ // subscribe.
479
+ const hasStaticContent = hasStaticContentMeta(meta);
470
480
  const hasLiveTrio = typeof meta.wsUrl === 'string' &&
471
481
  meta.wsUrl.length > 0 &&
472
482
  typeof meta.wsToken === 'string' &&
473
483
  meta.wsToken.length > 0;
484
+ // Transport-telemetry sink — the iframe's self-report channel (see
485
+ // runtime-telemetry.ts). Created for EVERY boot with an App bridge;
486
+ // the first batch carries the boot-path decision so operators can
487
+ // see which ladder (if any) this mount composed, even on hosts
488
+ // whose console and network are unreachable.
489
+ const telemetryApp = getCurrentApp();
490
+ const telemetry = telemetryApp !== null
491
+ ? createTelemetrySink({
492
+ sessionId: meta.sessionId,
493
+ callTool: (args) => telemetryApp.callServerTool(args),
494
+ })
495
+ : null;
496
+ currentTelemetrySink = telemetry;
497
+ // Fresh boot ⇒ not superseded (matters only for test re-runs that
498
+ // reuse the module; a real iframe boots once).
499
+ mountSuperseded = false;
500
+ telemetry?.record('boot.path', JSON.stringify({
501
+ hasStaticContent,
502
+ hasLiveTrio,
503
+ bridgeCapable: telemetryApp !== null,
504
+ }));
474
505
  // ── Static seed mount — zero-round-trip paint, no WS required. ──────
475
506
  // The ONLY mount path for spec-compliant MCP-Apps hosts that expose no
476
507
  // ggui live channel (claude.ai / ChatGPT / Claude Desktop), AND the
@@ -511,17 +542,28 @@ export async function bootSequence(opts) {
511
542
  emitCodeReadyOnce();
512
543
  }
513
544
  }
514
- // ── No live channel → static-only host. ────────────────────────────
545
+ // ── No live channel → static-only host (unless bridge-capable). ─────
515
546
  // The static seed already painted (or there was nothing to paint).
516
547
  // A bootstrap with neither static content nor a live trio is a
517
548
  // misconfiguration — surface a typed boot failure.
518
- if (!hasLiveTrio) {
519
- if (mountedRender === null) {
520
- const message = 'bootstrap carries neither static content (codeUrl/kind) nor a live trio (wsUrl/wsToken)';
521
- setStatus(refs, message, 'error');
522
- emitBootFailure('MISSING_META_GGUI_BOOTSTRAP', message);
523
- return { ok: false, mountedRender };
524
- }
549
+ //
550
+ // #471 round-6 live finding: when the host exposes an App bridge
551
+ // (tools/call postMessage — claude.ai, every MCP Apps host), a
552
+ // trio-less static mount must NOT stop here — the bridge-pull
553
+ // terminal rung is exactly its subscription. Fall through to the
554
+ // subscribe below, which binds trio-less (the registry starts the
555
+ // ladder at its tail). Gating the terminal rung behind the WS
556
+ // trio's presence disabled it on precisely the host class it was
557
+ // built for.
558
+ const staticOnlyNoBridge = !hasLiveTrio && getCurrentApp() === null;
559
+ if (!hasLiveTrio && mountedRender === null) {
560
+ const message = 'bootstrap carries neither static content (codeUrl/codeB64/kind) nor a live trio (wsUrl/wsToken)';
561
+ setStatus(refs, message, 'error');
562
+ emitBootFailure('MISSING_META_GGUI_BOOTSTRAP', message);
563
+ return { ok: false, mountedRender };
564
+ }
565
+ if (staticOnlyNoBridge) {
566
+ telemetry?.record('boot.static_only_no_bridge');
525
567
  // Host-context wiring for the no-WS static mount (claude.ai / ChatGPT
526
568
  // / Claude Desktop). The INITIAL host theme/style/fonts were already
527
569
  // applied at boot (applyHostContextStyling, above); attach the
@@ -543,12 +585,42 @@ export async function bootSequence(opts) {
543
585
  return { ok: true, mountedRender };
544
586
  }
545
587
  // ── Live-channel subscribe (conditional enhancement). ──────────────
588
+ // Failover-ladder rung URLs (WS → SSE → polling → bridge-pull),
589
+ // hoisted so the spreads below stay exact-optional-clean without
590
+ // non-null assertions. `sseUrl` is the server-stamped wsToken-gated
591
+ // `/api/sessions/<id>/stream` URL; SSE `id:` = ledger sequence = the
592
+ // SAME cursor model as the WS `sinceSequence` replay and polling
593
+ // ticks — switching rungs does not lose events.
594
+ const sseUrl = typeof meta.sseUrl === 'string' && meta.sseUrl.length > 0
595
+ ? meta.sseUrl
596
+ : undefined;
597
+ const pollingBaseUrl = typeof meta.pollingUrl === 'string' && meta.pollingUrl.length > 0
598
+ ? meta.pollingUrl
599
+ : undefined;
600
+ // One shared cursor for the whole ladder — SSE deliveries advance it
601
+ // (via onSequence), the polling + bridge descriptors read it per
602
+ // tick, so a demotion down the ladder resumes from the last
603
+ // delivered event instead of re-replaying from the boot snapshot.
604
+ // Created when ANY fallback rung exists — including the bridge-pull
605
+ // rung, whose only precondition is a connected App handle (the
606
+ // universal floor: a CSP-jailed host stamps no sseUrl/pollingUrl,
607
+ // yet its `tools/call` postMessage bridge still reaches the ledger).
608
+ const bridgeApp = getCurrentApp();
609
+ const ladderCursor = sseUrl !== undefined || pollingBaseUrl !== undefined || bridgeApp !== null
610
+ ? createSequenceCursor(meta.lastSequence ?? 0)
611
+ : undefined;
546
612
  let handle;
547
613
  try {
548
614
  handle = await connectFn({
549
615
  meta,
550
616
  registry: activeRegistry,
617
+ // Live-channel diagnostics tap — every channel_* event the
618
+ // transports emit (failover swaps, polling budget exhaustion,
619
+ // SSE lifecycle) lands in the telemetry buffer. Without this the
620
+ // ladder's story on sandboxed hosts is unobservable.
621
+ ...(telemetry !== null ? { logger: telemetry.channelLogger } : {}),
551
622
  onStatusChange: (status) => {
623
+ telemetry?.record(`status.${status}`);
552
624
  setStatus(refs, status === 'connected' ? 'Connected.' : `Connection ${status}…`, status);
553
625
  // Propagate WS status to the per-channel transport router so
554
626
  // it can flip WS-bound channels into polling
@@ -573,21 +645,48 @@ export async function bootSequence(opts) {
573
645
  onResubscribeAck: (ack) => {
574
646
  void applyAck(ack);
575
647
  },
576
- // R7 — registry-level events-polling fallback. Composed once at
577
- // bind time from `meta.pollingUrl` (server-stamped wsToken-
578
- // gated /api/sessions/<id>/events URL) + `meta.lastSequence`
579
- // (cursor seed). FailoverHandle uses this when WS reaches
580
- // 'failed'; absent → no polling fallback (WS-only mode).
648
+ // Failover-ladder fallback rungs (WS → SSE → polling →
649
+ // bridge-pull), composed once at bind time from the
650
+ // server-stamped wsToken-gated URLs + `meta.lastSequence`
651
+ // (shared cursor seed). FailoverHandle descends a rung on each
652
+ // 'failed'; the bridge rung is terminal (never emits 'failed').
581
653
  //
582
654
  // Same cursor model as the WS subscribe `sinceSequence` replay
583
655
  // path — switching transports does not lose events.
584
- ...(typeof meta.pollingUrl === 'string' && meta.pollingUrl.length > 0
656
+ ...(sseUrl !== undefined && ladderCursor !== undefined
657
+ ? {
658
+ sse: {
659
+ url: sseUrl,
660
+ initialSinceSequence: ladderCursor.get(),
661
+ onSequence: (seq) => {
662
+ ladderCursor.advance(seq);
663
+ },
664
+ },
665
+ }
666
+ : {}),
667
+ ...(pollingBaseUrl !== undefined && ladderCursor !== undefined
585
668
  ? {
586
669
  polling: buildEventsPolling({
587
- baseUrl: meta.pollingUrl,
588
- ...(meta.lastSequence !== undefined
589
- ? { initialSinceSequence: meta.lastSequence }
590
- : {}),
670
+ baseUrl: pollingBaseUrl,
671
+ cursor: ladderCursor,
672
+ }),
673
+ }
674
+ : {}),
675
+ // Bridge-pull terminal rung — composed whenever the App handle
676
+ // is bound (it always is by this point in the boot: connectApp
677
+ // succeeded and setCurrentApp ran above), INDEPENDENT of
678
+ // sseUrl/pollingUrl presence. This is the universal floor for
679
+ // CSP-jailed hosts (claude.ai) where the iframe has no network
680
+ // path at all: the ledger is pulled via `ggui_runtime_pull`
681
+ // `tools/call`s over the host's postMessage bridge. Shares the
682
+ // SAME ladder cursor — a demotion into the bridge resumes from
683
+ // whatever the rungs above already delivered.
684
+ ...(bridgeApp !== null && ladderCursor !== undefined
685
+ ? {
686
+ bridge: buildBridgePolling({
687
+ callTool: (name, args) => bridgeApp.callServerTool({ name, arguments: args }),
688
+ sessionId: meta.sessionId,
689
+ cursor: ladderCursor,
591
690
  }),
592
691
  }
593
692
  : {}),
@@ -626,12 +725,19 @@ export async function bootSequence(opts) {
626
725
  emitBootFailure('WS_HANDSHAKE_FAILED', message);
627
726
  return { ok: false, mountedRender };
628
727
  }
728
+ telemetry?.record('subscribe.resolved', JSON.stringify({ kind: handle.handle.kind, hasAck: handle.ack !== undefined }));
629
729
  // Attach the live transport handle to the renderer — flushes any
630
- // buffered outbound `action` frames that were queued
631
- // while the subscribe handshake completed. `handle.handle.send` is
632
- // the canonical send surface for the bound WS transport.
730
+ // buffered outbound `action` frames that were queued while the
731
+ // subscribe handshake completed. `send` narrows to the ws surface:
732
+ // a trio-less bridge-only bind has NO outbound channel (gestures
733
+ // ride app.callServerTool, not the transport), so its sends drop —
734
+ // the same posture a post-swap FailoverHandle already has.
735
+ const transportSend = (msg) => {
736
+ if (handle.handle.kind === 'ws')
737
+ handle.handle.send(msg);
738
+ };
633
739
  if (renderer !== null && rendererHooks?.attachManager !== undefined) {
634
- rendererHooks.attachManager(renderer, { send: (msg) => handle.handle.send(msg) });
740
+ rendererHooks.attachManager(renderer, { send: transportSend });
635
741
  }
636
742
  // seed the host-context emitter with the projection captured from
637
743
  // `app.getHostContext()` (the App class's spec-canonical
@@ -642,7 +748,7 @@ export async function bootSequence(opts) {
642
748
  if (parsed.hostContext !== undefined) {
643
749
  seedHostContext({
644
750
  sessionId: meta.sessionId,
645
- send: (msg) => handle.handle.send(msg),
751
+ send: transportSend,
646
752
  initial: parsed.hostContext,
647
753
  });
648
754
  // Bind via App's spec-canonical `hostcontextchanged` event surface.
@@ -655,8 +761,12 @@ export async function bootSequence(opts) {
655
761
  // `pinnedSessionId`) and mount it. Reuses the same `applyAck` helper
656
762
  // the reconnect-rebootstrap path uses — so a server-restart-driven
657
763
  // snapshot replay and the first-boot snapshot apply flow through
658
- // one implementation.
659
- await applyAck(handle.ack);
764
+ // one implementation. Trio-less bridge-only binds have no handshake
765
+ // and therefore no ack — the static seed already painted, and the
766
+ // bridge delivers deltas from the boot cursor.
767
+ if (handle.ack !== undefined) {
768
+ await applyAck(handle.ack);
769
+ }
660
770
  setConnectedStatus(refs);
661
771
  // Lifecycle `code-ready` — terminal happy state. Emitted ONCE per boot
662
772
  // (a static+live meta already fired it from the seed mount, so this is
@@ -801,16 +911,22 @@ function parseSeedProps(propsJson) {
801
911
  * no WS at all for spec-compliant MCP-Apps hosts that expose no ggui
802
912
  * live channel (claude.ai / ChatGPT / Claude Desktop).
803
913
  *
804
- * Two static-content shapes (the autostart discriminator):
914
+ * Three static-content shapes (the autostart discriminator), in
915
+ * priority order:
805
916
  * - `kind` → a system-card seed (`type:'system'`); no fetch.
917
+ * - `codeB64` → a compiled-component seed carried inline; DECODED,
918
+ * never fetched. Preferred over `codeUrl` when both are present —
919
+ * the inline bytes exist precisely for hosts whose iframe CSP
920
+ * blocks the fetch.
806
921
  * - `codeUrl` → a compiled-component seed; fetches the
807
922
  * content-addressable component bytes.
808
923
  *
809
- * Returns `null` when the meta carries NEITHER (a live-only meta has
924
+ * Returns `null` when the meta carries NONE (a live-only meta has
810
925
  * nothing to seed — the first WS ack mounts it). Throws when a `codeUrl`
811
- * fetch fails so the caller can surface a typed boot failure. The four
812
- * server-assigned ledger fields are intentionally absent — the first
813
- * ack reconciles the seed to a full `GguiSession` (no fabrication).
926
+ * fetch fails or `codeB64` is not valid base64 so the caller can
927
+ * surface a typed boot failure. The four server-assigned ledger fields
928
+ * are intentionally absent — the first ack reconciles the seed to a
929
+ * full `GguiSession` (no fabrication).
814
930
  */
815
931
  export async function buildGguiSessionSeedInput(meta) {
816
932
  const props = parseSeedProps(meta.propsJson);
@@ -824,6 +940,15 @@ export async function buildGguiSessionSeedInput(meta) {
824
940
  ...(props !== undefined ? { props } : {}),
825
941
  };
826
942
  }
943
+ // Inline compiled-component mode — decode, no network.
944
+ if (meta.codeB64 !== undefined) {
945
+ return {
946
+ id: meta.sessionId,
947
+ appId: meta.appId,
948
+ componentCode: decodeCodeB64(meta.codeB64),
949
+ ...(props !== undefined ? { props } : {}),
950
+ };
951
+ }
827
952
  // Compiled-component mode — fetch the content-addressable bytes.
828
953
  if (meta.codeUrl === undefined)
829
954
  return null;
@@ -839,6 +964,45 @@ export async function buildGguiSessionSeedInput(meta) {
839
964
  ...(props !== undefined ? { props } : {}),
840
965
  };
841
966
  }
967
+ /**
968
+ * Does this slice carry STATIC content the mount surface can paint
969
+ * without any network round-trip authorization — `codeUrl` (fetched),
970
+ * `codeB64` (decoded inline), or `kind` (built-in registry)?
971
+ *
972
+ * The ONE discriminator `bootSequence` consults when deciding whether
973
+ * the static seed-mount path runs. `codeB64` counting here is
974
+ * load-bearing for fetch-blocked hosts: their meta carries codeB64
975
+ * PLUS a live trio the iframe can never connect, and missing the
976
+ * inline arm would skip the only paint path and die on the dead WS.
977
+ * Mirrors `validateMeta`'s static-content arm (meta-parse.ts) and the
978
+ * protocol host-helper's `hasMountModeDiscriminator` — drift among
979
+ * the three IS the historical bug class, hence the named export.
980
+ */
981
+ export function hasStaticContentMeta(meta) {
982
+ return ((typeof meta.codeUrl === 'string' && meta.codeUrl.length > 0) ||
983
+ (typeof meta.codeB64 === 'string' && meta.codeB64.length > 0) ||
984
+ (typeof meta.kind === 'string' && meta.kind.length > 0));
985
+ }
986
+ /**
987
+ * Decode a `codeB64` slice field to UTF-8 component source. `atob`
988
+ * yields a byte string; routing through `TextDecoder` restores
989
+ * multi-byte characters (component source is UTF-8 — string literals
990
+ * carry emoji, quotes, non-Latin text). Throws a labeled error on
991
+ * malformed base64 so the boot-failure surface names the field.
992
+ */
993
+ function decodeCodeB64(codeB64) {
994
+ try {
995
+ const byteString = atob(codeB64);
996
+ const bytes = new Uint8Array(byteString.length);
997
+ for (let i = 0; i < byteString.length; i++) {
998
+ bytes[i] = byteString.charCodeAt(i);
999
+ }
1000
+ return new TextDecoder().decode(bytes);
1001
+ }
1002
+ catch (cause) {
1003
+ throw new Error('buildGguiSessionSeedInput: codeB64 is not valid base64', { cause });
1004
+ }
1005
+ }
842
1006
  /**
843
1007
  * Extract a {@link McpAppAiGguiRenderMeta} from a
844
1008
  * `ui/notifications/tool-result` JSON-RPC params payload — the
@@ -860,23 +1024,29 @@ export function extractMetaFromToolResult(params) {
860
1024
  /**
861
1025
  * Drain `window.__GGUI_PENDING_TOOL_RESULTS__` — the buffer the
862
1026
  * minimal shell populates while messages arrive between
863
- * shell-load and runtime-load. Returns the first valid slice meta
864
- * found; later params (if any) are dropped (each new tool-result
865
- * supersedes the previous).
866
- *
867
- * The shell-side buffer contract: an array of
868
- * `{params: unknown}`-shaped JSON-RPC params from
869
- * `ui/notifications/tool-result` notifications, populated in
870
- * arrival order. The runtime drains it on first read.
871
- */
872
- function readPendingToolResults() {
1027
+ * shell-load and runtime-load. Returns the NEWEST valid slice meta
1028
+ * (each new tool-result supersedes the previous — every emission
1029
+ * carries complete state, so last-wins loses nothing). Newest-first
1030
+ * matters on the inline-runtime shell: its preflight completes the
1031
+ * handshake BEFORE the large bundle parses, so a render AND a
1032
+ * follow-up update can both land in the buffer during parse — booting
1033
+ * the oldest would paint stale props and drop the update forever
1034
+ * (buffered entries never reach the post-mount listener; it hears
1035
+ * only post-App arrivals).
1036
+ *
1037
+ * The shell-side buffer contract: an array whose ELEMENTS are the raw
1038
+ * JSON-RPC `params` values of `ui/notifications/tool-result`
1039
+ * notifications, in arrival order (shells cap it newest-biased). The
1040
+ * runtime reads it once at autostart.
1041
+ */
1042
+ export function readPendingToolResults() {
873
1043
  if (typeof window === 'undefined')
874
1044
  return null;
875
1045
  const raw = window.__GGUI_PENDING_TOOL_RESULTS__;
876
1046
  if (!Array.isArray(raw) || raw.length === 0)
877
1047
  return null;
878
- for (const params of raw) {
879
- const meta = extractMetaFromToolResult(params);
1048
+ for (let i = raw.length - 1; i >= 0; i--) {
1049
+ const meta = extractMetaFromToolResult(raw[i]);
880
1050
  if (meta !== null)
881
1051
  return meta;
882
1052
  }
@@ -1247,6 +1417,23 @@ export function emitAudit(args) {
1247
1417
  * iframe-runtime then falls through to `ui/message` so the gesture
1248
1418
  * still reaches the agent on its next turn.
1249
1419
  */
1420
+ /**
1421
+ * Unwrap the payload record of a relayed submit-action tool result.
1422
+ * Envelope-level shim over the shared 3-tier unwrap
1423
+ * ({@link unwrapCallToolResult}: `structuredContent` →
1424
+ * `content[0].text` parsed as JSON → bare result) — this wrapper only
1425
+ * peels the JSON-RPC `result` field first. The tiers themselves are
1426
+ * shared with the bridge-pull rung's `fetchBody` carrier
1427
+ * (`events-polling.ts`) so both reads agree on which envelope tier
1428
+ * carries the payload for a given host shape.
1429
+ * Returns `null` when no tier yields an object — the caller treats
1430
+ * that as fallback/unknown.
1431
+ */
1432
+ function submitActionPayload(resp) {
1433
+ if (resp === null || typeof resp !== 'object')
1434
+ return null;
1435
+ return unwrapCallToolResult(resp.result);
1436
+ }
1250
1437
  function classifySubmitActionResponse(resp) {
1251
1438
  if (resp === null || typeof resp !== 'object')
1252
1439
  return 'fallback';
@@ -1255,15 +1442,9 @@ function classifySubmitActionResponse(resp) {
1255
1442
  resp.error !== null) {
1256
1443
  return 'fallback';
1257
1444
  }
1258
- const result = resp.result;
1259
- if (result === null || typeof result !== 'object')
1445
+ const inner = submitActionPayload(resp);
1446
+ if (inner === null)
1260
1447
  return 'fallback';
1261
- const r = result;
1262
- // Spec-canonical hosts wrap tool output under `structuredContent`;
1263
- // looser hosts may surface fields directly on `result`.
1264
- const inner = r.structuredContent && typeof r.structuredContent === 'object'
1265
- ? r.structuredContent
1266
- : r;
1267
1448
  return inner.ok === true ? 'success' : 'fallback';
1268
1449
  }
1269
1450
  /**
@@ -1295,25 +1476,65 @@ function isRelayShapedFailure(resp) {
1295
1476
  /**
1296
1477
  * Extract `consumerPresent` from a successful submit_action response.
1297
1478
  * Returns the boolean if present + well-typed; `undefined` otherwise
1298
- * (server didn't wire the registry, agnostic host stripped the field,
1299
- * or any non-`ok:true` shape — callers fall back to the 10s timer
1300
- * path on `undefined`). Mirrors the structuredContent → result
1301
- * unwrap of {@link classifySubmitActionResponse} so both reads agree
1302
- * on which envelope tier carries the success payload.
1479
+ * (agnostic host stripped the field, or any non-`ok:true` shape).
1480
+ * `undefined` is treated by the dispatch gate as "no confirmed
1481
+ * consumer" — the doorbell rings. Shares {@link submitActionPayload}
1482
+ * with {@link classifySubmitActionResponse} so both reads agree on
1483
+ * which envelope tier carries the payload.
1303
1484
  */
1304
1485
  function extractConsumerPresent(resp) {
1305
- if (resp === null || typeof resp !== 'object')
1306
- return undefined;
1307
- const result = resp.result;
1308
- if (result === null || typeof result !== 'object')
1486
+ const inner = submitActionPayload(resp);
1487
+ if (inner === null)
1309
1488
  return undefined;
1310
- const r = result;
1311
- const inner = r.structuredContent && typeof r.structuredContent === 'object'
1312
- ? r.structuredContent
1313
- : r;
1314
1489
  const flag = inner.consumerPresent;
1315
1490
  return typeof flag === 'boolean' ? flag : undefined;
1316
1491
  }
1492
+ /**
1493
+ * Freeze-cue overlay (#483). A superseded (history) card gets a
1494
+ * subtle, theme-aware "superseded" veil + a labelled pill, and its
1495
+ * content is made non-interactive so a stale-view click can't even
1496
+ * reach a control. Idempotent — the overlay id guards a second call.
1497
+ * A polite live-region announcement (reusing the #447 announcer)
1498
+ * tells a screen-reader user the card is now history.
1499
+ */
1500
+ const FREEZE_CUE_ID = '__ggui-superseded-cue__';
1501
+ function applyFreezeCue(root) {
1502
+ if (typeof document === 'undefined')
1503
+ return;
1504
+ if (root.querySelector(`#${FREEZE_CUE_ID}`) !== null)
1505
+ return;
1506
+ // Non-interactive + visually receded — inherits the mount's own
1507
+ // colors, so it reads correctly in any theme.
1508
+ root.style.pointerEvents = 'none';
1509
+ root.style.opacity = '0.55';
1510
+ root.style.filter = 'grayscale(0.4)';
1511
+ root.style.transition = 'opacity 160ms ease, filter 160ms ease';
1512
+ if (getComputedStyle(root).position === 'static') {
1513
+ root.style.position = 'relative';
1514
+ }
1515
+ const pill = document.createElement('div');
1516
+ pill.id = FREEZE_CUE_ID;
1517
+ pill.setAttribute('role', 'note');
1518
+ pill.textContent = 'Superseded — a newer version continues below';
1519
+ Object.assign(pill.style, {
1520
+ position: 'absolute',
1521
+ top: '8px',
1522
+ right: '8px',
1523
+ padding: '4px 10px',
1524
+ borderRadius: '9999px',
1525
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
1526
+ fontSize: '11px',
1527
+ fontWeight: '600',
1528
+ color: 'var(--ggui-color-onSurface, inherit)',
1529
+ background: 'rgba(128,128,128,0.16)',
1530
+ border: '1px solid currentColor',
1531
+ opacity: '0.85',
1532
+ pointerEvents: 'none',
1533
+ zIndex: '2',
1534
+ });
1535
+ root.appendChild(pill);
1536
+ announceToast('This card was superseded by a newer version below', 'fallback');
1537
+ }
1317
1538
  /** Container id for the announcer holding both live regions. */
1318
1539
  const TOAST_ANNOUNCER_ID = '__ggui-toast-announcer__';
1319
1540
  /**
@@ -1650,7 +1871,7 @@ function emitUserActionDoorbell(args) {
1650
1871
  ` <next_args>${nextArgs}</next_args>`,
1651
1872
  `</ggui_directive>`,
1652
1873
  '',
1653
- `The user interacted with render ${args.sessionId} while no ggui_consume long-poll was active. The gesture is queued on the consume pipe for that render — it is NOT in this message. After ggui_consume returns, react to the returned event with the appropriate domain tool, then call ggui_update on the SAME sessionId (${args.sessionId}).`,
1874
+ `The user interacted with render ${args.sessionId} while no ggui_consume long-poll was active. The gesture is queued on the consume pipe for that render — it is NOT in this message. After ggui_consume returns, react to the returned event with the appropriate domain tool, then call ggui_amend on the SAME sessionId (${args.sessionId}) — the card the user is looking at repaints in place. Use ggui_update instead only if this moment deserves a NEW card in the conversation (it advances the history number).`,
1654
1875
  ].join('\n');
1655
1876
  // Structured mirror of the directive for ggui-aware programmatic
1656
1877
  // consumers. OPTIONAL — nothing in the loop depends on a server-side
@@ -1694,6 +1915,7 @@ function emitUserActionDoorbell(args) {
1694
1915
  // record — the proper extension point). Namespaced under
1695
1916
  // `ai.ggui/userAction` to match our other protocol extensions
1696
1917
  // (`ai.ggui/render`, `ai.ggui/bootstrap`, etc.).
1918
+ currentTelemetrySink?.record('doorbell.ring', args.sessionId);
1697
1919
  postToParent({
1698
1920
  jsonrpc: '2.0',
1699
1921
  id: Math.floor(Math.random() * 1e9),
@@ -1725,6 +1947,23 @@ function emitUserActionDoorbell(args) {
1725
1947
  * nothing else about the latch is observable to the host.
1726
1948
  */
1727
1949
  let relayIncapabilityAnnounced = false;
1950
+ /**
1951
+ * Module-level handle to the CURRENT boot's telemetry sink so
1952
+ * module-scope paths (the doorbell emitter below) can report without
1953
+ * threading the sink through every call chain. Per-iframe
1954
+ * single-tenancy makes a single slot correct — same justification as
1955
+ * `getCurrentApp()`.
1956
+ */
1957
+ let currentTelemetrySink = null;
1958
+ /**
1959
+ * Freeze latch (#483): set true once a higher-epoch `ggui_update`
1960
+ * supersedes this mount. A frozen mount is HISTORY — its gestures no
1961
+ * longer target the live session, so `dispatchSubmitAction` drops them
1962
+ * (a stale-view submit against the live head is a prevented bug class).
1963
+ * Module-level for the same single-mount-per-iframe reason as
1964
+ * `currentTelemetrySink`.
1965
+ */
1966
+ let mountSuperseded = false;
1728
1967
  /**
1729
1968
  * Whether the user has manually dismissed the CURRENTLY-STANDING relay
1730
1969
  * notice (ggui#442).
@@ -2006,7 +2245,20 @@ function showPostDismissalCue(intent) {
2006
2245
  export function dispatchSubmitAction(args) {
2007
2246
  if (typeof window === 'undefined')
2008
2247
  return;
2248
+ // Freeze latch (#483): a superseded (history) mount does not drive
2249
+ // the live session — drop the gesture rather than submit a stale
2250
+ // view's action against the current head.
2251
+ if (mountSuperseded) {
2252
+ currentTelemetrySink?.record('gesture.dropped_superseded', args.intent);
2253
+ return;
2254
+ }
2009
2255
  const { toolName, intent, data, sessionId, appId } = args;
2256
+ // Gesture-path telemetry — the click's own autopsy trail. #471
2257
+ // round 12 hit a frame whose channels + beacons were fully healthy
2258
+ // while clicks produced NOTHING observable; without a record at the
2259
+ // dispatch entry there is no way to tell "handler never fired" from
2260
+ // "dispatch swallowed it" on a console-less host.
2261
+ currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({ intent, toolName }));
2010
2262
  const firedAt = new Date().toISOString();
2011
2263
  const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
2012
2264
  const inlineData = formatSubmitActionDataInline(data);
@@ -2091,6 +2343,15 @@ export function dispatchSubmitAction(args) {
2091
2343
  showActionToast(`⚠ ${intent} — transport error`, 'error');
2092
2344
  resp = null;
2093
2345
  }
2346
+ // Final hop of the gesture autopsy trail — what the relay
2347
+ // answered (or that it didn't).
2348
+ currentTelemetrySink?.record('gesture.result', JSON.stringify({
2349
+ intent,
2350
+ ok: resp !== null && resp.error === undefined,
2351
+ ...(resp?.error?.message !== undefined
2352
+ ? { error: resp.error.message.slice(0, 120) }
2353
+ : {}),
2354
+ }));
2094
2355
  // Self-healing (ggui#440): a result envelope arriving at all is
2095
2356
  // proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
2096
2357
  // (an otherwise-healthy relay, expired pipe) proves it exactly as
@@ -2132,16 +2393,25 @@ export function dispatchSubmitAction(args) {
2132
2393
  }
2133
2394
  if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
2134
2395
  const consumerPresent = extractConsumerPresent(resp);
2135
- if (consumerPresent === false) {
2136
- // No `ggui_consume` long-poll is draining this render's pipe,
2137
- // so the gesture needs a fresh agent turn. `ui/message` is the
2138
- // ONLY view→host method that can start one — a host that does
2139
- // not accept it cannot be woken by us at all, and the user has
2140
- // to send a message themselves (ggui#440).
2396
+ if (consumerPresent !== true) {
2397
+ // No `ggui_consume` long-poll is POSITIVELY confirmed to be
2398
+ // draining this render's pipe, so the gesture needs a fresh
2399
+ // agent turn. `ui/message` is the ONLY view→host method that
2400
+ // can start one — a host that does not accept it cannot be
2401
+ // woken by us at all, and the user has to send a message
2402
+ // themselves (ggui#440).
2141
2403
  //
2142
- // The doorbell is posted either way: capability absence is an
2143
- // unconfirmed signal, not a proven "cannot", and a dropped
2144
- // postMessage costs nothing.
2404
+ // `!== true` (not `=== false`) is load-bearing: a relay host
2405
+ // that normalizes the tool result and strips the
2406
+ // `consumerPresent` field (claude.ai's live behavior — the
2407
+ // first #471 retest click died exactly here) must ring the
2408
+ // doorbell, not wait forever for a drain_ack that cannot
2409
+ // arrive without a live channel. The doorbell is pointer-only
2410
+ // and the pipe pop is exactly-once, so a redundant ring on a
2411
+ // field-stripping host with a live consumer costs one empty
2412
+ // `ggui_consume`; servers that CAN answer always send an
2413
+ // explicit `true` (the factory wires the registry
2414
+ // unconditionally), so confirmed-consumer hosts stay quiet.
2145
2415
  showActionToast(hostCanReceiveMessages()
2146
2416
  ? `💬 ${intent}${dataPart} — agent not listening, sent to chat`
2147
2417
  : `💬 ${intent}${dataPart} — agent not listening. Send a message to continue.`, 'action_required');
@@ -2153,9 +2423,9 @@ export function dispatchSubmitAction(args) {
2153
2423
  });
2154
2424
  return;
2155
2425
  }
2156
- // consumerPresent is true (or undefined — agnostic host stripped
2157
- // the field). Toast stays `pending`; drain_ack listener dismisses
2158
- // it when ggui_consume drains the event.
2426
+ // consumerPresent === true — a live long-poll is confirmed
2427
+ // draining the pipe. Toast stays `pending`; the drain_ack
2428
+ // listener dismisses it when ggui_consume drains the event.
2159
2429
  return;
2160
2430
  }
2161
2431
  // Enqueue failed (pipe gone / transport error / host has no relay).
@@ -2609,6 +2879,10 @@ function installPostMountListener() {
2609
2879
  meta.sessionId,
2610
2880
  meta.kind ?? '-',
2611
2881
  meta.codeUrl ?? '-',
2882
+ // Inline code participates like codeUrl: an update that ships new
2883
+ // component bytes (re-generation) must re-mount even when
2884
+ // sessionId + props are unchanged.
2885
+ meta.codeB64 ?? '-',
2612
2886
  meta.propsJson ?? '-',
2613
2887
  liveTrio,
2614
2888
  ].join('|');
@@ -2918,6 +3192,28 @@ async function bootProduction(opts) {
2918
3192
  // these lived only in the retired self-contained boot path; the
2919
3193
  // WS-driven path installed neither, so anchor clicks + fullscreen
2920
3194
  // requests in a live-rendered component silently no-op'd.
3195
+ // Root of the gesture autopsy trail: a capture-phase listener
3196
+ // proving pointer events reach this document AT ALL. #471
3197
+ // round 12: a healthy frame (beacons + pulls flowing) whose
3198
+ // clicks produced nothing observable — without this record,
3199
+ // "host swallows pointer events" and "component handler never
3200
+ // attached" are indistinguishable from outside a console-less
3201
+ // host. Bounded: only the first few clicks record (the sink
3202
+ // throttles + caps flushes regardless).
3203
+ if (typeof document !== 'undefined') {
3204
+ let recordedClicks = 0;
3205
+ document.addEventListener('click', (ev) => {
3206
+ if (recordedClicks >= 5)
3207
+ return;
3208
+ recordedClicks += 1;
3209
+ const target = ev.target instanceof Element ? ev.target : null;
3210
+ currentTelemetrySink?.record('gesture.dom_click', JSON.stringify({
3211
+ tag: target?.tagName ?? 'unknown',
3212
+ role: target?.getAttribute('role') ?? undefined,
3213
+ trusted: ev.isTrusted,
3214
+ }));
3215
+ }, { capture: true });
3216
+ }
2921
3217
  installAnchorClickInterceptor({
2922
3218
  dispatchToolName,
2923
3219
  sessionId: meta.sessionId,
@@ -2935,6 +3231,10 @@ async function bootProduction(opts) {
2935
3231
  manager,
2936
3232
  streamBus,
2937
3233
  onDispatchEnvelope: (envelope) => {
3234
+ // First hop of the gesture autopsy trail (see
3235
+ // `dispatchSubmitAction`'s sibling record): proves the wire
3236
+ // layer received the component's action envelope at all.
3237
+ currentTelemetrySink?.record('gesture.envelope', JSON.stringify({ type: envelope.type }));
2938
3238
  if (envelope.type !== 'data:submit')
2939
3239
  return;
2940
3240
  const payload = envelope.payload;
@@ -3121,9 +3421,28 @@ async function bootProduction(opts) {
3121
3421
  streamBus,
3122
3422
  validatorCtx,
3123
3423
  }));
3424
+ // Freeze latch (#483). This mount's own history epoch, read once
3425
+ // from its boot meta: render → 0, an update-minted card → its N,
3426
+ // a pinned `#N` read → N. When a `props_update` frame carries a
3427
+ // higher epoch, a newer card superseded this one.
3428
+ const selfEpoch = typeof meta.epoch === 'number' ? meta.epoch : 0;
3429
+ let superseded = false;
3430
+ const supersede = () => {
3431
+ if (superseded)
3432
+ return;
3433
+ superseded = true;
3434
+ // Module-level dispatch guard: a history card's gestures no
3435
+ // longer target the live session.
3436
+ mountSuperseded = true;
3437
+ currentTelemetrySink?.record('epoch.frozen', String(selfEpoch));
3438
+ applyFreezeCue(renderInto);
3439
+ };
3124
3440
  channelRegistry.register(createPropsUpdateHandler({
3125
3441
  getCurrentGguiSession: () => currentRender,
3126
3442
  applyRender,
3443
+ getSelfEpoch: () => selfEpoch,
3444
+ onSuperseded: supersede,
3445
+ isSuperseded: () => superseded,
3127
3446
  }));
3128
3447
  channelRegistry.register(createDrainAckHandler({ dispatch: dispatchDrainAck }));
3129
3448
  channelRegistry.register(createChannelPayloadHandler({