@ggui-ai/iframe-runtime 0.8.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
@@ -471,6 +481,27 @@ export async function bootSequence(opts) {
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/codeB64/kind) nor a live trio (wsUrl/wsToken)';
521
- setStatus(refs, message, 'error');
522
- emitBootFailure('MISSING_META_GGUI_BOOTSTRAP', message);
523
- return { ok: false, mountedRender };
524
- }
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
@@ -1307,6 +1417,23 @@ export function emitAudit(args) {
1307
1417
  * iframe-runtime then falls through to `ui/message` so the gesture
1308
1418
  * still reaches the agent on its next turn.
1309
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
+ }
1310
1437
  function classifySubmitActionResponse(resp) {
1311
1438
  if (resp === null || typeof resp !== 'object')
1312
1439
  return 'fallback';
@@ -1315,15 +1442,9 @@ function classifySubmitActionResponse(resp) {
1315
1442
  resp.error !== null) {
1316
1443
  return 'fallback';
1317
1444
  }
1318
- const result = resp.result;
1319
- if (result === null || typeof result !== 'object')
1445
+ const inner = submitActionPayload(resp);
1446
+ if (inner === null)
1320
1447
  return 'fallback';
1321
- const r = result;
1322
- // Spec-canonical hosts wrap tool output under `structuredContent`;
1323
- // looser hosts may surface fields directly on `result`.
1324
- const inner = r.structuredContent && typeof r.structuredContent === 'object'
1325
- ? r.structuredContent
1326
- : r;
1327
1448
  return inner.ok === true ? 'success' : 'fallback';
1328
1449
  }
1329
1450
  /**
@@ -1355,25 +1476,65 @@ function isRelayShapedFailure(resp) {
1355
1476
  /**
1356
1477
  * Extract `consumerPresent` from a successful submit_action response.
1357
1478
  * Returns the boolean if present + well-typed; `undefined` otherwise
1358
- * (server didn't wire the registry, agnostic host stripped the field,
1359
- * or any non-`ok:true` shape — callers fall back to the 10s timer
1360
- * path on `undefined`). Mirrors the structuredContent → result
1361
- * unwrap of {@link classifySubmitActionResponse} so both reads agree
1362
- * on which envelope tier carries the success payload.
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.
1363
1484
  */
1364
1485
  function extractConsumerPresent(resp) {
1365
- if (resp === null || typeof resp !== 'object')
1486
+ const inner = submitActionPayload(resp);
1487
+ if (inner === null)
1366
1488
  return undefined;
1367
- const result = resp.result;
1368
- if (result === null || typeof result !== 'object')
1369
- return undefined;
1370
- const r = result;
1371
- const inner = r.structuredContent && typeof r.structuredContent === 'object'
1372
- ? r.structuredContent
1373
- : r;
1374
1489
  const flag = inner.consumerPresent;
1375
1490
  return typeof flag === 'boolean' ? flag : undefined;
1376
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
+ }
1377
1538
  /** Container id for the announcer holding both live regions. */
1378
1539
  const TOAST_ANNOUNCER_ID = '__ggui-toast-announcer__';
1379
1540
  /**
@@ -1710,7 +1871,7 @@ function emitUserActionDoorbell(args) {
1710
1871
  ` <next_args>${nextArgs}</next_args>`,
1711
1872
  `</ggui_directive>`,
1712
1873
  '',
1713
- `The user interacted with render ${args.sessionId} while no ggui_consume long-poll was active. The gesture is queued on the consume pipe for that render — it is NOT in this message. After ggui_consume returns, react to the returned event with the appropriate domain tool, then call ggui_update on the SAME sessionId (${args.sessionId}).`,
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).`,
1714
1875
  ].join('\n');
1715
1876
  // Structured mirror of the directive for ggui-aware programmatic
1716
1877
  // consumers. OPTIONAL — nothing in the loop depends on a server-side
@@ -1754,6 +1915,7 @@ function emitUserActionDoorbell(args) {
1754
1915
  // record — the proper extension point). Namespaced under
1755
1916
  // `ai.ggui/userAction` to match our other protocol extensions
1756
1917
  // (`ai.ggui/render`, `ai.ggui/bootstrap`, etc.).
1918
+ currentTelemetrySink?.record('doorbell.ring', args.sessionId);
1757
1919
  postToParent({
1758
1920
  jsonrpc: '2.0',
1759
1921
  id: Math.floor(Math.random() * 1e9),
@@ -1785,6 +1947,23 @@ function emitUserActionDoorbell(args) {
1785
1947
  * nothing else about the latch is observable to the host.
1786
1948
  */
1787
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;
1788
1967
  /**
1789
1968
  * Whether the user has manually dismissed the CURRENTLY-STANDING relay
1790
1969
  * notice (ggui#442).
@@ -2066,7 +2245,20 @@ function showPostDismissalCue(intent) {
2066
2245
  export function dispatchSubmitAction(args) {
2067
2246
  if (typeof window === 'undefined')
2068
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
+ }
2069
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 }));
2070
2262
  const firedAt = new Date().toISOString();
2071
2263
  const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
2072
2264
  const inlineData = formatSubmitActionDataInline(data);
@@ -2151,6 +2343,15 @@ export function dispatchSubmitAction(args) {
2151
2343
  showActionToast(`⚠ ${intent} — transport error`, 'error');
2152
2344
  resp = null;
2153
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
+ }));
2154
2355
  // Self-healing (ggui#440): a result envelope arriving at all is
2155
2356
  // proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
2156
2357
  // (an otherwise-healthy relay, expired pipe) proves it exactly as
@@ -2192,16 +2393,25 @@ export function dispatchSubmitAction(args) {
2192
2393
  }
2193
2394
  if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
2194
2395
  const consumerPresent = extractConsumerPresent(resp);
2195
- if (consumerPresent === false) {
2196
- // No `ggui_consume` long-poll is draining this render's pipe,
2197
- // so the gesture needs a fresh agent turn. `ui/message` is the
2198
- // ONLY view→host method that can start one — a host that does
2199
- // not accept it cannot be woken by us at all, and the user has
2200
- // to send a message themselves (ggui#440).
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).
2201
2403
  //
2202
- // The doorbell is posted either way: capability absence is an
2203
- // unconfirmed signal, not a proven "cannot", and a dropped
2204
- // postMessage costs nothing.
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.
2205
2415
  showActionToast(hostCanReceiveMessages()
2206
2416
  ? `💬 ${intent}${dataPart} — agent not listening, sent to chat`
2207
2417
  : `💬 ${intent}${dataPart} — agent not listening. Send a message to continue.`, 'action_required');
@@ -2213,9 +2423,9 @@ export function dispatchSubmitAction(args) {
2213
2423
  });
2214
2424
  return;
2215
2425
  }
2216
- // consumerPresent is true (or undefined — agnostic host stripped
2217
- // the field). Toast stays `pending`; drain_ack listener dismisses
2218
- // it when ggui_consume drains the event.
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.
2219
2429
  return;
2220
2430
  }
2221
2431
  // Enqueue failed (pipe gone / transport error / host has no relay).
@@ -2982,6 +3192,28 @@ async function bootProduction(opts) {
2982
3192
  // these lived only in the retired self-contained boot path; the
2983
3193
  // WS-driven path installed neither, so anchor clicks + fullscreen
2984
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
+ }
2985
3217
  installAnchorClickInterceptor({
2986
3218
  dispatchToolName,
2987
3219
  sessionId: meta.sessionId,
@@ -2999,6 +3231,10 @@ async function bootProduction(opts) {
2999
3231
  manager,
3000
3232
  streamBus,
3001
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 }));
3002
3238
  if (envelope.type !== 'data:submit')
3003
3239
  return;
3004
3240
  const payload = envelope.payload;
@@ -3185,9 +3421,28 @@ async function bootProduction(opts) {
3185
3421
  streamBus,
3186
3422
  validatorCtx,
3187
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
+ };
3188
3440
  channelRegistry.register(createPropsUpdateHandler({
3189
3441
  getCurrentGguiSession: () => currentRender,
3190
3442
  applyRender,
3443
+ getSelfEpoch: () => selfEpoch,
3444
+ onSuperseded: supersede,
3445
+ isSuperseded: () => superseded,
3191
3446
  }));
3192
3447
  channelRegistry.register(createDrainAckHandler({ dispatch: dispatchDrainAck }));
3193
3448
  channelRegistry.register(createChannelPayloadHandler({
@@ -3,7 +3,7 @@
3
3
  * ("did this generated UI work for you?").
4
4
  *
5
5
  * The in-iframe twin of the host-chrome `UiFeedback` component in
6
- * `@ggui-ai/react`: same three verdicts — Love / Dislike / Other
6
+ * `@ggui-ai/mcp-apps-react`: same three verdicts — Love / Dislike / Other
7
7
  * (free-text comment) — same acknowledgement after submit, same
8
8
  * dismiss control, same payload semantics (trimmed comment only on
9
9
  * `verdict: 'other'`, context stamps present exactly when known).
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  * ("did this generated UI work for you?").
5
5
  *
6
6
  * The in-iframe twin of the host-chrome `UiFeedback` component in
7
- * `@ggui-ai/react`: same three verdicts — Love / Dislike / Other
7
+ * `@ggui-ai/mcp-apps-react`: same three verdicts — Love / Dislike / Other
8
8
  * (free-text comment) — same acknowledgement after submit, same
9
9
  * dismiss control, same payload semantics (trimmed comment only on
10
10
  * `verdict: 'other'`, context stamps present exactly when known).
@@ -13,10 +13,13 @@
13
13
  *
14
14
  * Sink-reachability gate — never render a dead affordance:
15
15
  *
16
- * - `window.parent !== window` → a parent window exists, the
17
- * `ggui:observe` envelope has somewhere to go → mount. Parents
18
- * that don't understand the envelope ignore it (best-effort emit;
19
- * one postMessage of cost).
16
+ * - `window.parent !== window` → a parent window exists → this
17
+ * module's own precondition holds. The CALLER (bootSequence)
18
+ * additionally gates on a live sink: an injected `onObserve`
19
+ * emitter, or a first-party embed host (the only parent that
20
+ * consumes `ggui:observe`). Third-party MCP-Apps hosts drop the
21
+ * envelope, so the affordance is NOT mounted there — a feedback
22
+ * control that can never deliver feedback is worse than none.
20
23
  * - `window.parent === window` → top-level tab (the `/r/<shortCode>`
21
24
  * share-link viewer) → NO mount. There is no parent to receive
22
25
  * the envelope, so a rendered affordance would drop every click
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/iframe-runtime",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "The iframe-local ggui runtime: a self-contained ESM bundle that boots inside an MCP Apps host iframe, runs the protocol-version handshake, opens the live-channel WebSocket, and mounts the rendered UI. Bundles React, ReactDOM, and the ggui design system so generated component code has a single seam to read from.",
5
5
  "keywords": [
6
6
  "ggui",
@@ -35,12 +35,12 @@
35
35
  "qrcode-generator": "^2.0.4",
36
36
  "react": "^19.0.0",
37
37
  "react-dom": "^19.0.0",
38
- "@ggui-ai/live-channel": "0.8.0",
39
- "@ggui-ai/gadgets": "0.8.0",
40
- "@ggui-ai/design": "0.8.0",
41
- "@ggui-ai/protocol": "0.8.0",
42
- "@ggui-ai/preview-a2ui": "0.8.0",
43
- "@ggui-ai/wire": "0.8.0"
38
+ "@ggui-ai/live-channel": "0.9.0",
39
+ "@ggui-ai/gadgets": "0.9.0",
40
+ "@ggui-ai/design": "0.9.0",
41
+ "@ggui-ai/preview-a2ui": "0.9.0",
42
+ "@ggui-ai/protocol": "0.9.0",
43
+ "@ggui-ai/wire": "0.9.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@modelcontextprotocol/sdk": "^1.27.0"