@ggui-ai/iframe-runtime 0.13.0 → 0.15.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,10 +27,11 @@
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';
33
- import { projectHostContext } from '@ggui-ai/protocol';
34
+ import { projectHostContext } from '@ggui-ai/protocol/wire';
34
35
  import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
35
36
  import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
36
37
  import { mapHostPaletteToGguiVars } from './host-palette-bridge.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)
@@ -412,11 +421,35 @@ export async function bootSequence(opts) {
412
421
  // covers every boot path; arrivals before a renderer publishes
413
422
  // `applyRender` no-op inside the listener.
414
423
  installPersistentToolResultListener(app);
424
+ // Fresh boot ⇒ the relay latch and the connection store start
425
+ // aligned (ggui#670): a standing latch from a prior mount in a
426
+ // re-booted document must not outlive the store it was written to.
427
+ // It runs BEFORE the handshake so that every boot passes the close
428
+ // edge before it can latch — the boot-failed write below is then
429
+ // always on-edge (ggui#830 fold): a prior zone ends with its
430
+ // 'cleared' edge and its summary, never buried under a second
431
+ // 'latched' with its tally erased.
432
+ resetRelayLatchForBoot();
415
433
  const initResult = await connectApp(app, transport);
416
434
  if (!initResult.ok) {
417
435
  setStatus(refs, `ui/initialize failed: ${initResult.message}`, 'error');
418
436
  retireShellLoadingIndicator(doc);
419
437
  emitBootFailure('UI_INITIALIZE_FAILED', initResult.message);
438
+ // The maximal dead zone (ggui#830): the host refused the handshake
439
+ // every session begins with, so nothing can be delivered here — the
440
+ // relay latch sets through its one writer (store false, 'latched'
441
+ // edge, standing notice) instead of leaving this document with
442
+ // `isConnected === true`, no explanation and no count. No gesture
443
+ // is needed for that knowledge, and no gesture is suppressed by it:
444
+ // every later tap still attempts (attempt-always) and lands as a
445
+ // counted dead tap. The scope is empty because no bootstrap has
446
+ // named a session yet; it clears the only way a handshake failure
447
+ // can — the next boot's close edge, which this boot itself already
448
+ // passed above, so this write is on-edge by construction. The
449
+ // gesture gate's pre-capture rule ("never latch before the host has
450
+ // been asked") does not apply here: the host HAS been asked — this
451
+ // is its answer to `ui/initialize`, resolved as failure.
452
+ announceRelayIncapability('boot-failed', {});
420
453
  return { ok: false, mountedRender };
421
454
  }
422
455
  // Record what this host said it can do (ggui#440). Earliest valid
@@ -1606,13 +1639,69 @@ async function callServerToolSpec(toolName, args) {
1606
1639
  return { jsonrpc: '2.0', result: result };
1607
1640
  }
1608
1641
  catch (err) {
1642
+ // Preserve the JSON-RPC error code when the SDK throw carries one
1643
+ // (McpError does) — ggui#599 cycle-2: without it a genuine helper
1644
+ // refusal (-32601 / -32000) is indistinguishable from a socket
1645
+ // drop at both latch call sites, and the confirmed-refusal
1646
+ // classification below this file cannot exist. Code-less throws
1647
+ // stay code-less: transport-shaped, never a confirmed refusal.
1648
+ const code = err.code;
1609
1649
  return {
1610
1650
  error: {
1651
+ ...(typeof code === 'number' ? { code } : {}),
1611
1652
  message: err instanceof Error ? err.message : String(err),
1612
1653
  },
1613
1654
  };
1614
1655
  }
1615
1656
  }
1657
+ /**
1658
+ * Declared relay-refusal registry (ggui#599 cycle-2, finding 34): the
1659
+ * JSON-RPC error codes a HELPER mints to refuse the relay leg itself —
1660
+ * proof the envelope never round-tripped to the server. Server-side
1661
+ * tool failures come back as well-formed `CallToolResult`s (often
1662
+ * `{ok:false}`), never as JSON-RPC errors on this leg, so a registry
1663
+ * code here is helper-minted by construction:
1664
+ *
1665
+ * - `-32601` — method not supported (the spec's in-band refusal; the
1666
+ * host-helper conformance catalog's H3 shape). The ONLY registry
1667
+ * entry, deliberately.
1668
+ *
1669
+ * `-32000` is EXCLUDED by ruling (ggui#599 cycle-2): it collides with
1670
+ * the MCP SDK's `ErrorCode.ConnectionClosed`, so through the App-SDK
1671
+ * catch a helper-minted -32000 refusal and an SDK-local transport loss
1672
+ * are indistinguishable — and a transport loss must NEVER classify
1673
+ * (misbranding a dropped connection as incapability is the
1674
+ * misclassification asymmetry the cycle killed the mount probe over).
1675
+ * The one live -32000 minter (the RN dispatcher's `no-tool-handler`)
1676
+ * was fixed at source to -32601 in the same slice.
1677
+ *
1678
+ * Unknown codes NEVER confirm — they may be proxy- or server-minted
1679
+ * (e.g. `-32603` internal error), and classifying them would misbrand
1680
+ * capable mounts. They never CONFIRM (they can latch only through
1681
+ * the advert-silent leg, never on an advertising host); off-latch they
1682
+ * stay on the per-gesture transient path, and inside a standing zone
1683
+ * they count as dead taps like every other undelivered outcome
1684
+ * (ggui#826).
1685
+ */
1686
+ const CONFIRMED_RELAY_REFUSAL_CODES = new Set([-32601]);
1687
+ /**
1688
+ * A CONFIRMED relay refusal: the response is an error envelope whose
1689
+ * code is in the declared registry. Strictly stronger evidence than
1690
+ * {@link isRelayShapedFailure} (every confirmed refusal is
1691
+ * relay-shaped; a null/timeout or code-less error never confirms).
1692
+ * Confirmed refusal outranks a positive `serverTools` advertisement at
1693
+ * the latch — classification keys on confirmed attempt outcomes, never
1694
+ * advertisement (#440's doctrine applied symmetrically; the
1695
+ * advertises-but-refuses host is H2's worst dead-tap shape and was
1696
+ * unlatchable before this predicate existed).
1697
+ */
1698
+ function isConfirmedRelayRefusal(resp) {
1699
+ return (resp !== null &&
1700
+ resp.error !== undefined &&
1701
+ resp.error !== null &&
1702
+ typeof resp.error.code === 'number' &&
1703
+ CONFIRMED_RELAY_REFUSAL_CODES.has(resp.error.code));
1704
+ }
1616
1705
  /**
1617
1706
  * Fire a single canonical action envelope (`tools/call
1618
1707
  * ggui_runtime_submit_action`) carrying the typed `{kind, payload, …}` shape
@@ -1710,6 +1799,20 @@ function classifySubmitActionResponse(resp) {
1710
1799
  * carries a result envelope, so neither is evidence the relay worked.
1711
1800
  */
1712
1801
  function isRelayShapedFailure(resp) {
1802
+ // "Relay-shaped" IS "undelivered" on this leg, by construction — not
1803
+ // by hope (ggui#832, ruled from the hosts' source): every JSON-RPC
1804
+ // error the iframe sees here is host- or transport-minted. The MCP
1805
+ // SDK server turns a tool handler's throw into an `isError` RESULT and
1806
+ // mints JSON-RPC codes only for its own gate; the guuey view-host
1807
+ // answers a rejecting relay with an in-band unavailable RESULT, never
1808
+ // an error; ggui's embed host proxies over HTTP and mints its OWN
1809
+ // codes for a failed proxy. So a server-side outcome always arrives
1810
+ // as a `CallToolResult` (`ok:true` and `ok:false` alike clear the
1811
+ // latch), and an error envelope always means the call did not round
1812
+ // trip. A third-party host that forwarded a server's JSON-RPC error
1813
+ // verbatim would be indistinguishable by code — that is the
1814
+ // host-helper conformance catalog's business (H3 / R1), not this
1815
+ // predicate's.
1713
1816
  // Null-tolerant on `error`, matching `classifySubmitActionResponse`'s
1714
1817
  // own check: an `{error:null, result:{...}}` envelope classifies as
1715
1818
  // success there, so it must also read as NOT relay-shaped-failure
@@ -1930,13 +2033,12 @@ function hideToast(el) {
1930
2033
  el.setAttribute('aria-hidden', 'true');
1931
2034
  clearToastAnnouncement();
1932
2035
  }
2036
+ /** The single action-toast element, reused for every message. */
2037
+ const ACTION_TOAST_ID = '__ggui-action-toast__';
1933
2038
  function showActionToast(text, kind, onDismiss) {
1934
2039
  if (typeof document === 'undefined')
1935
2040
  return;
1936
- const w = window;
1937
- if (w.__GGUI_TOAST_DISABLED__)
1938
- return;
1939
- const id = '__ggui-action-toast__';
2041
+ const id = ACTION_TOAST_ID;
1940
2042
  let el = document.getElementById(id);
1941
2043
  if (!el) {
1942
2044
  el = document.createElement('div');
@@ -1972,6 +2074,9 @@ function showActionToast(text, kind, onDismiss) {
1972
2074
  : 'rgba(178,54,54,.94)';
1973
2075
  el.style.background = bg;
1974
2076
  el.textContent = text;
2077
+ // Any message rendered here replaces whatever stood before — a
2078
+ // standing relay notice included; the mark goes with it.
2079
+ el.removeAttribute(RELAY_NOTICE_ATTR);
1975
2080
  el.style.opacity = '1';
1976
2081
  el.style.transform = 'translateX(-50%) translateY(0)';
1977
2082
  // Reset to the non-interactive posture every time, so a toast that
@@ -2202,6 +2307,147 @@ function emitUserActionDoorbell(args) {
2202
2307
  * nothing else about the latch is observable to the host.
2203
2308
  */
2204
2309
  let relayIncapabilityAnnounced = false;
2310
+ /**
2311
+ * The current dead zone's origin and tally (ggui#670 Phase 3 — the
2312
+ * instrument's state): when the latch set, what set it, which render
2313
+ * it belongs to, and how many gestures have been attempted inside it.
2314
+ * Written only by {@link transitionRelayLatch} and
2315
+ * {@link emitRelayDeadTap}; read by nothing else.
2316
+ */
2317
+ let relayLatchedAt;
2318
+ let relayDeadTaps = 0;
2319
+ let relayLatchTrigger;
2320
+ let relayLatchScope = {};
2321
+ function transitionRelayLatch(next, trigger, scope = {}) {
2322
+ relayIncapabilityAnnounced = next;
2323
+ connectionStore.set(!next);
2324
+ if (next && trigger !== undefined) {
2325
+ relayLatchedAt = Date.now();
2326
+ relayDeadTaps = 0;
2327
+ relayLatchTrigger = trigger;
2328
+ relayLatchScope = scope;
2329
+ postObservabilityToParent({
2330
+ kind: 'relay-incapability',
2331
+ state: 'latched',
2332
+ trigger,
2333
+ ...scope,
2334
+ });
2335
+ return;
2336
+ }
2337
+ postObservabilityToParent({
2338
+ kind: 'relay-incapability',
2339
+ state: 'cleared',
2340
+ latchedForMs: relayLatchedAt === undefined ? 0 : Date.now() - relayLatchedAt,
2341
+ deadTaps: relayDeadTaps,
2342
+ ...relayLatchScope,
2343
+ });
2344
+ relayLatchedAt = undefined;
2345
+ relayDeadTaps = 0;
2346
+ relayLatchTrigger = undefined;
2347
+ relayLatchScope = {};
2348
+ }
2349
+ /**
2350
+ * Set the latch AND present it — the three steps every latch-set path
2351
+ * takes, in one place: the transition through the ONE writer (store
2352
+ * flip + `'latched'` edge), a fresh dead zone (unspent cue throttles —
2353
+ * the state belongs to THIS notice, so a second latch-set path never
2354
+ * has to know the clear edge already reset it), and the standing
2355
+ * explanation, marked so the per-gesture cue arms on its dismissal.
2356
+ * Callers: the dispatch outcome gate (a gesture's confirmed relay
2357
+ * failure) and the boot sequence (a refused `ui/initialize`, ggui#830).
2358
+ */
2359
+ function announceRelayIncapability(trigger, scope) {
2360
+ transitionRelayLatch(true, trigger, scope);
2361
+ resetRelayCueThrottles();
2362
+ showActionToast('This host cannot relay actions to the agent — interactive controls will not work here.', 'action_required');
2363
+ markRelayNotice();
2364
+ }
2365
+ /**
2366
+ * One {@link RelayDeadTapEvent} per gesture attempted inside a standing
2367
+ * dead zone that came back UNDELIVERED (ggui#670 Phase 3). Called from
2368
+ * the dispatch outcome branch ONLY — the channel router's fail-fast
2369
+ * never dispatches, so poll ticks are never counted; off-latch it is a
2370
+ * no-op by construction. Counted at the outcome, not the tap, so the
2371
+ * gesture that heals the zone is never a dead tap.
2372
+ */
2373
+ function emitRelayDeadTap(intent, scope) {
2374
+ if (!relayIncapabilityAnnounced ||
2375
+ relayLatchedAt === undefined ||
2376
+ relayLatchTrigger === undefined) {
2377
+ return;
2378
+ }
2379
+ relayDeadTaps += 1;
2380
+ postObservabilityToParent({
2381
+ kind: 'relay-dead-tap',
2382
+ intent,
2383
+ latchAgeMs: Date.now() - relayLatchedAt,
2384
+ ordinal: relayDeadTaps,
2385
+ trigger: relayLatchTrigger,
2386
+ ...scope,
2387
+ });
2388
+ }
2389
+ /**
2390
+ * Marks the toast element as the STANDING relay notice. The dead-zone
2391
+ * cue arms on this element's absence or invisibility — however it went
2392
+ * away (the user's click or a late `drain_ack` retiring it). `showActionToast`
2393
+ * drops the mark whenever it renders any other message, so a fallback
2394
+ * cue or a later pending toast never passes for the explanation.
2395
+ */
2396
+ const RELAY_NOTICE_ATTR = 'data-ggui-relay-notice';
2397
+ /**
2398
+ * Tag the just-shown notice and place it BEFORE the render root in
2399
+ * document order, so a keyboard or virtual-cursor user meets the
2400
+ * explanation before the dead controls it explains (ggui#670 Phase 3,
2401
+ * a11y fold). Reading order only — the element's fixed-position paint
2402
+ * is unchanged.
2403
+ */
2404
+ function markRelayNotice() {
2405
+ if (typeof document === 'undefined')
2406
+ return;
2407
+ const el = document.getElementById(ACTION_TOAST_ID);
2408
+ if (el === null)
2409
+ return; // no document surface to mark (pre-boot); the cue covers every attempt
2410
+ el.setAttribute(RELAY_NOTICE_ATTR, '');
2411
+ const root = document.querySelector('[data-ggui-session-root]');
2412
+ if (root !== null &&
2413
+ root.parentNode !== null &&
2414
+ (root.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0) {
2415
+ root.parentNode.insertBefore(el, root);
2416
+ }
2417
+ }
2418
+ /** Whether the standing relay notice is on screen right now. */
2419
+ function isRelayNoticeVisible() {
2420
+ if (typeof document === 'undefined')
2421
+ return false;
2422
+ const el = document.getElementById(ACTION_TOAST_ID);
2423
+ return (el !== null &&
2424
+ el.hasAttribute(RELAY_NOTICE_ATTR) &&
2425
+ el.style.opacity !== '0' &&
2426
+ el.getAttribute('aria-hidden') !== 'true');
2427
+ }
2428
+ /**
2429
+ * Boot-time reset of the relay latch (ggui#670 Phase 3). A latch
2430
+ * standing from a prior mount in a re-booted document is closed through
2431
+ * the ONE writer — the host gets its `cleared` edge with the zone's
2432
+ * summary — and the prior mount's standing notice is removed rather
2433
+ * than left describing a session that no longer exists.
2434
+ *
2435
+ * @internal — exported for unit tests.
2436
+ */
2437
+ export function resetRelayLatchForBoot() {
2438
+ if (typeof document !== 'undefined') {
2439
+ const el = document.getElementById(ACTION_TOAST_ID);
2440
+ if (el !== null && el.hasAttribute(RELAY_NOTICE_ATTR))
2441
+ el.remove();
2442
+ }
2443
+ resetRelayCueThrottles();
2444
+ if (relayIncapabilityAnnounced) {
2445
+ transitionRelayLatch(false);
2446
+ }
2447
+ else {
2448
+ connectionStore.set(true);
2449
+ }
2450
+ }
2205
2451
  /**
2206
2452
  * Module-level handle to the CURRENT boot's telemetry sink so
2207
2453
  * module-scope paths (the doorbell emitter below) can report without
@@ -2219,24 +2465,6 @@ let currentTelemetrySink = null;
2219
2465
  * `currentTelemetrySink`.
2220
2466
  */
2221
2467
  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
2468
  /**
2241
2469
  * Timestamp of the last fallback cue toast, for the throttle below.
2242
2470
  * Reset alongside the latch so a new dead zone never inherits an old
@@ -2312,7 +2540,11 @@ function resetRelayCueThrottles() {
2312
2540
  /** @internal — exported for unit tests to reset module state. */
2313
2541
  export function __resetRelayNoticeForTest() {
2314
2542
  relayIncapabilityAnnounced = false;
2315
- relayNoticeDismissed = false;
2543
+ connectionStore.set(true);
2544
+ relayLatchedAt = undefined;
2545
+ relayDeadTaps = 0;
2546
+ relayLatchTrigger = undefined;
2547
+ relayLatchScope = {};
2316
2548
  resetRelayCueThrottles();
2317
2549
  }
2318
2550
  /**
@@ -2372,10 +2604,8 @@ function resolveRelayCueTarget() {
2372
2604
  * be a lie about someone else's element. The honest counterpart is a
2373
2605
  * message in the announcer's own region, saying what the pulse means.
2374
2606
  *
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.
2607
+ * Announcing on the same condition as the visual keeps the two cues
2608
+ * telling the same story.
2379
2609
  *
2380
2610
  * Throttled on its own clock, unlike the pulse — but only against
2381
2611
  * IDENTICAL repeats. A flash repeating on every gesture costs a sighted
@@ -2431,8 +2661,10 @@ function retractRelayCueAnnouncement() {
2431
2661
  }
2432
2662
  /**
2433
2663
  * 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.
2664
+ * signal, for a gesture on a host already known to be relay-incapable
2665
+ * while the standing explanation is NOT on screen — dismissed by the
2666
+ * user or retired by a late `drain_ack` (ggui#670 Phase 3: the cue arms
2667
+ * on the notice's absence, never on how it went away).
2436
2668
  *
2437
2669
  * Component-agnostic by construction. It pulses whatever the user
2438
2670
  * focused without knowing what that is, and falls back to the toast
@@ -2440,7 +2672,7 @@ function retractRelayCueAnnouncement() {
2440
2672
  * (ggui#447) — the pulse via {@link announceRelayCue}, the fallback via
2441
2673
  * the toast primitive's own announcement.
2442
2674
  */
2443
- function showPostDismissalCue(intent) {
2675
+ function showDeadZoneCue(intent) {
2444
2676
  if (typeof document === 'undefined')
2445
2677
  return;
2446
2678
  const target = resolveRelayCueTarget();
@@ -2549,16 +2781,20 @@ export function dispatchSubmitAction(args) {
2549
2781
  // that nothing downstream ever restores — the opposite of "left
2550
2782
  // standing" (see the latch declaration + terminal branch below).
2551
2783
  //
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.
2784
+ // While the latch stands the gesture still dispatches (attempt-always,
2785
+ // #443 — the attempt is the self-heal sensor); its outcome is counted
2786
+ // for the host where it lands (ggui#670 Phase 3, the terminal branch
2787
+ // below). When the standing explanation is not on screen — dismissed,
2788
+ // or retired by a late `drain_ack` — skipping would leave the gesture
2789
+ // with no feedback at all: the dead zone ggui#442 names. The cue fills it
2790
+ // here, at dispatch time, because the element it pulses is the one
2791
+ // the user has focused NOW; by the time the relay response settles,
2792
+ // focus may have moved on.
2557
2793
  if (!relayIncapabilityAnnounced) {
2558
2794
  showActionToast(`→ ${intent}${dataPart}`, 'pending');
2559
2795
  }
2560
- else if (relayNoticeDismissed) {
2561
- showPostDismissalCue(intent);
2796
+ else if (!isRelayNoticeVisible()) {
2797
+ showDeadZoneCue(intent);
2562
2798
  }
2563
2799
  // (2) Try submit_action via host relay. Spec-compliant hosts
2564
2800
  // forward the tools/call to the MCP server's submit_action handler;
@@ -2622,16 +2858,11 @@ export function dispatchSubmitAction(args) {
2622
2858
  if (relayIncapabilityAnnounced &&
2623
2859
  resp !== null &&
2624
2860
  !isRelayShapedFailure(resp)) {
2625
- relayIncapabilityAnnounced = false;
2626
2861
  // Self-heal leaves NO cue residue (ggui#442): the dead zone is
2627
2862
  // gone, so the state that armed the cue goes with it. A later
2628
2863
  // re-latch starts from a clean slate.
2629
- relayNoticeDismissed = false;
2630
2864
  resetRelayCueThrottles();
2631
- postObservabilityToParent({
2632
- kind: 'relay-incapability',
2633
- state: 'cleared',
2634
- });
2865
+ transitionRelayLatch(false);
2635
2866
  // The (1.5) pending toast above was SKIPPED for this gesture —
2636
2867
  // the latch was still standing at dispatch time — so a bare
2637
2868
  // clear leaves the now-false "cannot relay" notice standing with
@@ -2708,32 +2939,52 @@ export function dispatchSubmitAction(args) {
2708
2939
  // standing latch at the response-arrival guard above, and
2709
2940
  // keeps routing to the ordinary per-gesture transient toast
2710
2941
  // below instead.
2942
+ // Two evidence classes latch (ggui#599 cycle-2): the original
2943
+ // advert-silent leg (nothing advertised AND the attempt failed
2944
+ // relay-shaped), and the CONFIRMED-REFUSAL leg — a declared
2945
+ // helper-minted refusal code, which outranks any positive
2946
+ // advertisement (the advertises-but-refuses host previously got a
2947
+ // fresh transient toast per tap, forever). Timeouts and unknown
2948
+ // codes never reach the second leg — they never CONFIRM (the
2949
+ // advert-silent leg alone can latch on them); inside a standing
2950
+ // zone they are counted like every other undelivered outcome by
2951
+ // the branch below (ggui#826). The un-classify edge for
2952
+ // BOTH legs is the same response-arrival clear guard above
2953
+ // (attempt-always preserved — nothing here suppresses dispatch,
2954
+ // per the #443 doctrine: the attempt is the self-heal sensor).
2955
+ const confirmedRefusal = isConfirmedRelayRefusal(resp);
2956
+ if (relayIncapabilityAnnounced && isRelayShapedFailure(resp)) {
2957
+ // A gesture whose undelivered outcome landed inside a standing
2958
+ // dead zone — a dead tap, counted for the host (ggui#670 Phase 3);
2959
+ // an in-flight gesture that raced the latching one counts too.
2960
+ // Counted on the OUTCOME and keyed on the LATCH, never on
2961
+ // advertisement (ggui#826): the advertises-but-refuses host
2962
+ // latches by a confirmed refusal, and its later code-less or
2963
+ // timeout failures are exactly the undelivered gestures the
2964
+ // RelayDeadTapEvent contract promises to count. The eligibility
2965
+ // predicate below is the LATCH-SET rule — a timeout or unknown
2966
+ // code never CONFIRMS; it can latch only through the advert-silent
2967
+ // leg, never on an advertising host — and is the wrong key for
2968
+ // counting. Every outcome that
2969
+ // reaches here while the latch stands is relay-shaped by
2970
+ // construction (the response-arrival guard above cleared on any
2971
+ // well-formed result); the conjunct states the contract instead
2972
+ // of trusting that. The latching gesture is the 'latched' edge
2973
+ // and a gesture that heals the zone is the 'cleared' edge —
2974
+ // neither is a dead tap.
2975
+ emitRelayDeadTap(intent, { sessionId, appId });
2976
+ return;
2977
+ }
2711
2978
  if (hostCapabilitiesCaptured() &&
2712
- !hostCanRelayToolCalls() &&
2713
- isRelayShapedFailure(resp)) {
2714
- if (!relayIncapabilityAnnounced) {
2715
- relayIncapabilityAnnounced = true;
2716
- // Transition edge — the paired 'cleared' edge lives at the
2717
- // response-arrival guard above. NEVER emit off-edge (e.g. per
2718
- // channel poll tick): the router ticks on an interval and
2719
- // would spam; the two edges carry the full information.
2720
- postObservabilityToParent({
2721
- kind: 'relay-incapability',
2722
- state: 'latched',
2723
- });
2724
- // Establish the flag's invariant where the notice it describes
2725
- // is created: a freshly-shown notice is undismissed, and its
2726
- // dead zone starts with an unspent throttle. Today every
2727
- // re-latch already passes through the clear edge above (which
2728
- // resets both), so this is the same value arriving by a second
2729
- // route — kept because the state belongs to THIS notice, and a
2730
- // future second latch-set path shouldn't have to know that.
2731
- relayNoticeDismissed = false;
2732
- resetRelayCueThrottles();
2733
- showActionToast('This host cannot relay actions to the agent — interactive controls will not work here.', 'action_required', () => {
2734
- relayNoticeDismissed = true;
2735
- });
2736
- }
2979
+ ((!hostCanRelayToolCalls() && isRelayShapedFailure(resp)) ||
2980
+ confirmedRefusal)) {
2981
+ // Transition edge — the paired 'cleared' edge lives at the
2982
+ // response-arrival guard above. NEVER emit off-edge (e.g. per
2983
+ // channel poll tick): the router ticks on an interval and
2984
+ // would spam; the two edges carry the full information. A
2985
+ // standing latch never reaches here: its undelivered outcomes
2986
+ // were counted above, and a delivered one cleared it.
2987
+ announceRelayIncapability(confirmedRefusal ? 'confirmed-refusal' : 'advert-silent', { sessionId, appId });
2737
2988
  return;
2738
2989
  }
2739
2990
  showActionToast(`⚠ ${intent} — could not reach the agent`, 'error');
@@ -2742,19 +2993,22 @@ export function dispatchSubmitAction(args) {
2742
2993
  /**
2743
2994
  * Channel-transport router's `tools/call` invoker (ggui#440) —
2744
2995
  * iframe-polling transport, `tools/call` against the parent MCP host
2745
- * via `app.callServerTool` (spec-canonical) when the App handle is
2746
- * set; falls back to raw postMessage pre-handshake or in tests.
2996
+ * via `app.callServerTool` (spec-canonical); with no App bound the
2997
+ * call answers a code-less error envelope — never a raw postMessage.
2747
2998
  * Direct call (no LLM consent loop). Returns the tool's
2748
2999
  * structuredContent (or `content[0]` if that's where the payload
2749
3000
  * landed) as a `JsonValue`. On RPC error we throw — the router
2750
3001
  * catches and silently retries on the next tick.
2751
3002
  *
2752
- * Once a REAL gesture has confirmed this host can't relay (the latch
2753
- * above — set only after {@link dispatchSubmitAction} actually tried
2754
- * and failed on a host with no `serverTools` advertised, with
2755
- * capabilities already captured, AND the failure was relay-shaped —
2756
- * not a well-formed `{ok:false}` result; see `isRelayShapedFailure`),
2757
- * every subsequent poll will fail identically.
3003
+ * Once this document has confirmed it cannot relay (the latch above —
3004
+ * set through the ONE writer on exactly three paths: a REAL gesture
3005
+ * that failed relay-shaped on a host that never advertised
3006
+ * `serverTools`, with capabilities already captured (`advert-silent`);
3007
+ * a declared refusal code answered under a positive advertisement
3008
+ * (`confirmed-refusal`); or the host's own failure of the mandatory
3009
+ * `ui/initialize` handshake, before any gesture (`boot-failed`,
3010
+ * ggui#830) — never a well-formed `{ok:false}` result; see
3011
+ * `isRelayShapedFailure`), every subsequent poll will fail identically.
2758
3012
  *
2759
3013
  * This guard does NOT stop the router's poll loop, and nothing here
2760
3014
  * should ever be written so that it could (ggui#443). It does two
@@ -2780,10 +3034,13 @@ export function dispatchSubmitAction(args) {
2780
3034
  * (ggui's own embed host pre-Task-2 is exactly this case) — silently
2781
3035
  * killing working channel polls forever. That is "absence of a
2782
3036
  * capability blocks an attempt", which the fail-safe constraint
2783
- * forbids. The latch implies `!hostCanRelayToolCalls()` (it only
2784
- * ever sets inside that guard), so testing it alone is sufficient —
2785
- * a host that never fails a real gesture never latches, and its
2786
- * channels keep working.
3037
+ * forbids. The latch is set only by a CONFIRMED OUTCOME — a failed
3038
+ * real gesture (the advert-silent leg, or a confirmed refusal under a
3039
+ * positive advertisement, ggui#599 cycle-2) or a failed handshake
3040
+ * (ggui#830), never by an advertisement's absence — so testing the
3041
+ * latch alone is sufficient: a host that answers its handshake and
3042
+ * never fails a real gesture never latches, and its channels keep
3043
+ * working.
2787
3044
  *
2788
3045
  * Extracted to a named export (rather than an inline closure inside
2789
3046
  * `bootProduction`) so unit tests can drive it directly through the
package/dist/types.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * declared the manager's constructor bag has been retired.
16
16
  */
17
17
  import type { McpAppAiGguiRenderMeta } from '@ggui-ai/protocol/integrations/mcp-apps';
18
- import type { HostContextProjection, ComponentGguiSession, SystemGguiSession } from '@ggui-ai/protocol';
18
+ import type { HostContextProjection, ComponentGguiSession, SystemGguiSession } from '@ggui-ai/protocol/wire';
19
19
  /**
20
20
  * The four server-assigned ledger fields on every wire-delivered
21
21
  * {@link GguiSession}. They're stamped by the server's per-render event
@@ -26,7 +26,7 @@
26
26
  * host wrapper explicitly does NOT run this path — its only contract is
27
27
  * the postMessage bridge to the renderer iframe.
28
28
  */
29
- import type { ActionSpec, ActionEnvelope, ContextSpec, JsonObject, PropsSpec, ReservedChannelValidator, StreamSpec, ValidationResult } from '@ggui-ai/protocol';
29
+ import type { ActionSpec, ActionEnvelope, ContextSpec, JsonObject, PropsSpec, ReservedChannelValidator, StreamSpec, ValidationResult } from '@ggui-ai/protocol/wire';
30
30
  import type { CompiledValidatorSet } from './compiled-validators.js';
31
31
  /**
32
32
  * Install the precompiled validators loaded from the bootstrap. Called
@@ -1,4 +1,4 @@
1
- import { isKnownReservedChannel, validateActionEnvelope, validateContextData, validatePropsData, validateStreamData, } from '@ggui-ai/protocol';
1
+ import { isKnownReservedChannel, validateActionEnvelope, validateContextData, validatePropsData, validateStreamData, } from '@ggui-ai/protocol/wire';
2
2
  // =============================================================================
3
3
  // Active precompiled-validator set
4
4
  // =============================================================================
@@ -16,7 +16,7 @@
16
16
  * ProtocolError dual-emission policy, and the tools/call-vs-WS
17
17
  * dispatch transport.
18
18
  */
19
- import type { ActionEnvelope, GguiSession } from '@ggui-ai/protocol';
19
+ import type { ActionEnvelope, GguiSession } from '@ggui-ai/protocol/wire';
20
20
  import type { GguiSessionSeedInput } from './types.js';
21
21
  import { ClientContractViolationError, StreamBus, type WireConfig } from '@ggui-ai/wire';
22
22
  import type { WebSocketMessage } from '@ggui-ai/protocol/transport/websocket';
@@ -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.