@ggui-ai/iframe-runtime 0.13.0 → 0.14.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
@@ -27,6 +27,7 @@
27
27
  // FIRST import on purpose — declares zod jitless before any
28
28
  // schema-defining dependency initializes (see zod-jitless.ts).
29
29
  import './zod-jitless.js';
30
+ import { connectionStore } from './connection.js';
30
31
  import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, } from '@ggui-ai/protocol/integrations/mcp-apps';
31
32
  import { effectiveThemeId, effectiveThemeMode, } from '@ggui-ai/protocol/integrations/theme-binding';
32
33
  import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
@@ -74,7 +75,15 @@ export function subscribeDrainAck(listener) {
74
75
  drainAckListeners.delete(listener);
75
76
  };
76
77
  }
77
- function dispatchDrainAck(payload) {
78
+ /**
79
+ * The `drain_ack` ingress — fans a frame out to subscribers until one
80
+ * claims it.
81
+ *
82
+ * @internal — exported for unit tests: a late ack for an earlier
83
+ * delivered gesture is the path that retires the standing relay notice
84
+ * without any user click (ggui#670 Phase 3, truth-2).
85
+ */
86
+ export function dispatchDrainAck(payload) {
78
87
  for (const listener of drainAckListeners) {
79
88
  try {
80
89
  if (listener(payload) === true)
@@ -669,6 +678,10 @@ export async function bootSequence(opts) {
669
678
  // Fresh boot ⇒ not superseded (matters only for test re-runs that
670
679
  // reuse the module; a real iframe boots once).
671
680
  mountSuperseded = false;
681
+ // Fresh boot ⇒ the relay latch and the connection store start
682
+ // aligned (ggui#670): a standing latch from a prior mount in a
683
+ // re-booted document must not outlive the store it was written to.
684
+ resetRelayLatchForBoot();
672
685
  telemetry?.record('boot.path', JSON.stringify({
673
686
  hasStaticContent,
674
687
  hasLiveTrio,
@@ -1606,13 +1619,65 @@ async function callServerToolSpec(toolName, args) {
1606
1619
  return { jsonrpc: '2.0', result: result };
1607
1620
  }
1608
1621
  catch (err) {
1622
+ // Preserve the JSON-RPC error code when the SDK throw carries one
1623
+ // (McpError does) — ggui#599 cycle-2: without it a genuine helper
1624
+ // refusal (-32601 / -32000) is indistinguishable from a socket
1625
+ // drop at both latch call sites, and the confirmed-refusal
1626
+ // classification below this file cannot exist. Code-less throws
1627
+ // stay code-less: transport-shaped, never a confirmed refusal.
1628
+ const code = err.code;
1609
1629
  return {
1610
1630
  error: {
1631
+ ...(typeof code === 'number' ? { code } : {}),
1611
1632
  message: err instanceof Error ? err.message : String(err),
1612
1633
  },
1613
1634
  };
1614
1635
  }
1615
1636
  }
1637
+ /**
1638
+ * Declared relay-refusal registry (ggui#599 cycle-2, finding 34): the
1639
+ * JSON-RPC error codes a HELPER mints to refuse the relay leg itself —
1640
+ * proof the envelope never round-tripped to the server. Server-side
1641
+ * tool failures come back as well-formed `CallToolResult`s (often
1642
+ * `{ok:false}`), never as JSON-RPC errors on this leg, so a registry
1643
+ * code here is helper-minted by construction:
1644
+ *
1645
+ * - `-32601` — method not supported (the spec's in-band refusal; the
1646
+ * host-helper conformance catalog's H3 shape). The ONLY registry
1647
+ * entry, deliberately.
1648
+ *
1649
+ * `-32000` is EXCLUDED by ruling (ggui#599 cycle-2): it collides with
1650
+ * the MCP SDK's `ErrorCode.ConnectionClosed`, so through the App-SDK
1651
+ * catch a helper-minted -32000 refusal and an SDK-local transport loss
1652
+ * are indistinguishable — and a transport loss must NEVER classify
1653
+ * (misbranding a dropped connection as incapability is the
1654
+ * misclassification asymmetry the cycle killed the mount probe over).
1655
+ * The one live -32000 minter (the RN dispatcher's `no-tool-handler`)
1656
+ * was fixed at source to -32601 in the same slice.
1657
+ *
1658
+ * Unknown codes NEVER confirm — they may be proxy- or server-minted
1659
+ * (e.g. `-32603` internal error), and classifying them would misbrand
1660
+ * capable mounts. They stay on the per-gesture transient path.
1661
+ */
1662
+ const CONFIRMED_RELAY_REFUSAL_CODES = new Set([-32601]);
1663
+ /**
1664
+ * A CONFIRMED relay refusal: the response is an error envelope whose
1665
+ * code is in the declared registry. Strictly stronger evidence than
1666
+ * {@link isRelayShapedFailure} (every confirmed refusal is
1667
+ * relay-shaped; a null/timeout or code-less error never confirms).
1668
+ * Confirmed refusal outranks a positive `serverTools` advertisement at
1669
+ * the latch — classification keys on confirmed attempt outcomes, never
1670
+ * advertisement (#440's doctrine applied symmetrically; the
1671
+ * advertises-but-refuses host is H2's worst dead-tap shape and was
1672
+ * unlatchable before this predicate existed).
1673
+ */
1674
+ function isConfirmedRelayRefusal(resp) {
1675
+ return (resp !== null &&
1676
+ resp.error !== undefined &&
1677
+ resp.error !== null &&
1678
+ typeof resp.error.code === 'number' &&
1679
+ CONFIRMED_RELAY_REFUSAL_CODES.has(resp.error.code));
1680
+ }
1616
1681
  /**
1617
1682
  * Fire a single canonical action envelope (`tools/call
1618
1683
  * ggui_runtime_submit_action`) carrying the typed `{kind, payload, …}` shape
@@ -1930,13 +1995,12 @@ function hideToast(el) {
1930
1995
  el.setAttribute('aria-hidden', 'true');
1931
1996
  clearToastAnnouncement();
1932
1997
  }
1998
+ /** The single action-toast element, reused for every message. */
1999
+ const ACTION_TOAST_ID = '__ggui-action-toast__';
1933
2000
  function showActionToast(text, kind, onDismiss) {
1934
2001
  if (typeof document === 'undefined')
1935
2002
  return;
1936
- const w = window;
1937
- if (w.__GGUI_TOAST_DISABLED__)
1938
- return;
1939
- const id = '__ggui-action-toast__';
2003
+ const id = ACTION_TOAST_ID;
1940
2004
  let el = document.getElementById(id);
1941
2005
  if (!el) {
1942
2006
  el = document.createElement('div');
@@ -1972,6 +2036,9 @@ function showActionToast(text, kind, onDismiss) {
1972
2036
  : 'rgba(178,54,54,.94)';
1973
2037
  el.style.background = bg;
1974
2038
  el.textContent = text;
2039
+ // Any message rendered here replaces whatever stood before — a
2040
+ // standing relay notice included; the mark goes with it.
2041
+ el.removeAttribute(RELAY_NOTICE_ATTR);
1975
2042
  el.style.opacity = '1';
1976
2043
  el.style.transform = 'translateX(-50%) translateY(0)';
1977
2044
  // Reset to the non-interactive posture every time, so a toast that
@@ -2202,6 +2269,131 @@ function emitUserActionDoorbell(args) {
2202
2269
  * nothing else about the latch is observable to the host.
2203
2270
  */
2204
2271
  let relayIncapabilityAnnounced = false;
2272
+ /**
2273
+ * The current dead zone's origin and tally (ggui#670 Phase 3 — the
2274
+ * instrument's state): when the latch set, what set it, which render
2275
+ * it belongs to, and how many gestures have been attempted inside it.
2276
+ * Written only by {@link transitionRelayLatch} and
2277
+ * {@link emitRelayDeadTap}; read by nothing else.
2278
+ */
2279
+ let relayLatchedAt;
2280
+ let relayDeadTaps = 0;
2281
+ let relayLatchTrigger;
2282
+ let relayLatchScope = {};
2283
+ function transitionRelayLatch(next, trigger, scope = {}) {
2284
+ relayIncapabilityAnnounced = next;
2285
+ connectionStore.set(!next);
2286
+ if (next && trigger !== undefined) {
2287
+ relayLatchedAt = Date.now();
2288
+ relayDeadTaps = 0;
2289
+ relayLatchTrigger = trigger;
2290
+ relayLatchScope = scope;
2291
+ postObservabilityToParent({
2292
+ kind: 'relay-incapability',
2293
+ state: 'latched',
2294
+ trigger,
2295
+ ...scope,
2296
+ });
2297
+ return;
2298
+ }
2299
+ postObservabilityToParent({
2300
+ kind: 'relay-incapability',
2301
+ state: 'cleared',
2302
+ latchedForMs: relayLatchedAt === undefined ? 0 : Date.now() - relayLatchedAt,
2303
+ deadTaps: relayDeadTaps,
2304
+ ...relayLatchScope,
2305
+ });
2306
+ relayLatchedAt = undefined;
2307
+ relayDeadTaps = 0;
2308
+ relayLatchTrigger = undefined;
2309
+ relayLatchScope = {};
2310
+ }
2311
+ /**
2312
+ * One {@link RelayDeadTapEvent} per gesture attempted inside a standing
2313
+ * dead zone that came back UNDELIVERED (ggui#670 Phase 3). Called from
2314
+ * the dispatch outcome branch ONLY — the channel router's fail-fast
2315
+ * never dispatches, so poll ticks are never counted; off-latch it is a
2316
+ * no-op by construction. Counted at the outcome, not the tap, so the
2317
+ * gesture that heals the zone is never a dead tap.
2318
+ */
2319
+ function emitRelayDeadTap(intent, scope) {
2320
+ if (!relayIncapabilityAnnounced ||
2321
+ relayLatchedAt === undefined ||
2322
+ relayLatchTrigger === undefined) {
2323
+ return;
2324
+ }
2325
+ relayDeadTaps += 1;
2326
+ postObservabilityToParent({
2327
+ kind: 'relay-dead-tap',
2328
+ intent,
2329
+ latchAgeMs: Date.now() - relayLatchedAt,
2330
+ ordinal: relayDeadTaps,
2331
+ trigger: relayLatchTrigger,
2332
+ ...scope,
2333
+ });
2334
+ }
2335
+ /**
2336
+ * Marks the toast element as the STANDING relay notice. The dead-zone
2337
+ * cue arms on this element's absence or invisibility — however it went
2338
+ * away (the user's click or a late `drain_ack` retiring it). `showActionToast`
2339
+ * drops the mark whenever it renders any other message, so a fallback
2340
+ * cue or a later pending toast never passes for the explanation.
2341
+ */
2342
+ const RELAY_NOTICE_ATTR = 'data-ggui-relay-notice';
2343
+ /**
2344
+ * Tag the just-shown notice and place it BEFORE the render root in
2345
+ * document order, so a keyboard or virtual-cursor user meets the
2346
+ * explanation before the dead controls it explains (ggui#670 Phase 3,
2347
+ * a11y fold). Reading order only — the element's fixed-position paint
2348
+ * is unchanged.
2349
+ */
2350
+ function markRelayNotice() {
2351
+ if (typeof document === 'undefined')
2352
+ return;
2353
+ const el = document.getElementById(ACTION_TOAST_ID);
2354
+ if (el === null)
2355
+ return; // no document surface to mark (pre-boot); the cue covers every attempt
2356
+ el.setAttribute(RELAY_NOTICE_ATTR, '');
2357
+ const root = document.querySelector('[data-ggui-session-root]');
2358
+ if (root !== null &&
2359
+ root.parentNode !== null &&
2360
+ (root.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0) {
2361
+ root.parentNode.insertBefore(el, root);
2362
+ }
2363
+ }
2364
+ /** Whether the standing relay notice is on screen right now. */
2365
+ function isRelayNoticeVisible() {
2366
+ if (typeof document === 'undefined')
2367
+ return false;
2368
+ const el = document.getElementById(ACTION_TOAST_ID);
2369
+ return (el !== null &&
2370
+ el.hasAttribute(RELAY_NOTICE_ATTR) &&
2371
+ el.style.opacity !== '0' &&
2372
+ el.getAttribute('aria-hidden') !== 'true');
2373
+ }
2374
+ /**
2375
+ * Boot-time reset of the relay latch (ggui#670 Phase 3). A latch
2376
+ * standing from a prior mount in a re-booted document is closed through
2377
+ * the ONE writer — the host gets its `cleared` edge with the zone's
2378
+ * summary — and the prior mount's standing notice is removed rather
2379
+ * than left describing a session that no longer exists.
2380
+ *
2381
+ * @internal — exported for unit tests.
2382
+ */
2383
+ export function resetRelayLatchForBoot() {
2384
+ if (typeof document !== 'undefined') {
2385
+ const el = document.getElementById(ACTION_TOAST_ID);
2386
+ if (el !== null && el.hasAttribute(RELAY_NOTICE_ATTR))
2387
+ el.remove();
2388
+ }
2389
+ resetRelayCueThrottles();
2390
+ if (relayIncapabilityAnnounced) {
2391
+ transitionRelayLatch(false);
2392
+ }
2393
+ else {
2394
+ connectionStore.set(true);
2395
+ }
2396
+ }
2205
2397
  /**
2206
2398
  * Module-level handle to the CURRENT boot's telemetry sink so
2207
2399
  * module-scope paths (the doorbell emitter below) can report without
@@ -2219,24 +2411,6 @@ let currentTelemetrySink = null;
2219
2411
  * `currentTelemetrySink`.
2220
2412
  */
2221
2413
  let mountSuperseded = false;
2222
- /**
2223
- * Whether the user has manually dismissed the CURRENTLY-STANDING relay
2224
- * notice (ggui#442).
2225
- *
2226
- * "Dismissed the one explanation" is not "wants zero feedback forever",
2227
- * but it IS consent that the explanation itself has been read. So the
2228
- * dead zone this opens is filled with a per-gesture CUE, never with the
2229
- * notice again.
2230
- *
2231
- * Scoped to the notice, not to toasts in general: the doorbell's
2232
- * `action_required` toast is a different message with a different
2233
- * dismissal meaning, so the flag is set from the relay notice's own
2234
- * `onDismiss` callback rather than from `showActionToast` at large.
2235
- *
2236
- * Reset on BOTH latch edges — a re-latch shows a fresh notice nobody
2237
- * has dismissed yet, and a self-heal must leave no residue behind.
2238
- */
2239
- let relayNoticeDismissed = false;
2240
2414
  /**
2241
2415
  * Timestamp of the last fallback cue toast, for the throttle below.
2242
2416
  * Reset alongside the latch so a new dead zone never inherits an old
@@ -2312,7 +2486,11 @@ function resetRelayCueThrottles() {
2312
2486
  /** @internal — exported for unit tests to reset module state. */
2313
2487
  export function __resetRelayNoticeForTest() {
2314
2488
  relayIncapabilityAnnounced = false;
2315
- relayNoticeDismissed = false;
2489
+ connectionStore.set(true);
2490
+ relayLatchedAt = undefined;
2491
+ relayDeadTaps = 0;
2492
+ relayLatchTrigger = undefined;
2493
+ relayLatchScope = {};
2316
2494
  resetRelayCueThrottles();
2317
2495
  }
2318
2496
  /**
@@ -2372,10 +2550,8 @@ function resolveRelayCueTarget() {
2372
2550
  * be a lie about someone else's element. The honest counterpart is a
2373
2551
  * message in the announcer's own region, saying what the pulse means.
2374
2552
  *
2375
- * NOT gated on `__GGUI_TOAST_DISABLED__`: that flag suppresses our
2376
- * toast chrome, and the pulse is not toast chrome — it fires either
2377
- * way. Announcing on the same condition as the visual keeps the two
2378
- * cues telling the same story.
2553
+ * Announcing on the same condition as the visual keeps the two cues
2554
+ * telling the same story.
2379
2555
  *
2380
2556
  * Throttled on its own clock, unlike the pulse — but only against
2381
2557
  * IDENTICAL repeats. A flash repeating on every gesture costs a sighted
@@ -2431,8 +2607,10 @@ function retractRelayCueAnnouncement() {
2431
2607
  }
2432
2608
  /**
2433
2609
  * The dead-zone cue (ggui#442): the smallest honest "that did nothing"
2434
- * signal, for a gesture on a host already known — and already
2435
- * explained — to be relay-incapable.
2610
+ * signal, for a gesture on a host already known to be relay-incapable
2611
+ * while the standing explanation is NOT on screen — dismissed by the
2612
+ * user or retired by a late `drain_ack` (ggui#670 Phase 3: the cue arms
2613
+ * on the notice's absence, never on how it went away).
2436
2614
  *
2437
2615
  * Component-agnostic by construction. It pulses whatever the user
2438
2616
  * focused without knowing what that is, and falls back to the toast
@@ -2440,7 +2618,7 @@ function retractRelayCueAnnouncement() {
2440
2618
  * (ggui#447) — the pulse via {@link announceRelayCue}, the fallback via
2441
2619
  * the toast primitive's own announcement.
2442
2620
  */
2443
- function showPostDismissalCue(intent) {
2621
+ function showDeadZoneCue(intent) {
2444
2622
  if (typeof document === 'undefined')
2445
2623
  return;
2446
2624
  const target = resolveRelayCueTarget();
@@ -2549,16 +2727,20 @@ export function dispatchSubmitAction(args) {
2549
2727
  // that nothing downstream ever restores — the opposite of "left
2550
2728
  // standing" (see the latch declaration + terminal branch below).
2551
2729
  //
2552
- // Once that explanation has been DISMISSED, though, skipping leaves
2553
- // the gesture with no feedback at all — the dead zone ggui#442
2554
- // names. The cue fills it here, at dispatch time, because the
2555
- // element it pulses is the one the user has focused NOW; by the time
2556
- // the relay response settles, focus may have moved on.
2730
+ // While the latch stands the gesture still dispatches (attempt-always,
2731
+ // #443 — the attempt is the self-heal sensor); its outcome is counted
2732
+ // for the host where it lands (ggui#670 Phase 3, the terminal branch
2733
+ // below). When the standing explanation is not on screen — dismissed,
2734
+ // or retired by a late `drain_ack` — skipping would leave the gesture
2735
+ // with no feedback at all: the dead zone ggui#442 names. The cue fills it
2736
+ // here, at dispatch time, because the element it pulses is the one
2737
+ // the user has focused NOW; by the time the relay response settles,
2738
+ // focus may have moved on.
2557
2739
  if (!relayIncapabilityAnnounced) {
2558
2740
  showActionToast(`→ ${intent}${dataPart}`, 'pending');
2559
2741
  }
2560
- else if (relayNoticeDismissed) {
2561
- showPostDismissalCue(intent);
2742
+ else if (!isRelayNoticeVisible()) {
2743
+ showDeadZoneCue(intent);
2562
2744
  }
2563
2745
  // (2) Try submit_action via host relay. Spec-compliant hosts
2564
2746
  // forward the tools/call to the MCP server's submit_action handler;
@@ -2622,16 +2804,11 @@ export function dispatchSubmitAction(args) {
2622
2804
  if (relayIncapabilityAnnounced &&
2623
2805
  resp !== null &&
2624
2806
  !isRelayShapedFailure(resp)) {
2625
- relayIncapabilityAnnounced = false;
2626
2807
  // Self-heal leaves NO cue residue (ggui#442): the dead zone is
2627
2808
  // gone, so the state that armed the cue goes with it. A later
2628
2809
  // re-latch starts from a clean slate.
2629
- relayNoticeDismissed = false;
2630
2810
  resetRelayCueThrottles();
2631
- postObservabilityToParent({
2632
- kind: 'relay-incapability',
2633
- state: 'cleared',
2634
- });
2811
+ transitionRelayLatch(false);
2635
2812
  // The (1.5) pending toast above was SKIPPED for this gesture —
2636
2813
  // the latch was still standing at dispatch time — so a bare
2637
2814
  // clear leaves the now-false "cannot relay" notice standing with
@@ -2708,19 +2885,26 @@ export function dispatchSubmitAction(args) {
2708
2885
  // standing latch at the response-arrival guard above, and
2709
2886
  // keeps routing to the ordinary per-gesture transient toast
2710
2887
  // below instead.
2888
+ // Two evidence classes latch (ggui#599 cycle-2): the original
2889
+ // advert-silent leg (nothing advertised AND the attempt failed
2890
+ // relay-shaped), and the CONFIRMED-REFUSAL leg — a declared
2891
+ // helper-minted refusal code, which outranks any positive
2892
+ // advertisement (the advertises-but-refuses host previously got a
2893
+ // fresh transient toast per tap, forever). Timeouts and unknown
2894
+ // codes never reach the second leg; the un-classify edge for BOTH
2895
+ // is the same response-arrival clear guard above (attempt-always
2896
+ // preserved — nothing here suppresses dispatch, per the #443
2897
+ // doctrine: the attempt is the self-heal sensor).
2898
+ const confirmedRefusal = isConfirmedRelayRefusal(resp);
2711
2899
  if (hostCapabilitiesCaptured() &&
2712
- !hostCanRelayToolCalls() &&
2713
- isRelayShapedFailure(resp)) {
2900
+ ((!hostCanRelayToolCalls() && isRelayShapedFailure(resp)) ||
2901
+ confirmedRefusal)) {
2714
2902
  if (!relayIncapabilityAnnounced) {
2715
- relayIncapabilityAnnounced = true;
2716
2903
  // Transition edge — the paired 'cleared' edge lives at the
2717
2904
  // response-arrival guard above. NEVER emit off-edge (e.g. per
2718
2905
  // channel poll tick): the router ticks on an interval and
2719
2906
  // would spam; the two edges carry the full information.
2720
- postObservabilityToParent({
2721
- kind: 'relay-incapability',
2722
- state: 'latched',
2723
- });
2907
+ transitionRelayLatch(true, confirmedRefusal ? 'confirmed-refusal' : 'advert-silent', { sessionId, appId });
2724
2908
  // Establish the flag's invariant where the notice it describes
2725
2909
  // is created: a freshly-shown notice is undismissed, and its
2726
2910
  // dead zone starts with an unspent throttle. Today every
@@ -2728,11 +2912,17 @@ export function dispatchSubmitAction(args) {
2728
2912
  // resets both), so this is the same value arriving by a second
2729
2913
  // route — kept because the state belongs to THIS notice, and a
2730
2914
  // future second latch-set path shouldn't have to know that.
2731
- relayNoticeDismissed = false;
2732
2915
  resetRelayCueThrottles();
2733
- showActionToast('This host cannot relay actions to the agent — interactive controls will not work here.', 'action_required', () => {
2734
- relayNoticeDismissed = true;
2735
- });
2916
+ showActionToast('This host cannot relay actions to the agent — interactive controls will not work here.', 'action_required');
2917
+ markRelayNotice();
2918
+ }
2919
+ else {
2920
+ // A gesture attempted inside a standing dead zone came back
2921
+ // undelivered — a dead tap, counted for the host (ggui#670
2922
+ // Phase 3). The latching gesture above is the 'latched' edge,
2923
+ // and a gesture that heals the zone is the 'cleared' edge —
2924
+ // neither is a dead tap.
2925
+ emitRelayDeadTap(intent, { sessionId, appId });
2736
2926
  }
2737
2927
  return;
2738
2928
  }
@@ -1,9 +1,12 @@
1
1
  import { buildWireConfig, StreamBus, } from '@ggui-ai/wire';
2
2
  import { validateOutboundActionEnvelope } from './validation.js';
3
3
  import { defaultProtocolErrorEmitter, fromClientContractViolation, } from './protocol-error.js';
4
- // `StreamBus` (and its bounded reserved-channel replay ring) was
5
- // hoisted into `@ggui-ai/wire` so `<GguiRender>` and this runtime share
6
- // one implementation. Re-exported for the runtime's internal modules.
4
+ // `StreamBus` (and its bounded reserved-channel replay ring) lives in
5
+ // `@ggui-ai/wire` (one implementation for every first-party renderer).
6
+ // It was originally hoisted to share with `<GguiRender>` — that
7
+ // component and its WS action path are DELETED (react host-helper
8
+ // ruling; ggui#599 cycle-2 caught the stale reference here) — and the
9
+ // hoist stands on its own merits. Re-exported for internal modules.
7
10
  export { StreamBus };
8
11
  /**
9
12
  * Build the per-render `WireConfig` for the iframe runtime.
@@ -44,6 +47,9 @@ export function buildRootWireConfig(opts) {
44
47
  }
45
48
  return buildWireConfig({
46
49
  app: { appId: opts.appId, appName: opts.appId },
50
+ // `isConnected` here is the static config field; `useRender()`
51
+ // reads the LIVE value from wire's document connection store,
52
+ // which the runtime writes at the relay latch's edges (ggui#670).
47
53
  render: { sessionId: opts.sessionId, isConnected: true },
48
54
  auth: { isAuthenticated: false },
49
55
  // Resolve the active render's actionSpec on every dispatch.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/iframe-runtime",
3
- "version": "0.13.0",
3
+ "version": "0.14.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",
@@ -28,6 +28,11 @@
28
28
  "types": "./dist/server.d.ts",
29
29
  "import": "./dist/server.js",
30
30
  "default": "./dist/server.js"
31
+ },
32
+ "./observability": {
33
+ "types": "./dist/observability.d.ts",
34
+ "import": "./dist/observability.js",
35
+ "default": "./dist/observability.js"
31
36
  }
32
37
  },
33
38
  "dependencies": {
@@ -36,12 +41,12 @@
36
41
  "react": "^19.0.0",
37
42
  "react-dom": "^19.0.0",
38
43
  "zod": "^4.3.6",
39
- "@ggui-ai/design": "0.13.0",
40
- "@ggui-ai/preview-a2ui": "0.13.0",
41
- "@ggui-ai/gadgets": "0.13.0",
42
- "@ggui-ai/protocol": "0.13.0",
43
- "@ggui-ai/wire": "0.13.0",
44
- "@ggui-ai/live-channel": "0.13.0"
44
+ "@ggui-ai/gadgets": "0.14.0",
45
+ "@ggui-ai/preview-a2ui": "0.14.0",
46
+ "@ggui-ai/live-channel": "0.14.0",
47
+ "@ggui-ai/protocol": "0.14.0",
48
+ "@ggui-ai/design": "0.14.0",
49
+ "@ggui-ai/wire": "0.14.0"
45
50
  },
46
51
  "peerDependencies": {
47
52
  "@modelcontextprotocol/sdk": "^1.27.0"
@@ -52,7 +57,7 @@
52
57
  "@types/node": "^24.0.0",
53
58
  "@types/react": "^19.0.0",
54
59
  "@types/react-dom": "^19.0.0",
55
- "esbuild": "^0.25.12",
60
+ "esbuild": "^0.28.2",
56
61
  "jsdom": "^24.0.0",
57
62
  "tsx": "^4.21.0",
58
63
  "typescript": "^5.0.0",
@@ -71,11 +76,11 @@
71
76
  "access": "public"
72
77
  },
73
78
  "engines": {
74
- "node": ">=20.0.0"
79
+ "node": ">=22.0.0"
75
80
  },
76
81
  "author": "ggui contributors <hello@ggui.ai>",
77
82
  "scripts": {
78
- "build": "rm -rf dist.staging && tsc -p tsconfig.types.json --outDir dist.staging && GGUI_ESBUILD_OUT_DIR=dist.staging node esbuild.config.mjs && node ../scripts/atomic-swap.mjs dist.staging dist && pnpm run check:bundle-size",
83
+ "build": "rm -rf dist.staging-$$ && tsc -p tsconfig.types.json --outDir dist.staging-$$ && GGUI_ESBUILD_OUT_DIR=dist.staging-$$ node esbuild.config.mjs && node ../scripts/atomic-swap.mjs dist.staging-$$ dist && pnpm run check:bundle-size",
79
84
  "check:bundle-size": "tsx scripts/check-bundle-size.ts",
80
85
  "check:bundle-size:self-test": "tsx scripts/check-bundle-size.ts --self-test",
81
86
  "typecheck": "tsc --noEmit",