@ggui-ai/iframe-runtime 0.6.2 → 0.7.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
@@ -5,8 +5,9 @@ import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
5
5
  import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
6
6
  import { buildRootWireConfig, StreamBus, } from './wire-config.js';
7
7
  import { createChannelTransportRouter, } from './channel-transport.js';
8
+ import { RelayIncapableError } from './relay-incapability.js';
8
9
  import { ChannelRegistry } from '@ggui-ai/live-channel';
9
- import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createPropsUpdateHandler, createRenderHandler, createSystemHandler, } from './channels/index.js';
10
+ import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createPropsUpdateHandler, createRenderHandler, } from './channels/index.js';
10
11
  import { connectViaRegistry, } from './registry-subscribe.js';
11
12
  import { buildEventsPolling } from './events-polling.js';
12
13
  import { ensureStatusDom, setStatus, setConnectedStatus, } from './status-dom.js';
@@ -16,6 +17,7 @@ import { mountRender, } from './render-item.js';
16
17
  import { fromBootstrapFailure, } from './protocol-error.js';
17
18
  import { postObservabilityToParent, } from './observability.js';
18
19
  import { mountUiFeedbackChrome } from './ui-feedback-chrome.js';
20
+ import { hostCanReceiveMessages, hostCanRelayToolCalls, hostCapabilitiesCaptured, setHostCapabilities, } from './host-capabilities.js';
19
21
  import { makeLifecycleEvent, postLifecycleToParent, } from './lifecycle.js';
20
22
  import { contextSlotLastValues, createContextStateHost, installContextRegistry, reemitLastContextValues, } from './context-observer.js';
21
23
  const RENDERER_VERSION = typeof __GGUI_RUNTIME_VERSION__ === 'string' ? __GGUI_RUNTIME_VERSION__ : 'dev';
@@ -214,6 +216,14 @@ export async function bootSequence(opts) {
214
216
  onLifecycle?.(makeLifecycleEvent('error', { error: { code: reason, message } }));
215
217
  };
216
218
  const refs = ensureStatusDom(doc);
219
+ // Live regions up BEFORE anything can announce through them
220
+ // (ggui#447). A region created in the same tick as its first message
221
+ // is a region no screen reader was watching yet, and that first
222
+ // message — the one telling the user their very first gesture was
223
+ // received — is the one that gets lost. Mounted here, alongside the
224
+ // render root, because every toast is at minimum a user gesture away
225
+ // from this point.
226
+ ensureToastAnnouncer(doc);
217
227
  // The mounted render is established after the first ack lands —
218
228
  // populated by `applyAck` below + read back by every channel handler
219
229
  // that needs `propsSpec` / `streamSpec`. Tracked as a closure-scoped
@@ -253,6 +263,11 @@ export async function bootSequence(opts) {
253
263
  emitBootFailure('UI_INITIALIZE_FAILED', initResult.message);
254
264
  return { ok: false, mountedRender };
255
265
  }
266
+ // Record what this host said it can do (ggui#440). Earliest valid
267
+ // read: `getHostCapabilities()` is undefined until `connect()`
268
+ // resolves. Used to EXPLAIN failures (gesture relay, doorbell), never
269
+ // to pre-empt an attempt — see `./host-capabilities.ts`.
270
+ setHostCapabilities(app.getHostCapabilities());
256
271
  // Connected — expose the App on the module-level slot so outbound
257
272
  // `tools/call` (dispatchSubmitAction, channel-transport router)
258
273
  // routes through `app.callServerTool` instead of the legacy raw
@@ -683,15 +698,13 @@ function createPlaceholderRegistry(params) {
683
698
  * Frame dispatch lives inside `@ggui-ai/live-channel`'s
684
699
  * `ChannelRegistry`. Every WS frame type (`render`, `data`,
685
700
  * `props_update`, `drain_ack`, `channel_payload`,
686
- * `channel_error`, `system`) has a registered handler in
687
- * `channels/*.ts`; the registry's bound transport routes inbound
688
- * frames directly to them. Observability emission (`auth-required`)
689
- * is inside the system handler.
701
+ * `channel_error`) has a registered handler in `channels/*.ts`; the
702
+ * registry's bound transport routes inbound frames directly to them.
690
703
  *
691
704
  * The pre-B3b helpers (`handleServerMessage`, `handleRendererMessage`,
692
- * `handleObservableMessage`, `emitAuthRequiredFromSystemFrame`,
693
- * `BufferedManagerShim`) lived here and have been retired — see
694
- * commit message + plan B3b for the full retirement notes.
705
+ * `handleObservableMessage`, `BufferedManagerShim`) lived here and
706
+ * have been retired — see commit message + plan B3b for the full
707
+ * retirement notes.
695
708
  */
696
709
  /**
697
710
  * Type guard for the `UpgradeRequiredError` class without importing
@@ -1253,6 +1266,32 @@ function classifySubmitActionResponse(resp) {
1253
1266
  : r;
1254
1267
  return inner.ok === true ? 'success' : 'fallback';
1255
1268
  }
1269
+ /**
1270
+ * Did the relay itself fail — as opposed to relaying successfully to a
1271
+ * well-formed `{ok:false}` result (ggui#440)? `classifySubmitActionResponse`
1272
+ * collapses BOTH into `'fallback'`, which is the right call for the
1273
+ * generic "enqueue failed" toast (either way the gesture isn't on the
1274
+ * pipe) but the WRONG call for the relay-incapability latch: a result
1275
+ * envelope arriving at all — `{ok:false, code:'PIPE_NOT_FOUND'}` is the
1276
+ * common case (an otherwise-healthy relay, expired pipe) — is proof the
1277
+ * host DID relay the call to the server and back. Latching "cannot
1278
+ * relay" on that evidence would be false: a host that plainly can relay
1279
+ * would get permanently mislabeled from one stale-pipe response.
1280
+ *
1281
+ * `resp === null` covers the transport-throw / no-App-bound paths (the
1282
+ * outer `catch` above resets `resp` to `null`); `resp.error !== undefined
1283
+ * && resp.error !== null` covers the JSON-RPC error envelope. Neither
1284
+ * carries a result envelope, so neither is evidence the relay worked.
1285
+ */
1286
+ function isRelayShapedFailure(resp) {
1287
+ // Null-tolerant on `error`, matching `classifySubmitActionResponse`'s
1288
+ // own check: an `{error:null, result:{...}}` envelope classifies as
1289
+ // success there, so it must also read as NOT relay-shaped-failure
1290
+ // here — otherwise the two predicates disagree on that shape and the
1291
+ // response-arrival clear guard above would refuse to release a
1292
+ // standing latch on an unambiguous success.
1293
+ return resp === null || (resp.error !== undefined && resp.error !== null);
1294
+ }
1256
1295
  /**
1257
1296
  * Extract `consumerPresent` from a successful submit_action response.
1258
1297
  * Returns the boolean if present + well-typed; `undefined` otherwise
@@ -1275,7 +1314,147 @@ function extractConsumerPresent(resp) {
1275
1314
  const flag = inner.consumerPresent;
1276
1315
  return typeof flag === 'boolean' ? flag : undefined;
1277
1316
  }
1278
- function showActionToast(text, kind) {
1317
+ /** Container id for the announcer holding both live regions. */
1318
+ const TOAST_ANNOUNCER_ID = '__ggui-toast-announcer__';
1319
+ /**
1320
+ * Ensure the pair of live regions the toast surface announces through.
1321
+ *
1322
+ * MUST be called before the first message can land — a live region
1323
+ * created in the same tick as its first content is a region the screen
1324
+ * reader was never watching, and that first announcement is lost. The
1325
+ * boot path calls this at the same point it mounts the render root, so
1326
+ * every toast (at minimum a user gesture away) lands in a region that
1327
+ * has been registered for many frames. The toast + cue paths call it
1328
+ * again defensively; it is idempotent per document.
1329
+ *
1330
+ * Visually hidden rather than `display:none`: a region that is not
1331
+ * rendered is not in the accessibility tree, and content added to it is
1332
+ * never announced. The clip rectangle keeps it out of the visual layout
1333
+ * while leaving it live.
1334
+ *
1335
+ * Returns `null` when the document has no body yet — nothing to attach
1336
+ * to, and no announcement is possible.
1337
+ *
1338
+ * @internal — exported for unit tests.
1339
+ */
1340
+ export function ensureToastAnnouncer(doc) {
1341
+ if (doc.body === null)
1342
+ return null;
1343
+ const existing = doc.getElementById(TOAST_ANNOUNCER_ID);
1344
+ if (existing !== null) {
1345
+ const polite = existing.querySelector('[data-ggui-toast-announce="polite"]');
1346
+ const assertive = existing.querySelector('[data-ggui-toast-announce="assertive"]');
1347
+ if (polite !== null && assertive !== null)
1348
+ return { polite, assertive };
1349
+ // Half a container is worse than none — it looks mounted to the
1350
+ // idempotence check while announcing nothing. Rebuild whole.
1351
+ existing.remove();
1352
+ }
1353
+ const host = doc.createElement('div');
1354
+ host.id = TOAST_ANNOUNCER_ID;
1355
+ host.style.cssText = [
1356
+ 'position:absolute',
1357
+ 'width:1px',
1358
+ 'height:1px',
1359
+ 'margin:-1px',
1360
+ 'padding:0',
1361
+ 'border:0',
1362
+ 'overflow:hidden',
1363
+ 'clip:rect(0 0 0 0)',
1364
+ 'clip-path:inset(50%)',
1365
+ 'white-space:nowrap',
1366
+ ].join(';');
1367
+ const makeRegion = (politeness) => {
1368
+ const region = doc.createElement('div');
1369
+ region.setAttribute('data-ggui-toast-announce', politeness);
1370
+ // Role and `aria-live` together: the role is the semantic, the
1371
+ // explicit `aria-live` is the belt for assistive technology that
1372
+ // does not map `role="status"` to a politeness on its own.
1373
+ region.setAttribute('role', politeness === 'polite' ? 'status' : 'alert');
1374
+ region.setAttribute('aria-live', politeness);
1375
+ // Toast text is one sentence that only makes sense whole — read the
1376
+ // region, not the diff.
1377
+ region.setAttribute('aria-atomic', 'true');
1378
+ host.appendChild(region);
1379
+ return region;
1380
+ };
1381
+ const polite = makeRegion('polite');
1382
+ const assertive = makeRegion('assertive');
1383
+ doc.body.appendChild(host);
1384
+ return { polite, assertive };
1385
+ }
1386
+ /**
1387
+ * Speak a toast message. `error` + `action_required` interrupt (a
1388
+ * gesture failed, or needs the user to act); everything else queues.
1389
+ *
1390
+ * Exactly one region carries text at a time — the other is cleared, so
1391
+ * a user who navigates to the announcer afterwards finds the current
1392
+ * message and not a transcript. Clearing is silent: assistive
1393
+ * technology announces content arriving in a live region, not content
1394
+ * leaving one.
1395
+ */
1396
+ function announceToast(text, kind) {
1397
+ if (typeof document === 'undefined')
1398
+ return;
1399
+ const regions = ensureToastAnnouncer(document);
1400
+ if (regions === null)
1401
+ return;
1402
+ // Whatever speaks last owns the region. A retraction scheduled for an
1403
+ // earlier relay cue would otherwise come due in the middle of THIS
1404
+ // message and take it down instead — and it could not be filtered out
1405
+ // by comparing text, because a cue and the fallback cue toast for the
1406
+ // same intent are character-identical. (Declared below; this only
1407
+ // ever runs long after module init.)
1408
+ cancelRelayCueRetraction();
1409
+ const interrupts = kind === 'error' || kind === 'action_required';
1410
+ const target = interrupts ? regions.assertive : regions.polite;
1411
+ const idle = interrupts ? regions.polite : regions.assertive;
1412
+ idle.textContent = '';
1413
+ target.textContent = text;
1414
+ }
1415
+ /**
1416
+ * Empty both live regions. Paired with every route that takes the
1417
+ * visible toast away, so the spoken surface and the visual one carry
1418
+ * the same message at the same time — including "nothing".
1419
+ */
1420
+ function clearToastAnnouncement() {
1421
+ if (typeof document === 'undefined')
1422
+ return;
1423
+ const host = document.getElementById(TOAST_ANNOUNCER_ID);
1424
+ if (host === null)
1425
+ return;
1426
+ for (const region of Array.from(host.children)) {
1427
+ region.textContent = '';
1428
+ }
1429
+ }
1430
+ /**
1431
+ * Take the visible toast away, on every route that does so (auto-
1432
+ * dismiss timer, user dismissal, `drain_ack` resolution).
1433
+ *
1434
+ * A hidden toast must be inert to pointer, keyboard AND assistive
1435
+ * technology alike: the element keeps its fixed position over the top
1436
+ * of the render for the rest of the session, so anything left behind —
1437
+ * a live click target, a tab stop, an announced sentence — outlives the
1438
+ * message it belonged to.
1439
+ */
1440
+ function hideToast(el) {
1441
+ el.style.opacity = '0';
1442
+ el.style.transform = 'translateX(-50%) translateY(8px)';
1443
+ el.style.pointerEvents = 'none';
1444
+ el.onclick = null;
1445
+ el.onkeydown = null;
1446
+ // Blur BEFORE `aria-hidden` lands: hiding the focused element from
1447
+ // the accessibility tree strands the screen reader's cursor on a node
1448
+ // it can no longer describe.
1449
+ if (el.ownerDocument.activeElement === el)
1450
+ el.blur();
1451
+ el.removeAttribute('role');
1452
+ el.removeAttribute('tabindex');
1453
+ el.removeAttribute('aria-label');
1454
+ el.setAttribute('aria-hidden', 'true');
1455
+ clearToastAnnouncement();
1456
+ }
1457
+ function showActionToast(text, kind, onDismiss) {
1279
1458
  if (typeof document === 'undefined')
1280
1459
  return;
1281
1460
  const w = window;
@@ -1319,34 +1498,62 @@ function showActionToast(text, kind) {
1319
1498
  el.textContent = text;
1320
1499
  el.style.opacity = '1';
1321
1500
  el.style.transform = 'translateX(-50%) translateY(0)';
1501
+ // Reset to the non-interactive posture every time, so a toast that
1502
+ // follows an `action_required` notice does not inherit its tab stop
1503
+ // and name. The `action_required` branch below re-applies them.
1504
+ el.setAttribute('aria-hidden', 'true');
1505
+ el.removeAttribute('role');
1506
+ el.removeAttribute('tabindex');
1507
+ el.removeAttribute('aria-label');
1508
+ el.style.pointerEvents = 'none';
1509
+ el.onkeydown = null;
1510
+ announceToast(text, kind);
1322
1511
  // Clear any prior auto-dismiss timer so a fresh pending toast
1323
1512
  // doesn't get hidden mid-flight.
1324
1513
  const elWithTimer = el;
1325
1514
  if (elWithTimer.__toastTimer)
1326
1515
  clearTimeout(elWithTimer.__toastTimer);
1327
- // `action_required` PERSISTS until the user clicks the toast
1328
- // (manual dismiss). Per MCP Apps spec, `ui/message` is a
1516
+ // `action_required` PERSISTS until the user dismisses the toast
1517
+ // (click, Enter or Space). Per MCP Apps spec, `ui/message` is a
1329
1518
  // PREPARED user prompt — the host renders it into the chat input
1330
1519
  // but the user must press send for it to reach the agent. So the
1331
1520
  // toast can't auto-dismiss; doing so would imply the gesture went
1332
1521
  // through when it actually requires a user follow-up.
1522
+ //
1523
+ // `onDismiss` fires on that click — the only signal a caller gets
1524
+ // that the user has read and closed a persistent notice. The relay
1525
+ // notice uses it to arm the post-dismissal cue (ggui#442).
1333
1526
  if (kind === 'action_required') {
1334
- el.onclick = () => {
1335
- if (!el)
1527
+ // The dismissal is a real control, so it carries real control
1528
+ // semantics: a role, an accessible name that says what activating
1529
+ // it does, a tab stop, and the two keys a button responds to. The
1530
+ // announcement already carried the message; this carries the way
1531
+ // out of it.
1532
+ const target = el;
1533
+ target.removeAttribute('aria-hidden');
1534
+ target.setAttribute('role', 'button');
1535
+ target.setAttribute('tabindex', '0');
1536
+ target.setAttribute('aria-label', `${text} Activate to dismiss.`);
1537
+ target.style.pointerEvents = 'auto';
1538
+ const dismiss = () => {
1539
+ hideToast(target);
1540
+ onDismiss?.();
1541
+ };
1542
+ target.onclick = dismiss;
1543
+ target.onkeydown = (ev) => {
1544
+ if (ev.key !== 'Enter' && ev.key !== ' ')
1336
1545
  return;
1337
- el.style.opacity = '0';
1338
- el.style.transform = 'translateX(-50%) translateY(8px)';
1339
- el.onclick = null;
1546
+ // Space scrolls the page by default; a button must not.
1547
+ ev.preventDefault();
1548
+ dismiss();
1340
1549
  };
1341
1550
  }
1342
1551
  else {
1343
1552
  el.onclick = null;
1344
1553
  if (kind === 'success' || kind === 'fallback' || kind === 'error') {
1554
+ const target = el;
1345
1555
  elWithTimer.__toastTimer = window.setTimeout(() => {
1346
- if (!el)
1347
- return;
1348
- el.style.opacity = '0';
1349
- el.style.transform = 'translateX(-50%) translateY(8px)';
1556
+ hideToast(target);
1350
1557
  }, 2500);
1351
1558
  }
1352
1559
  }
@@ -1362,8 +1569,7 @@ function dismissActionToast() {
1362
1569
  const el = document.getElementById('__ggui-action-toast__');
1363
1570
  if (!el)
1364
1571
  return;
1365
- el.style.opacity = '0';
1366
- el.style.transform = 'translateX(-50%) translateY(8px)';
1572
+ hideToast(el);
1367
1573
  const elWithTimer = el;
1368
1574
  if (elWithTimer.__toastTimer) {
1369
1575
  clearTimeout(elWithTimer.__toastTimer);
@@ -1506,6 +1712,284 @@ function emitUserActionDoorbell(args) {
1506
1712
  },
1507
1713
  });
1508
1714
  }
1715
+ /**
1716
+ * Latch for the relay-incapability notice (ggui#440). A host that
1717
+ * cannot relay fails EVERY gesture, so the explanation is stated once
1718
+ * and left standing; without the latch the user gets one identical
1719
+ * toast per click, which is the #426 failure mode this closes.
1720
+ *
1721
+ * Cleared by ANY well-formed result envelope — `{ok:true}` and
1722
+ * `{ok:false}` alike, either proves the host can relay (see the
1723
+ * response-arrival guard in {@link dispatchSubmitAction}). Both
1724
+ * transition edges emit a `relay-incapability` observability event;
1725
+ * nothing else about the latch is observable to the host.
1726
+ */
1727
+ let relayIncapabilityAnnounced = false;
1728
+ /**
1729
+ * Whether the user has manually dismissed the CURRENTLY-STANDING relay
1730
+ * notice (ggui#442).
1731
+ *
1732
+ * "Dismissed the one explanation" is not "wants zero feedback forever",
1733
+ * but it IS consent that the explanation itself has been read. So the
1734
+ * dead zone this opens is filled with a per-gesture CUE, never with the
1735
+ * notice again.
1736
+ *
1737
+ * Scoped to the notice, not to toasts in general: the doorbell's
1738
+ * `action_required` toast is a different message with a different
1739
+ * dismissal meaning, so the flag is set from the relay notice's own
1740
+ * `onDismiss` callback rather than from `showActionToast` at large.
1741
+ *
1742
+ * Reset on BOTH latch edges — a re-latch shows a fresh notice nobody
1743
+ * has dismissed yet, and a self-heal must leave no residue behind.
1744
+ */
1745
+ let relayNoticeDismissed = false;
1746
+ /**
1747
+ * Timestamp of the last fallback cue toast, for the throttle below.
1748
+ * Reset alongside the latch so a new dead zone never inherits an old
1749
+ * one's quiet period.
1750
+ */
1751
+ let lastRelayCueToastAt = 0;
1752
+ /**
1753
+ * Timestamp of the last SPOKEN cue (ggui#447), on its own clock.
1754
+ *
1755
+ * Separate from the toast clock because the two cue shapes are
1756
+ * mutually exclusive per gesture — a gesture either pulses a focused
1757
+ * control or falls back to the toast — and each needs its own quiet
1758
+ * period to be the one it advertises.
1759
+ */
1760
+ let lastRelayCueAnnouncedAt = 0;
1761
+ /**
1762
+ * Intent named by the standing spoken cue, or `null` when none is.
1763
+ *
1764
+ * The throttle is keyed on this as well as the clock. A quiet period
1765
+ * that ignores WHICH action was attempted does not merely withhold the
1766
+ * second cue — it leaves the region still naming the FIRST one, so a
1767
+ * screen reader asked to re-read it reports the wrong action as the
1768
+ * thing that just failed. Silence is a defensible answer to a repeat;
1769
+ * it is never a defensible answer to a different gesture.
1770
+ */
1771
+ let lastRelayCueAnnouncedIntent = null;
1772
+ /**
1773
+ * Expiry timer for the standing spoken cue, held so a repeat can cancel
1774
+ * its predecessor instead of racing it.
1775
+ */
1776
+ let relayCueAnnounceTimer;
1777
+ /** Class carrying the pulse animation; also the `@keyframes` name. */
1778
+ const RELAY_CUE_CLASS = 'ggui-relay-cue-pulse';
1779
+ /** Stable id for the injected `<style>` — idempotent per document. */
1780
+ const RELAY_CUE_STYLE_ID = 'ggui-relay-cue-style';
1781
+ /** Pulse duration (ms). Brief enough to read as acknowledgement. */
1782
+ const RELAY_CUE_DURATION_MS = 400;
1783
+ /** At most one fallback cue toast, and one spoken cue, per window (ms). */
1784
+ const RELAY_CUE_THROTTLE_MS = 5_000;
1785
+ /**
1786
+ * How long a spoken cue stands before it is retracted (ms).
1787
+ *
1788
+ * Matches the toast's own auto-dismiss cadence, and deliberately
1789
+ * outlasts the 400ms pulse by a wide margin: content pulled out of a
1790
+ * live region too soon after it lands can be dropped before the screen
1791
+ * reader gets to it, so retracting at pulse-end would trade a stale
1792
+ * announcement for a missing one.
1793
+ */
1794
+ const RELAY_CUE_ANNOUNCE_TTL_MS = 2_500;
1795
+ /**
1796
+ * Drop the retraction a standing spoken cue has pending, because
1797
+ * something newer is about to own the region — another cue, a toast, or
1798
+ * a latch edge that ends the dead zone entirely. Left armed, it comes
1799
+ * due mid-message and clears whatever is in the region then.
1800
+ */
1801
+ function cancelRelayCueRetraction() {
1802
+ if (relayCueAnnounceTimer === undefined)
1803
+ return;
1804
+ clearTimeout(relayCueAnnounceTimer);
1805
+ relayCueAnnounceTimer = undefined;
1806
+ }
1807
+ /**
1808
+ * Give the dead zone a fresh quiet period. Called on both latch edges
1809
+ * and from the test reset — every throttle belongs to the notice that
1810
+ * is standing NOW, so a new one never inherits an old one's silence.
1811
+ */
1812
+ function resetRelayCueThrottles() {
1813
+ lastRelayCueToastAt = 0;
1814
+ lastRelayCueAnnouncedAt = 0;
1815
+ lastRelayCueAnnouncedIntent = null;
1816
+ cancelRelayCueRetraction();
1817
+ }
1818
+ /** @internal — exported for unit tests to reset module state. */
1819
+ export function __resetRelayNoticeForTest() {
1820
+ relayIncapabilityAnnounced = false;
1821
+ relayNoticeDismissed = false;
1822
+ resetRelayCueThrottles();
1823
+ }
1824
+ /**
1825
+ * Inject the pulse keyframes once per document. Mirrors how
1826
+ * `react-renderer.ts` injects its theme CSS — `<style>` with a stable
1827
+ * id on `<head>`, idempotent on repeat calls.
1828
+ *
1829
+ * Lazy rather than at boot: a session that never enters the dead zone
1830
+ * never pays for the rule.
1831
+ */
1832
+ function ensureRelayCueStyle() {
1833
+ if (document.getElementById(RELAY_CUE_STYLE_ID) !== null)
1834
+ return;
1835
+ const style = document.createElement('style');
1836
+ style.id = RELAY_CUE_STYLE_ID;
1837
+ // Opacity only. Anything touching geometry (scale, translate, outline
1838
+ // width) would have to make assumptions about the generated markup it
1839
+ // lands on — this cue is applied to whatever the user happened to
1840
+ // focus, so it must be incapable of disturbing any layout.
1841
+ style.textContent =
1842
+ `@keyframes ${RELAY_CUE_CLASS}{0%,100%{opacity:1}50%{opacity:.45}}` +
1843
+ `.${RELAY_CUE_CLASS}{animation:${RELAY_CUE_CLASS} ` +
1844
+ `${RELAY_CUE_DURATION_MS}ms ease-in-out}`;
1845
+ document.head.appendChild(style);
1846
+ }
1847
+ /**
1848
+ * The element to pulse, or `null` when there isn't a usable one.
1849
+ *
1850
+ * Usable means: a real focused element that belongs to the render. A
1851
+ * document with nothing focused reports `document.body`, which is not a
1852
+ * control the user just pressed; and anything outside
1853
+ * `[data-ggui-session-root]` (host chrome, the toast itself) is not
1854
+ * ours to animate. Both fall through to the toast.
1855
+ *
1856
+ * The root ITSELF is excluded too. `contains` is reflexive, so a
1857
+ * focused session root would otherwise pulse the whole render — the
1858
+ * opposite of a cue pointing at one control. That is reachable: the
1859
+ * root is reused if one already exists in the document (see
1860
+ * `ensureStatusDom`), so a host or a generated tree can hand it a
1861
+ * `tabindex` and focus it.
1862
+ */
1863
+ function resolveRelayCueTarget() {
1864
+ const active = document.activeElement;
1865
+ if (active === null || active === document.body)
1866
+ return null;
1867
+ const root = document.querySelector('[data-ggui-session-root]');
1868
+ if (root === null || active === root || !root.contains(active))
1869
+ return null;
1870
+ return active;
1871
+ }
1872
+ /**
1873
+ * Speak the pulse (ggui#447).
1874
+ *
1875
+ * The pulse animates opacity on a control the runtime does not own, so
1876
+ * it carries nothing to assistive technology and nothing may be added
1877
+ * to that control — a role or label written onto generated markup would
1878
+ * be a lie about someone else's element. The honest counterpart is a
1879
+ * message in the announcer's own region, saying what the pulse means.
1880
+ *
1881
+ * NOT gated on `__GGUI_TOAST_DISABLED__`: that flag suppresses our
1882
+ * toast chrome, and the pulse is not toast chrome — it fires either
1883
+ * way. Announcing on the same condition as the visual keeps the two
1884
+ * cues telling the same story.
1885
+ *
1886
+ * Throttled on its own clock, unlike the pulse — but only against
1887
+ * IDENTICAL repeats. A flash repeating on every gesture costs a sighted
1888
+ * user nothing to ignore, while the same sentence read aloud each time
1889
+ * buries whatever else the screen reader was saying and carries no new
1890
+ * information. A DIFFERENT action is new information: withholding it
1891
+ * would leave the region naming the previous gesture, which reads as a
1892
+ * confident answer about the wrong thing. The two cues share a meaning,
1893
+ * not a rate.
1894
+ *
1895
+ * The cue also expires. A live region is a description of what is
1896
+ * happening now, and a 400ms pulse that leaves a sentence standing for
1897
+ * the rest of the session is the same stale-announcement defect the
1898
+ * toast half avoids by clearing on hide.
1899
+ */
1900
+ function announceRelayCue(intent) {
1901
+ const now = Date.now();
1902
+ if (intent === lastRelayCueAnnouncedIntent &&
1903
+ now - lastRelayCueAnnouncedAt < RELAY_CUE_THROTTLE_MS) {
1904
+ return;
1905
+ }
1906
+ lastRelayCueAnnouncedAt = now;
1907
+ lastRelayCueAnnouncedIntent = intent;
1908
+ // `announceToast` cancels any retraction still pending, so the timer
1909
+ // armed here is always the only one in flight.
1910
+ announceToast(`⚠ ${intent} — not delivered`, 'error');
1911
+ relayCueAnnounceTimer = window.setTimeout(() => {
1912
+ relayCueAnnounceTimer = undefined;
1913
+ retractRelayCueAnnouncement();
1914
+ }, RELAY_CUE_ANNOUNCE_TTL_MS);
1915
+ }
1916
+ /**
1917
+ * Take a spoken cue back down once it has been heard.
1918
+ *
1919
+ * Unconditional, because by the time it runs the region can only hold
1920
+ * this cue's own sentence or nothing at all: every other write to the
1921
+ * region goes through {@link announceToast}, which cancels this
1922
+ * retraction before it speaks.
1923
+ *
1924
+ * That cancellation is the whole mechanism — comparing text here could
1925
+ * not substitute for it. The pulse cue and the fallback cue toast build
1926
+ * their sentences from the SAME template, so for one intent the two are
1927
+ * character-identical and no comparison can tell a dead cue from a live
1928
+ * toast.
1929
+ */
1930
+ function retractRelayCueAnnouncement() {
1931
+ if (typeof document === 'undefined')
1932
+ return;
1933
+ const regions = ensureToastAnnouncer(document);
1934
+ if (regions === null)
1935
+ return;
1936
+ regions.assertive.textContent = '';
1937
+ }
1938
+ /**
1939
+ * The dead-zone cue (ggui#442): the smallest honest "that did nothing"
1940
+ * signal, for a gesture on a host already known — and already
1941
+ * explained — to be relay-incapable.
1942
+ *
1943
+ * Component-agnostic by construction. It pulses whatever the user
1944
+ * focused without knowing what that is, and falls back to the toast
1945
+ * primitive when there is nothing to pulse. Either shape also SPEAKS
1946
+ * (ggui#447) — the pulse via {@link announceRelayCue}, the fallback via
1947
+ * the toast primitive's own announcement.
1948
+ */
1949
+ function showPostDismissalCue(intent) {
1950
+ if (typeof document === 'undefined')
1951
+ return;
1952
+ const target = resolveRelayCueTarget();
1953
+ if (target !== null) {
1954
+ // A pulse already running on this element IS the acknowledgement
1955
+ // for this gesture too. Re-adding a present class would not restart
1956
+ // the animation anyway (that needs a reflow), and racing two
1957
+ // removal timers on one element is how a class gets stranded.
1958
+ if (target.classList.contains(RELAY_CUE_CLASS))
1959
+ return;
1960
+ ensureRelayCueStyle();
1961
+ target.classList.add(RELAY_CUE_CLASS);
1962
+ announceRelayCue(intent);
1963
+ // Both cleanup routes cancel the other. Without the `clearTimeout`,
1964
+ // a pulse ended early by `animationend` leaves its timer armed, and
1965
+ // that timer later strips whatever class is on the element THEN —
1966
+ // truncating the next pulse partway through. The window is the gap
1967
+ // between the two, which widens on any host that ends the
1968
+ // animation early.
1969
+ const clear = () => {
1970
+ target.classList.remove(RELAY_CUE_CLASS);
1971
+ target.removeEventListener('animationend', clear);
1972
+ // `timer` is declared below; `clear` only ever RUNS after that
1973
+ // line, so it always reads an initialized id.
1974
+ clearTimeout(timer);
1975
+ };
1976
+ target.addEventListener('animationend', clear, { once: true });
1977
+ // Belt for real browsers, braces for everything that never fires
1978
+ // `animationend`: jsdom, a display:none subtree, and any host whose
1979
+ // reduced-motion policy skips the animation outright.
1980
+ const timer = window.setTimeout(clear, RELAY_CUE_DURATION_MS + 100);
1981
+ return;
1982
+ }
1983
+ const now = Date.now();
1984
+ if (now - lastRelayCueToastAt < RELAY_CUE_THROTTLE_MS)
1985
+ return;
1986
+ lastRelayCueToastAt = now;
1987
+ // The toast primitive's auto-dismissing variant — this is a cue, not
1988
+ // a second explanation, so it must clear itself. Throttled because a
1989
+ // per-click toast on a host that fails every click is the #426
1990
+ // failure mode the latch exists to prevent.
1991
+ showActionToast(`⚠ ${intent} — not delivered`, 'error');
1992
+ }
1509
1993
  /**
1510
1994
  * Dispatch a submit-action via the empirically-validated bridge
1511
1995
  * chain (validated against claude.ai): a `tools/call` to
@@ -1551,7 +2035,24 @@ export function dispatchSubmitAction(args) {
1551
2035
  // immediately so they know the click was registered, even when
1552
2036
  // submit_action's HTTP round-trip takes a moment. State updates
1553
2037
  // when the response (or fallback) lands.
1554
- showActionToast(`→ ${intent}${dataPart}`, 'pending');
2038
+ //
2039
+ // Skipped once the relay-incapability notice has latched (ggui#440):
2040
+ // we already know this host can't relay, so a fresh "sending…" flash
2041
+ // would clobber the persistent explanation with a transient state
2042
+ // that nothing downstream ever restores — the opposite of "left
2043
+ // standing" (see the latch declaration + terminal branch below).
2044
+ //
2045
+ // Once that explanation has been DISMISSED, though, skipping leaves
2046
+ // the gesture with no feedback at all — the dead zone ggui#442
2047
+ // names. The cue fills it here, at dispatch time, because the
2048
+ // element it pulses is the one the user has focused NOW; by the time
2049
+ // the relay response settles, focus may have moved on.
2050
+ if (!relayIncapabilityAnnounced) {
2051
+ showActionToast(`→ ${intent}${dataPart}`, 'pending');
2052
+ }
2053
+ else if (relayNoticeDismissed) {
2054
+ showPostDismissalCue(intent);
2055
+ }
1555
2056
  // (2) Try submit_action via host relay. Spec-compliant hosts
1556
2057
  // forward the tools/call to the MCP server's submit_action handler;
1557
2058
  // the pipe entry lands with `id: actionId`. On success, branch on
@@ -1590,10 +2091,60 @@ export function dispatchSubmitAction(args) {
1590
2091
  showActionToast(`⚠ ${intent} — transport error`, 'error');
1591
2092
  resp = null;
1592
2093
  }
2094
+ // Self-healing (ggui#440): a result envelope arriving at all is
2095
+ // proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
2096
+ // (an otherwise-healthy relay, expired pipe) proves it exactly as
2097
+ // much as `{ok:true}` — so any earlier (possibly false — see
2098
+ // `isRelayShapedFailure`) "cannot relay" latch no longer holds.
2099
+ // Cleared HERE, at response arrival, so the success path and the
2100
+ // `{ok:false}` path below both release it; clearing also
2101
+ // un-freezes `channelToolsCall`'s guard, which reads this same
2102
+ // latch. Transition-edged: acts (and emits the observability
2103
+ // edge) only when a latch was actually standing. No oscillation
2104
+ // within one dispatch — the latch-set branch below requires
2105
+ // `isRelayShapedFailure(resp)`, which this guard excludes.
2106
+ if (relayIncapabilityAnnounced &&
2107
+ resp !== null &&
2108
+ !isRelayShapedFailure(resp)) {
2109
+ relayIncapabilityAnnounced = false;
2110
+ // Self-heal leaves NO cue residue (ggui#442): the dead zone is
2111
+ // gone, so the state that armed the cue goes with it. A later
2112
+ // re-latch starts from a clean slate.
2113
+ relayNoticeDismissed = false;
2114
+ resetRelayCueThrottles();
2115
+ postObservabilityToParent({
2116
+ kind: 'relay-incapability',
2117
+ state: 'cleared',
2118
+ });
2119
+ // The (1.5) pending toast above was SKIPPED for this gesture —
2120
+ // the latch was still standing at dispatch time — so a bare
2121
+ // clear leaves the now-false "cannot relay" notice standing with
2122
+ // no successor until a `drain_ack` frame that may never arrive
2123
+ // in MCP-Apps relay contexts. On the success sub-case, replace
2124
+ // it with the ordinary pending toast so the drain_ack dismissal
2125
+ // chain below has a predecessor to dismiss. No equivalent is
2126
+ // needed on the {ok:false} sub-case: that path always falls
2127
+ // through to the terminal "could not reach the agent" toast a
2128
+ // few lines down, which already replaces the stale notice.
2129
+ if (classifySubmitActionResponse(resp) === 'success') {
2130
+ showActionToast(`→ ${intent}${dataPart}`, 'pending');
2131
+ }
2132
+ }
1593
2133
  if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
1594
2134
  const consumerPresent = extractConsumerPresent(resp);
1595
2135
  if (consumerPresent === false) {
1596
- showActionToast(`💬 ${intent}${dataPart} — agent not listening, sent to chat`, 'action_required');
2136
+ // No `ggui_consume` long-poll is draining this render's pipe,
2137
+ // so the gesture needs a fresh agent turn. `ui/message` is the
2138
+ // ONLY view→host method that can start one — a host that does
2139
+ // not accept it cannot be woken by us at all, and the user has
2140
+ // to send a message themselves (ggui#440).
2141
+ //
2142
+ // The doorbell is posted either way: capability absence is an
2143
+ // unconfirmed signal, not a proven "cannot", and a dropped
2144
+ // postMessage costs nothing.
2145
+ showActionToast(hostCanReceiveMessages()
2146
+ ? `💬 ${intent}${dataPart} — agent not listening, sent to chat`
2147
+ : `💬 ${intent}${dataPart} — agent not listening. Send a message to continue.`, 'action_required');
1597
2148
  emitUserActionDoorbell({
1598
2149
  intent,
1599
2150
  sessionId,
@@ -1607,12 +2158,135 @@ export function dispatchSubmitAction(args) {
1607
2158
  // it when ggui_consume drains the event.
1608
2159
  return;
1609
2160
  }
1610
- // Enqueue failed (pipe gone / transport error). The gesture is not
1611
- // on any pipe, so a doorbell would point at an empty queue. Surface
1612
- // the failure as a toast; no `ui/message` is emitted.
2161
+ // Enqueue failed (pipe gone / transport error / host has no relay).
2162
+ // The gesture is not on any pipe, so a doorbell would point at an
2163
+ // empty queue; no `ui/message` is emitted either way.
2164
+ //
2165
+ // Separate the STRUCTURAL case from the transient one (ggui#440):
2166
+ // when the host never advertised `serverTools`, every gesture will
2167
+ // fail identically, so explain it once, persistently, instead of
2168
+ // one identical error toast per click. Latching requires ALL of:
2169
+ //
2170
+ // - `hostCapabilitiesCaptured()` — the handshake has actually
2171
+ // resolved. Before that, capability absence is "not asked
2172
+ // yet", not "advertised nothing"; a gesture that fails in the
2173
+ // mount-to-handshake window would otherwise falsely latch a
2174
+ // fully-capable host that just hasn't finished connecting.
2175
+ // - `!hostCanRelayToolCalls()` — the host never advertised
2176
+ // `serverTools`.
2177
+ // - `isRelayShapedFailure(resp)` — NO well-formed result
2178
+ // envelope arrived. A `{ok:false, code:'PIPE_NOT_FOUND'}`
2179
+ // result (the common expired-pipe case) is proof the host DID
2180
+ // relay the call there and back; latching on that would falsely
2181
+ // brand a working relay as incapable and freeze the channel
2182
+ // router pre-attempt. That case has already CLEARED any
2183
+ // standing latch at the response-arrival guard above, and
2184
+ // keeps routing to the ordinary per-gesture transient toast
2185
+ // below instead.
2186
+ if (hostCapabilitiesCaptured() &&
2187
+ !hostCanRelayToolCalls() &&
2188
+ isRelayShapedFailure(resp)) {
2189
+ if (!relayIncapabilityAnnounced) {
2190
+ relayIncapabilityAnnounced = true;
2191
+ // Transition edge — the paired 'cleared' edge lives at the
2192
+ // response-arrival guard above. NEVER emit off-edge (e.g. per
2193
+ // channel poll tick): the router ticks on an interval and
2194
+ // would spam; the two edges carry the full information.
2195
+ postObservabilityToParent({
2196
+ kind: 'relay-incapability',
2197
+ state: 'latched',
2198
+ });
2199
+ // Establish the flag's invariant where the notice it describes
2200
+ // is created: a freshly-shown notice is undismissed, and its
2201
+ // dead zone starts with an unspent throttle. Today every
2202
+ // re-latch already passes through the clear edge above (which
2203
+ // resets both), so this is the same value arriving by a second
2204
+ // route — kept because the state belongs to THIS notice, and a
2205
+ // future second latch-set path shouldn't have to know that.
2206
+ relayNoticeDismissed = false;
2207
+ resetRelayCueThrottles();
2208
+ showActionToast('This host cannot relay actions to the agent — interactive controls will not work here.', 'action_required', () => {
2209
+ relayNoticeDismissed = true;
2210
+ });
2211
+ }
2212
+ return;
2213
+ }
1613
2214
  showActionToast(`⚠ ${intent} — could not reach the agent`, 'error');
1614
2215
  })();
1615
2216
  }
2217
+ /**
2218
+ * Channel-transport router's `tools/call` invoker (ggui#440) —
2219
+ * iframe-polling transport, `tools/call` against the parent MCP host
2220
+ * via `app.callServerTool` (spec-canonical) when the App handle is
2221
+ * set; falls back to raw postMessage pre-handshake or in tests.
2222
+ * Direct call (no LLM consent loop). Returns the tool's
2223
+ * structuredContent (or `content[0]` if that's where the payload
2224
+ * landed) as a `JsonValue`. On RPC error we throw — the router
2225
+ * catches and silently retries on the next tick.
2226
+ *
2227
+ * Once a REAL gesture has confirmed this host can't relay (the latch
2228
+ * above — set only after {@link dispatchSubmitAction} actually tried
2229
+ * and failed on a host with no `serverTools` advertised, with
2230
+ * capabilities already captured, AND the failure was relay-shaped —
2231
+ * not a well-formed `{ok:false}` result; see `isRelayShapedFailure`),
2232
+ * every subsequent poll will fail identically.
2233
+ *
2234
+ * This guard does NOT stop the router's poll loop, and nothing here
2235
+ * should ever be written so that it could (ggui#443). It does two
2236
+ * things. It makes each doomed tick CHEAP — the poll throws before
2237
+ * the `callServerToolSpec` round-trip. And, by throwing the typed
2238
+ * {@link RelayIncapableError} rather than a bare `Error`, it tells
2239
+ * the router WHICH KIND of failure this is: `channel-transport.ts`'s
2240
+ * tick classifies structural rejections apart from transient ones and
2241
+ * drops the channel to a slow probe cadence, emitting one
2242
+ * `channel-poll-degraded` event per transition. The first poll that
2243
+ * succeeds after the latch clears restores the normal cadence and
2244
+ * emits `channel-poll-recovered`.
2245
+ *
2246
+ * The channel keeps probing throughout, which is what makes that
2247
+ * recovery possible: the latch clears only when a call succeeds, and
2248
+ * only an attempt can discover that. A channel stopped on structural
2249
+ * failure could never come back.
2250
+ *
2251
+ * Keyed on the LATCH, not on `hostCanRelayToolCalls()` directly: raw
2252
+ * advertisement absence never changes after boot, so gating on it
2253
+ * here would throw pre-attempt for the LIFE OF THE SESSION on any
2254
+ * host that under-advertises but genuinely proxies `tools/call`
2255
+ * (ggui's own embed host pre-Task-2 is exactly this case) — silently
2256
+ * killing working channel polls forever. That is "absence of a
2257
+ * capability blocks an attempt", which the fail-safe constraint
2258
+ * forbids. The latch implies `!hostCanRelayToolCalls()` (it only
2259
+ * ever sets inside that guard), so testing it alone is sufficient —
2260
+ * a host that never fails a real gesture never latches, and its
2261
+ * channels keep working.
2262
+ *
2263
+ * Extracted to a named export (rather than an inline closure inside
2264
+ * `bootProduction`) so unit tests can drive it directly through the
2265
+ * lightweight `MockTransport` harness — `bootProduction` dynamic-
2266
+ * imports the full react/design/wire module graph, which is too
2267
+ * heavy to exercise from a spec file (see
2268
+ * `boot-production-context.test.ts`'s docstring for precedent).
2269
+ *
2270
+ * @internal — exported for unit tests + production reuse.
2271
+ */
2272
+ export async function channelToolsCall(args) {
2273
+ if (relayIncapabilityAnnounced) {
2274
+ throw new RelayIncapableError();
2275
+ }
2276
+ const resp = await callServerToolSpec(args.toolName, args.args);
2277
+ if (resp.error !== undefined) {
2278
+ throw new Error(resp.error.message ?? 'tools/call failed');
2279
+ }
2280
+ const result = resp.result;
2281
+ if (result === null || typeof result !== 'object') {
2282
+ return null;
2283
+ }
2284
+ const structured = result
2285
+ .structuredContent;
2286
+ if (structured !== undefined)
2287
+ return structured;
2288
+ return result;
2289
+ }
1616
2290
  /**
1617
2291
  * Resolve the name of the spec-canonical submit-action receiver tool
1618
2292
  * the dispatch path targets with `tools/call`.
@@ -2397,28 +3071,11 @@ async function bootProduction(opts) {
2397
3071
  }
2398
3072
  : {}),
2399
3073
  send: (msg) => manager.send(msg),
2400
- toolsCall: async ({ toolName, args }) => {
2401
- // Iframe-polling transport — `tools/call` against the
2402
- // parent MCP host via `app.callServerTool` (spec-canonical)
2403
- // when the App handle is set; falls back to raw postMessage
2404
- // pre-handshake or in tests. Direct call (no LLM consent
2405
- // loop). Returns the tool's structuredContent (or
2406
- // `content[0]` if that's where the payload landed) as a
2407
- // JsonValue. On RPC error we throw — the router catches
2408
- // and silently retries on the next tick.
2409
- const resp = await callServerToolSpec(toolName, args);
2410
- if (resp.error !== undefined) {
2411
- throw new Error(resp.error.message ?? 'tools/call failed');
2412
- }
2413
- const result = resp.result;
2414
- if (result === null || typeof result !== 'object') {
2415
- return null;
2416
- }
2417
- const structured = result.structuredContent;
2418
- if (structured !== undefined)
2419
- return structured;
2420
- return result;
2421
- },
3074
+ // See `channelToolsCall`'s docstring (defined alongside
3075
+ // `dispatchSubmitAction`, its sibling consumer of the
3076
+ // relay-incapability latch) for the fail-fast + fail-safe
3077
+ // rationale (ggui#440).
3078
+ toolsCall: channelToolsCall,
2422
3079
  streamBus,
2423
3080
  ...(onObserve !== undefined
2424
3081
  ? {
@@ -2475,9 +3132,6 @@ async function bootProduction(opts) {
2475
3132
  channelRegistry.register(createChannelErrorHandler({
2476
3133
  getChannelTransport: () => channelTransport,
2477
3134
  }));
2478
- channelRegistry.register(createSystemHandler({
2479
- ...(onObserve !== undefined ? { onObserve } : {}),
2480
- }));
2481
3135
  return {
2482
3136
  rootWireConfig: rootConfig,
2483
3137
  streamBus,