@ggui-ai/iframe-runtime 0.6.3 → 0.8.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
@@ -446,12 +461,12 @@ export async function bootSequence(opts) {
446
461
  onLifecycle?.(makeLifecycleEvent('code-ready', { sessionId: meta.sessionId }));
447
462
  };
448
463
  // Mode discriminators. The mount surface is DECOUPLED from the live
449
- // channel: static content (codeUrl/kind) paints immediately with no WS;
450
- // the live trio (wsUrl+wsToken) is an OPTIONAL enhancement that
451
- // delivers props_update / data / re-render frames. A bootstrap with
452
- // NEITHER has nothing to show and nowhere to subscribe.
453
- const hasStaticContent = (typeof meta.codeUrl === 'string' && meta.codeUrl.length > 0) ||
454
- (typeof meta.kind === 'string' && meta.kind.length > 0);
464
+ // channel: static content (codeUrl/codeB64/kind) paints immediately
465
+ // with no WS; the live trio (wsUrl+wsToken) is an OPTIONAL
466
+ // enhancement that delivers props_update / data / re-render frames.
467
+ // A bootstrap with NEITHER has nothing to show and nowhere to
468
+ // subscribe.
469
+ const hasStaticContent = hasStaticContentMeta(meta);
455
470
  const hasLiveTrio = typeof meta.wsUrl === 'string' &&
456
471
  meta.wsUrl.length > 0 &&
457
472
  typeof meta.wsToken === 'string' &&
@@ -502,7 +517,7 @@ export async function bootSequence(opts) {
502
517
  // misconfiguration — surface a typed boot failure.
503
518
  if (!hasLiveTrio) {
504
519
  if (mountedRender === null) {
505
- const message = 'bootstrap carries neither static content (codeUrl/kind) nor a live trio (wsUrl/wsToken)';
520
+ const message = 'bootstrap carries neither static content (codeUrl/codeB64/kind) nor a live trio (wsUrl/wsToken)';
506
521
  setStatus(refs, message, 'error');
507
522
  emitBootFailure('MISSING_META_GGUI_BOOTSTRAP', message);
508
523
  return { ok: false, mountedRender };
@@ -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
@@ -788,16 +801,22 @@ function parseSeedProps(propsJson) {
788
801
  * no WS at all for spec-compliant MCP-Apps hosts that expose no ggui
789
802
  * live channel (claude.ai / ChatGPT / Claude Desktop).
790
803
  *
791
- * Two static-content shapes (the autostart discriminator):
804
+ * Three static-content shapes (the autostart discriminator), in
805
+ * priority order:
792
806
  * - `kind` → a system-card seed (`type:'system'`); no fetch.
807
+ * - `codeB64` → a compiled-component seed carried inline; DECODED,
808
+ * never fetched. Preferred over `codeUrl` when both are present —
809
+ * the inline bytes exist precisely for hosts whose iframe CSP
810
+ * blocks the fetch.
793
811
  * - `codeUrl` → a compiled-component seed; fetches the
794
812
  * content-addressable component bytes.
795
813
  *
796
- * Returns `null` when the meta carries NEITHER (a live-only meta has
814
+ * Returns `null` when the meta carries NONE (a live-only meta has
797
815
  * nothing to seed — the first WS ack mounts it). Throws when a `codeUrl`
798
- * fetch fails so the caller can surface a typed boot failure. The four
799
- * server-assigned ledger fields are intentionally absent — the first
800
- * ack reconciles the seed to a full `GguiSession` (no fabrication).
816
+ * fetch fails or `codeB64` is not valid base64 so the caller can
817
+ * surface a typed boot failure. The four server-assigned ledger fields
818
+ * are intentionally absent — the first ack reconciles the seed to a
819
+ * full `GguiSession` (no fabrication).
801
820
  */
802
821
  export async function buildGguiSessionSeedInput(meta) {
803
822
  const props = parseSeedProps(meta.propsJson);
@@ -811,6 +830,15 @@ export async function buildGguiSessionSeedInput(meta) {
811
830
  ...(props !== undefined ? { props } : {}),
812
831
  };
813
832
  }
833
+ // Inline compiled-component mode — decode, no network.
834
+ if (meta.codeB64 !== undefined) {
835
+ return {
836
+ id: meta.sessionId,
837
+ appId: meta.appId,
838
+ componentCode: decodeCodeB64(meta.codeB64),
839
+ ...(props !== undefined ? { props } : {}),
840
+ };
841
+ }
814
842
  // Compiled-component mode — fetch the content-addressable bytes.
815
843
  if (meta.codeUrl === undefined)
816
844
  return null;
@@ -826,6 +854,45 @@ export async function buildGguiSessionSeedInput(meta) {
826
854
  ...(props !== undefined ? { props } : {}),
827
855
  };
828
856
  }
857
+ /**
858
+ * Does this slice carry STATIC content the mount surface can paint
859
+ * without any network round-trip authorization — `codeUrl` (fetched),
860
+ * `codeB64` (decoded inline), or `kind` (built-in registry)?
861
+ *
862
+ * The ONE discriminator `bootSequence` consults when deciding whether
863
+ * the static seed-mount path runs. `codeB64` counting here is
864
+ * load-bearing for fetch-blocked hosts: their meta carries codeB64
865
+ * PLUS a live trio the iframe can never connect, and missing the
866
+ * inline arm would skip the only paint path and die on the dead WS.
867
+ * Mirrors `validateMeta`'s static-content arm (meta-parse.ts) and the
868
+ * protocol host-helper's `hasMountModeDiscriminator` — drift among
869
+ * the three IS the historical bug class, hence the named export.
870
+ */
871
+ export function hasStaticContentMeta(meta) {
872
+ return ((typeof meta.codeUrl === 'string' && meta.codeUrl.length > 0) ||
873
+ (typeof meta.codeB64 === 'string' && meta.codeB64.length > 0) ||
874
+ (typeof meta.kind === 'string' && meta.kind.length > 0));
875
+ }
876
+ /**
877
+ * Decode a `codeB64` slice field to UTF-8 component source. `atob`
878
+ * yields a byte string; routing through `TextDecoder` restores
879
+ * multi-byte characters (component source is UTF-8 — string literals
880
+ * carry emoji, quotes, non-Latin text). Throws a labeled error on
881
+ * malformed base64 so the boot-failure surface names the field.
882
+ */
883
+ function decodeCodeB64(codeB64) {
884
+ try {
885
+ const byteString = atob(codeB64);
886
+ const bytes = new Uint8Array(byteString.length);
887
+ for (let i = 0; i < byteString.length; i++) {
888
+ bytes[i] = byteString.charCodeAt(i);
889
+ }
890
+ return new TextDecoder().decode(bytes);
891
+ }
892
+ catch (cause) {
893
+ throw new Error('buildGguiSessionSeedInput: codeB64 is not valid base64', { cause });
894
+ }
895
+ }
829
896
  /**
830
897
  * Extract a {@link McpAppAiGguiRenderMeta} from a
831
898
  * `ui/notifications/tool-result` JSON-RPC params payload — the
@@ -847,23 +914,29 @@ export function extractMetaFromToolResult(params) {
847
914
  /**
848
915
  * Drain `window.__GGUI_PENDING_TOOL_RESULTS__` — the buffer the
849
916
  * minimal shell populates while messages arrive between
850
- * shell-load and runtime-load. Returns the first valid slice meta
851
- * found; later params (if any) are dropped (each new tool-result
852
- * supersedes the previous).
917
+ * shell-load and runtime-load. Returns the NEWEST valid slice meta
918
+ * (each new tool-result supersedes the previous — every emission
919
+ * carries complete state, so last-wins loses nothing). Newest-first
920
+ * matters on the inline-runtime shell: its preflight completes the
921
+ * handshake BEFORE the large bundle parses, so a render AND a
922
+ * follow-up update can both land in the buffer during parse — booting
923
+ * the oldest would paint stale props and drop the update forever
924
+ * (buffered entries never reach the post-mount listener; it hears
925
+ * only post-App arrivals).
853
926
  *
854
- * The shell-side buffer contract: an array of
855
- * `{params: unknown}`-shaped JSON-RPC params from
856
- * `ui/notifications/tool-result` notifications, populated in
857
- * arrival order. The runtime drains it on first read.
927
+ * The shell-side buffer contract: an array whose ELEMENTS are the raw
928
+ * JSON-RPC `params` values of `ui/notifications/tool-result`
929
+ * notifications, in arrival order (shells cap it newest-biased). The
930
+ * runtime reads it once at autostart.
858
931
  */
859
- function readPendingToolResults() {
932
+ export function readPendingToolResults() {
860
933
  if (typeof window === 'undefined')
861
934
  return null;
862
935
  const raw = window.__GGUI_PENDING_TOOL_RESULTS__;
863
936
  if (!Array.isArray(raw) || raw.length === 0)
864
937
  return null;
865
- for (const params of raw) {
866
- const meta = extractMetaFromToolResult(params);
938
+ for (let i = raw.length - 1; i >= 0; i--) {
939
+ const meta = extractMetaFromToolResult(raw[i]);
867
940
  if (meta !== null)
868
941
  return meta;
869
942
  }
@@ -1253,6 +1326,32 @@ function classifySubmitActionResponse(resp) {
1253
1326
  : r;
1254
1327
  return inner.ok === true ? 'success' : 'fallback';
1255
1328
  }
1329
+ /**
1330
+ * Did the relay itself fail — as opposed to relaying successfully to a
1331
+ * well-formed `{ok:false}` result (ggui#440)? `classifySubmitActionResponse`
1332
+ * collapses BOTH into `'fallback'`, which is the right call for the
1333
+ * generic "enqueue failed" toast (either way the gesture isn't on the
1334
+ * pipe) but the WRONG call for the relay-incapability latch: a result
1335
+ * envelope arriving at all — `{ok:false, code:'PIPE_NOT_FOUND'}` is the
1336
+ * common case (an otherwise-healthy relay, expired pipe) — is proof the
1337
+ * host DID relay the call to the server and back. Latching "cannot
1338
+ * relay" on that evidence would be false: a host that plainly can relay
1339
+ * would get permanently mislabeled from one stale-pipe response.
1340
+ *
1341
+ * `resp === null` covers the transport-throw / no-App-bound paths (the
1342
+ * outer `catch` above resets `resp` to `null`); `resp.error !== undefined
1343
+ * && resp.error !== null` covers the JSON-RPC error envelope. Neither
1344
+ * carries a result envelope, so neither is evidence the relay worked.
1345
+ */
1346
+ function isRelayShapedFailure(resp) {
1347
+ // Null-tolerant on `error`, matching `classifySubmitActionResponse`'s
1348
+ // own check: an `{error:null, result:{...}}` envelope classifies as
1349
+ // success there, so it must also read as NOT relay-shaped-failure
1350
+ // here — otherwise the two predicates disagree on that shape and the
1351
+ // response-arrival clear guard above would refuse to release a
1352
+ // standing latch on an unambiguous success.
1353
+ return resp === null || (resp.error !== undefined && resp.error !== null);
1354
+ }
1256
1355
  /**
1257
1356
  * Extract `consumerPresent` from a successful submit_action response.
1258
1357
  * Returns the boolean if present + well-typed; `undefined` otherwise
@@ -1275,7 +1374,147 @@ function extractConsumerPresent(resp) {
1275
1374
  const flag = inner.consumerPresent;
1276
1375
  return typeof flag === 'boolean' ? flag : undefined;
1277
1376
  }
1278
- function showActionToast(text, kind) {
1377
+ /** Container id for the announcer holding both live regions. */
1378
+ const TOAST_ANNOUNCER_ID = '__ggui-toast-announcer__';
1379
+ /**
1380
+ * Ensure the pair of live regions the toast surface announces through.
1381
+ *
1382
+ * MUST be called before the first message can land — a live region
1383
+ * created in the same tick as its first content is a region the screen
1384
+ * reader was never watching, and that first announcement is lost. The
1385
+ * boot path calls this at the same point it mounts the render root, so
1386
+ * every toast (at minimum a user gesture away) lands in a region that
1387
+ * has been registered for many frames. The toast + cue paths call it
1388
+ * again defensively; it is idempotent per document.
1389
+ *
1390
+ * Visually hidden rather than `display:none`: a region that is not
1391
+ * rendered is not in the accessibility tree, and content added to it is
1392
+ * never announced. The clip rectangle keeps it out of the visual layout
1393
+ * while leaving it live.
1394
+ *
1395
+ * Returns `null` when the document has no body yet — nothing to attach
1396
+ * to, and no announcement is possible.
1397
+ *
1398
+ * @internal — exported for unit tests.
1399
+ */
1400
+ export function ensureToastAnnouncer(doc) {
1401
+ if (doc.body === null)
1402
+ return null;
1403
+ const existing = doc.getElementById(TOAST_ANNOUNCER_ID);
1404
+ if (existing !== null) {
1405
+ const polite = existing.querySelector('[data-ggui-toast-announce="polite"]');
1406
+ const assertive = existing.querySelector('[data-ggui-toast-announce="assertive"]');
1407
+ if (polite !== null && assertive !== null)
1408
+ return { polite, assertive };
1409
+ // Half a container is worse than none — it looks mounted to the
1410
+ // idempotence check while announcing nothing. Rebuild whole.
1411
+ existing.remove();
1412
+ }
1413
+ const host = doc.createElement('div');
1414
+ host.id = TOAST_ANNOUNCER_ID;
1415
+ host.style.cssText = [
1416
+ 'position:absolute',
1417
+ 'width:1px',
1418
+ 'height:1px',
1419
+ 'margin:-1px',
1420
+ 'padding:0',
1421
+ 'border:0',
1422
+ 'overflow:hidden',
1423
+ 'clip:rect(0 0 0 0)',
1424
+ 'clip-path:inset(50%)',
1425
+ 'white-space:nowrap',
1426
+ ].join(';');
1427
+ const makeRegion = (politeness) => {
1428
+ const region = doc.createElement('div');
1429
+ region.setAttribute('data-ggui-toast-announce', politeness);
1430
+ // Role and `aria-live` together: the role is the semantic, the
1431
+ // explicit `aria-live` is the belt for assistive technology that
1432
+ // does not map `role="status"` to a politeness on its own.
1433
+ region.setAttribute('role', politeness === 'polite' ? 'status' : 'alert');
1434
+ region.setAttribute('aria-live', politeness);
1435
+ // Toast text is one sentence that only makes sense whole — read the
1436
+ // region, not the diff.
1437
+ region.setAttribute('aria-atomic', 'true');
1438
+ host.appendChild(region);
1439
+ return region;
1440
+ };
1441
+ const polite = makeRegion('polite');
1442
+ const assertive = makeRegion('assertive');
1443
+ doc.body.appendChild(host);
1444
+ return { polite, assertive };
1445
+ }
1446
+ /**
1447
+ * Speak a toast message. `error` + `action_required` interrupt (a
1448
+ * gesture failed, or needs the user to act); everything else queues.
1449
+ *
1450
+ * Exactly one region carries text at a time — the other is cleared, so
1451
+ * a user who navigates to the announcer afterwards finds the current
1452
+ * message and not a transcript. Clearing is silent: assistive
1453
+ * technology announces content arriving in a live region, not content
1454
+ * leaving one.
1455
+ */
1456
+ function announceToast(text, kind) {
1457
+ if (typeof document === 'undefined')
1458
+ return;
1459
+ const regions = ensureToastAnnouncer(document);
1460
+ if (regions === null)
1461
+ return;
1462
+ // Whatever speaks last owns the region. A retraction scheduled for an
1463
+ // earlier relay cue would otherwise come due in the middle of THIS
1464
+ // message and take it down instead — and it could not be filtered out
1465
+ // by comparing text, because a cue and the fallback cue toast for the
1466
+ // same intent are character-identical. (Declared below; this only
1467
+ // ever runs long after module init.)
1468
+ cancelRelayCueRetraction();
1469
+ const interrupts = kind === 'error' || kind === 'action_required';
1470
+ const target = interrupts ? regions.assertive : regions.polite;
1471
+ const idle = interrupts ? regions.polite : regions.assertive;
1472
+ idle.textContent = '';
1473
+ target.textContent = text;
1474
+ }
1475
+ /**
1476
+ * Empty both live regions. Paired with every route that takes the
1477
+ * visible toast away, so the spoken surface and the visual one carry
1478
+ * the same message at the same time — including "nothing".
1479
+ */
1480
+ function clearToastAnnouncement() {
1481
+ if (typeof document === 'undefined')
1482
+ return;
1483
+ const host = document.getElementById(TOAST_ANNOUNCER_ID);
1484
+ if (host === null)
1485
+ return;
1486
+ for (const region of Array.from(host.children)) {
1487
+ region.textContent = '';
1488
+ }
1489
+ }
1490
+ /**
1491
+ * Take the visible toast away, on every route that does so (auto-
1492
+ * dismiss timer, user dismissal, `drain_ack` resolution).
1493
+ *
1494
+ * A hidden toast must be inert to pointer, keyboard AND assistive
1495
+ * technology alike: the element keeps its fixed position over the top
1496
+ * of the render for the rest of the session, so anything left behind —
1497
+ * a live click target, a tab stop, an announced sentence — outlives the
1498
+ * message it belonged to.
1499
+ */
1500
+ function hideToast(el) {
1501
+ el.style.opacity = '0';
1502
+ el.style.transform = 'translateX(-50%) translateY(8px)';
1503
+ el.style.pointerEvents = 'none';
1504
+ el.onclick = null;
1505
+ el.onkeydown = null;
1506
+ // Blur BEFORE `aria-hidden` lands: hiding the focused element from
1507
+ // the accessibility tree strands the screen reader's cursor on a node
1508
+ // it can no longer describe.
1509
+ if (el.ownerDocument.activeElement === el)
1510
+ el.blur();
1511
+ el.removeAttribute('role');
1512
+ el.removeAttribute('tabindex');
1513
+ el.removeAttribute('aria-label');
1514
+ el.setAttribute('aria-hidden', 'true');
1515
+ clearToastAnnouncement();
1516
+ }
1517
+ function showActionToast(text, kind, onDismiss) {
1279
1518
  if (typeof document === 'undefined')
1280
1519
  return;
1281
1520
  const w = window;
@@ -1319,34 +1558,62 @@ function showActionToast(text, kind) {
1319
1558
  el.textContent = text;
1320
1559
  el.style.opacity = '1';
1321
1560
  el.style.transform = 'translateX(-50%) translateY(0)';
1561
+ // Reset to the non-interactive posture every time, so a toast that
1562
+ // follows an `action_required` notice does not inherit its tab stop
1563
+ // and name. The `action_required` branch below re-applies them.
1564
+ el.setAttribute('aria-hidden', 'true');
1565
+ el.removeAttribute('role');
1566
+ el.removeAttribute('tabindex');
1567
+ el.removeAttribute('aria-label');
1568
+ el.style.pointerEvents = 'none';
1569
+ el.onkeydown = null;
1570
+ announceToast(text, kind);
1322
1571
  // Clear any prior auto-dismiss timer so a fresh pending toast
1323
1572
  // doesn't get hidden mid-flight.
1324
1573
  const elWithTimer = el;
1325
1574
  if (elWithTimer.__toastTimer)
1326
1575
  clearTimeout(elWithTimer.__toastTimer);
1327
- // `action_required` PERSISTS until the user clicks the toast
1328
- // (manual dismiss). Per MCP Apps spec, `ui/message` is a
1576
+ // `action_required` PERSISTS until the user dismisses the toast
1577
+ // (click, Enter or Space). Per MCP Apps spec, `ui/message` is a
1329
1578
  // PREPARED user prompt — the host renders it into the chat input
1330
1579
  // but the user must press send for it to reach the agent. So the
1331
1580
  // toast can't auto-dismiss; doing so would imply the gesture went
1332
1581
  // through when it actually requires a user follow-up.
1582
+ //
1583
+ // `onDismiss` fires on that click — the only signal a caller gets
1584
+ // that the user has read and closed a persistent notice. The relay
1585
+ // notice uses it to arm the post-dismissal cue (ggui#442).
1333
1586
  if (kind === 'action_required') {
1334
- el.onclick = () => {
1335
- if (!el)
1587
+ // The dismissal is a real control, so it carries real control
1588
+ // semantics: a role, an accessible name that says what activating
1589
+ // it does, a tab stop, and the two keys a button responds to. The
1590
+ // announcement already carried the message; this carries the way
1591
+ // out of it.
1592
+ const target = el;
1593
+ target.removeAttribute('aria-hidden');
1594
+ target.setAttribute('role', 'button');
1595
+ target.setAttribute('tabindex', '0');
1596
+ target.setAttribute('aria-label', `${text} Activate to dismiss.`);
1597
+ target.style.pointerEvents = 'auto';
1598
+ const dismiss = () => {
1599
+ hideToast(target);
1600
+ onDismiss?.();
1601
+ };
1602
+ target.onclick = dismiss;
1603
+ target.onkeydown = (ev) => {
1604
+ if (ev.key !== 'Enter' && ev.key !== ' ')
1336
1605
  return;
1337
- el.style.opacity = '0';
1338
- el.style.transform = 'translateX(-50%) translateY(8px)';
1339
- el.onclick = null;
1606
+ // Space scrolls the page by default; a button must not.
1607
+ ev.preventDefault();
1608
+ dismiss();
1340
1609
  };
1341
1610
  }
1342
1611
  else {
1343
1612
  el.onclick = null;
1344
1613
  if (kind === 'success' || kind === 'fallback' || kind === 'error') {
1614
+ const target = el;
1345
1615
  elWithTimer.__toastTimer = window.setTimeout(() => {
1346
- if (!el)
1347
- return;
1348
- el.style.opacity = '0';
1349
- el.style.transform = 'translateX(-50%) translateY(8px)';
1616
+ hideToast(target);
1350
1617
  }, 2500);
1351
1618
  }
1352
1619
  }
@@ -1362,8 +1629,7 @@ function dismissActionToast() {
1362
1629
  const el = document.getElementById('__ggui-action-toast__');
1363
1630
  if (!el)
1364
1631
  return;
1365
- el.style.opacity = '0';
1366
- el.style.transform = 'translateX(-50%) translateY(8px)';
1632
+ hideToast(el);
1367
1633
  const elWithTimer = el;
1368
1634
  if (elWithTimer.__toastTimer) {
1369
1635
  clearTimeout(elWithTimer.__toastTimer);
@@ -1506,6 +1772,284 @@ function emitUserActionDoorbell(args) {
1506
1772
  },
1507
1773
  });
1508
1774
  }
1775
+ /**
1776
+ * Latch for the relay-incapability notice (ggui#440). A host that
1777
+ * cannot relay fails EVERY gesture, so the explanation is stated once
1778
+ * and left standing; without the latch the user gets one identical
1779
+ * toast per click, which is the #426 failure mode this closes.
1780
+ *
1781
+ * Cleared by ANY well-formed result envelope — `{ok:true}` and
1782
+ * `{ok:false}` alike, either proves the host can relay (see the
1783
+ * response-arrival guard in {@link dispatchSubmitAction}). Both
1784
+ * transition edges emit a `relay-incapability` observability event;
1785
+ * nothing else about the latch is observable to the host.
1786
+ */
1787
+ let relayIncapabilityAnnounced = false;
1788
+ /**
1789
+ * Whether the user has manually dismissed the CURRENTLY-STANDING relay
1790
+ * notice (ggui#442).
1791
+ *
1792
+ * "Dismissed the one explanation" is not "wants zero feedback forever",
1793
+ * but it IS consent that the explanation itself has been read. So the
1794
+ * dead zone this opens is filled with a per-gesture CUE, never with the
1795
+ * notice again.
1796
+ *
1797
+ * Scoped to the notice, not to toasts in general: the doorbell's
1798
+ * `action_required` toast is a different message with a different
1799
+ * dismissal meaning, so the flag is set from the relay notice's own
1800
+ * `onDismiss` callback rather than from `showActionToast` at large.
1801
+ *
1802
+ * Reset on BOTH latch edges — a re-latch shows a fresh notice nobody
1803
+ * has dismissed yet, and a self-heal must leave no residue behind.
1804
+ */
1805
+ let relayNoticeDismissed = false;
1806
+ /**
1807
+ * Timestamp of the last fallback cue toast, for the throttle below.
1808
+ * Reset alongside the latch so a new dead zone never inherits an old
1809
+ * one's quiet period.
1810
+ */
1811
+ let lastRelayCueToastAt = 0;
1812
+ /**
1813
+ * Timestamp of the last SPOKEN cue (ggui#447), on its own clock.
1814
+ *
1815
+ * Separate from the toast clock because the two cue shapes are
1816
+ * mutually exclusive per gesture — a gesture either pulses a focused
1817
+ * control or falls back to the toast — and each needs its own quiet
1818
+ * period to be the one it advertises.
1819
+ */
1820
+ let lastRelayCueAnnouncedAt = 0;
1821
+ /**
1822
+ * Intent named by the standing spoken cue, or `null` when none is.
1823
+ *
1824
+ * The throttle is keyed on this as well as the clock. A quiet period
1825
+ * that ignores WHICH action was attempted does not merely withhold the
1826
+ * second cue — it leaves the region still naming the FIRST one, so a
1827
+ * screen reader asked to re-read it reports the wrong action as the
1828
+ * thing that just failed. Silence is a defensible answer to a repeat;
1829
+ * it is never a defensible answer to a different gesture.
1830
+ */
1831
+ let lastRelayCueAnnouncedIntent = null;
1832
+ /**
1833
+ * Expiry timer for the standing spoken cue, held so a repeat can cancel
1834
+ * its predecessor instead of racing it.
1835
+ */
1836
+ let relayCueAnnounceTimer;
1837
+ /** Class carrying the pulse animation; also the `@keyframes` name. */
1838
+ const RELAY_CUE_CLASS = 'ggui-relay-cue-pulse';
1839
+ /** Stable id for the injected `<style>` — idempotent per document. */
1840
+ const RELAY_CUE_STYLE_ID = 'ggui-relay-cue-style';
1841
+ /** Pulse duration (ms). Brief enough to read as acknowledgement. */
1842
+ const RELAY_CUE_DURATION_MS = 400;
1843
+ /** At most one fallback cue toast, and one spoken cue, per window (ms). */
1844
+ const RELAY_CUE_THROTTLE_MS = 5_000;
1845
+ /**
1846
+ * How long a spoken cue stands before it is retracted (ms).
1847
+ *
1848
+ * Matches the toast's own auto-dismiss cadence, and deliberately
1849
+ * outlasts the 400ms pulse by a wide margin: content pulled out of a
1850
+ * live region too soon after it lands can be dropped before the screen
1851
+ * reader gets to it, so retracting at pulse-end would trade a stale
1852
+ * announcement for a missing one.
1853
+ */
1854
+ const RELAY_CUE_ANNOUNCE_TTL_MS = 2_500;
1855
+ /**
1856
+ * Drop the retraction a standing spoken cue has pending, because
1857
+ * something newer is about to own the region — another cue, a toast, or
1858
+ * a latch edge that ends the dead zone entirely. Left armed, it comes
1859
+ * due mid-message and clears whatever is in the region then.
1860
+ */
1861
+ function cancelRelayCueRetraction() {
1862
+ if (relayCueAnnounceTimer === undefined)
1863
+ return;
1864
+ clearTimeout(relayCueAnnounceTimer);
1865
+ relayCueAnnounceTimer = undefined;
1866
+ }
1867
+ /**
1868
+ * Give the dead zone a fresh quiet period. Called on both latch edges
1869
+ * and from the test reset — every throttle belongs to the notice that
1870
+ * is standing NOW, so a new one never inherits an old one's silence.
1871
+ */
1872
+ function resetRelayCueThrottles() {
1873
+ lastRelayCueToastAt = 0;
1874
+ lastRelayCueAnnouncedAt = 0;
1875
+ lastRelayCueAnnouncedIntent = null;
1876
+ cancelRelayCueRetraction();
1877
+ }
1878
+ /** @internal — exported for unit tests to reset module state. */
1879
+ export function __resetRelayNoticeForTest() {
1880
+ relayIncapabilityAnnounced = false;
1881
+ relayNoticeDismissed = false;
1882
+ resetRelayCueThrottles();
1883
+ }
1884
+ /**
1885
+ * Inject the pulse keyframes once per document. Mirrors how
1886
+ * `react-renderer.ts` injects its theme CSS — `<style>` with a stable
1887
+ * id on `<head>`, idempotent on repeat calls.
1888
+ *
1889
+ * Lazy rather than at boot: a session that never enters the dead zone
1890
+ * never pays for the rule.
1891
+ */
1892
+ function ensureRelayCueStyle() {
1893
+ if (document.getElementById(RELAY_CUE_STYLE_ID) !== null)
1894
+ return;
1895
+ const style = document.createElement('style');
1896
+ style.id = RELAY_CUE_STYLE_ID;
1897
+ // Opacity only. Anything touching geometry (scale, translate, outline
1898
+ // width) would have to make assumptions about the generated markup it
1899
+ // lands on — this cue is applied to whatever the user happened to
1900
+ // focus, so it must be incapable of disturbing any layout.
1901
+ style.textContent =
1902
+ `@keyframes ${RELAY_CUE_CLASS}{0%,100%{opacity:1}50%{opacity:.45}}` +
1903
+ `.${RELAY_CUE_CLASS}{animation:${RELAY_CUE_CLASS} ` +
1904
+ `${RELAY_CUE_DURATION_MS}ms ease-in-out}`;
1905
+ document.head.appendChild(style);
1906
+ }
1907
+ /**
1908
+ * The element to pulse, or `null` when there isn't a usable one.
1909
+ *
1910
+ * Usable means: a real focused element that belongs to the render. A
1911
+ * document with nothing focused reports `document.body`, which is not a
1912
+ * control the user just pressed; and anything outside
1913
+ * `[data-ggui-session-root]` (host chrome, the toast itself) is not
1914
+ * ours to animate. Both fall through to the toast.
1915
+ *
1916
+ * The root ITSELF is excluded too. `contains` is reflexive, so a
1917
+ * focused session root would otherwise pulse the whole render — the
1918
+ * opposite of a cue pointing at one control. That is reachable: the
1919
+ * root is reused if one already exists in the document (see
1920
+ * `ensureStatusDom`), so a host or a generated tree can hand it a
1921
+ * `tabindex` and focus it.
1922
+ */
1923
+ function resolveRelayCueTarget() {
1924
+ const active = document.activeElement;
1925
+ if (active === null || active === document.body)
1926
+ return null;
1927
+ const root = document.querySelector('[data-ggui-session-root]');
1928
+ if (root === null || active === root || !root.contains(active))
1929
+ return null;
1930
+ return active;
1931
+ }
1932
+ /**
1933
+ * Speak the pulse (ggui#447).
1934
+ *
1935
+ * The pulse animates opacity on a control the runtime does not own, so
1936
+ * it carries nothing to assistive technology and nothing may be added
1937
+ * to that control — a role or label written onto generated markup would
1938
+ * be a lie about someone else's element. The honest counterpart is a
1939
+ * message in the announcer's own region, saying what the pulse means.
1940
+ *
1941
+ * NOT gated on `__GGUI_TOAST_DISABLED__`: that flag suppresses our
1942
+ * toast chrome, and the pulse is not toast chrome — it fires either
1943
+ * way. Announcing on the same condition as the visual keeps the two
1944
+ * cues telling the same story.
1945
+ *
1946
+ * Throttled on its own clock, unlike the pulse — but only against
1947
+ * IDENTICAL repeats. A flash repeating on every gesture costs a sighted
1948
+ * user nothing to ignore, while the same sentence read aloud each time
1949
+ * buries whatever else the screen reader was saying and carries no new
1950
+ * information. A DIFFERENT action is new information: withholding it
1951
+ * would leave the region naming the previous gesture, which reads as a
1952
+ * confident answer about the wrong thing. The two cues share a meaning,
1953
+ * not a rate.
1954
+ *
1955
+ * The cue also expires. A live region is a description of what is
1956
+ * happening now, and a 400ms pulse that leaves a sentence standing for
1957
+ * the rest of the session is the same stale-announcement defect the
1958
+ * toast half avoids by clearing on hide.
1959
+ */
1960
+ function announceRelayCue(intent) {
1961
+ const now = Date.now();
1962
+ if (intent === lastRelayCueAnnouncedIntent &&
1963
+ now - lastRelayCueAnnouncedAt < RELAY_CUE_THROTTLE_MS) {
1964
+ return;
1965
+ }
1966
+ lastRelayCueAnnouncedAt = now;
1967
+ lastRelayCueAnnouncedIntent = intent;
1968
+ // `announceToast` cancels any retraction still pending, so the timer
1969
+ // armed here is always the only one in flight.
1970
+ announceToast(`⚠ ${intent} — not delivered`, 'error');
1971
+ relayCueAnnounceTimer = window.setTimeout(() => {
1972
+ relayCueAnnounceTimer = undefined;
1973
+ retractRelayCueAnnouncement();
1974
+ }, RELAY_CUE_ANNOUNCE_TTL_MS);
1975
+ }
1976
+ /**
1977
+ * Take a spoken cue back down once it has been heard.
1978
+ *
1979
+ * Unconditional, because by the time it runs the region can only hold
1980
+ * this cue's own sentence or nothing at all: every other write to the
1981
+ * region goes through {@link announceToast}, which cancels this
1982
+ * retraction before it speaks.
1983
+ *
1984
+ * That cancellation is the whole mechanism — comparing text here could
1985
+ * not substitute for it. The pulse cue and the fallback cue toast build
1986
+ * their sentences from the SAME template, so for one intent the two are
1987
+ * character-identical and no comparison can tell a dead cue from a live
1988
+ * toast.
1989
+ */
1990
+ function retractRelayCueAnnouncement() {
1991
+ if (typeof document === 'undefined')
1992
+ return;
1993
+ const regions = ensureToastAnnouncer(document);
1994
+ if (regions === null)
1995
+ return;
1996
+ regions.assertive.textContent = '';
1997
+ }
1998
+ /**
1999
+ * The dead-zone cue (ggui#442): the smallest honest "that did nothing"
2000
+ * signal, for a gesture on a host already known — and already
2001
+ * explained — to be relay-incapable.
2002
+ *
2003
+ * Component-agnostic by construction. It pulses whatever the user
2004
+ * focused without knowing what that is, and falls back to the toast
2005
+ * primitive when there is nothing to pulse. Either shape also SPEAKS
2006
+ * (ggui#447) — the pulse via {@link announceRelayCue}, the fallback via
2007
+ * the toast primitive's own announcement.
2008
+ */
2009
+ function showPostDismissalCue(intent) {
2010
+ if (typeof document === 'undefined')
2011
+ return;
2012
+ const target = resolveRelayCueTarget();
2013
+ if (target !== null) {
2014
+ // A pulse already running on this element IS the acknowledgement
2015
+ // for this gesture too. Re-adding a present class would not restart
2016
+ // the animation anyway (that needs a reflow), and racing two
2017
+ // removal timers on one element is how a class gets stranded.
2018
+ if (target.classList.contains(RELAY_CUE_CLASS))
2019
+ return;
2020
+ ensureRelayCueStyle();
2021
+ target.classList.add(RELAY_CUE_CLASS);
2022
+ announceRelayCue(intent);
2023
+ // Both cleanup routes cancel the other. Without the `clearTimeout`,
2024
+ // a pulse ended early by `animationend` leaves its timer armed, and
2025
+ // that timer later strips whatever class is on the element THEN —
2026
+ // truncating the next pulse partway through. The window is the gap
2027
+ // between the two, which widens on any host that ends the
2028
+ // animation early.
2029
+ const clear = () => {
2030
+ target.classList.remove(RELAY_CUE_CLASS);
2031
+ target.removeEventListener('animationend', clear);
2032
+ // `timer` is declared below; `clear` only ever RUNS after that
2033
+ // line, so it always reads an initialized id.
2034
+ clearTimeout(timer);
2035
+ };
2036
+ target.addEventListener('animationend', clear, { once: true });
2037
+ // Belt for real browsers, braces for everything that never fires
2038
+ // `animationend`: jsdom, a display:none subtree, and any host whose
2039
+ // reduced-motion policy skips the animation outright.
2040
+ const timer = window.setTimeout(clear, RELAY_CUE_DURATION_MS + 100);
2041
+ return;
2042
+ }
2043
+ const now = Date.now();
2044
+ if (now - lastRelayCueToastAt < RELAY_CUE_THROTTLE_MS)
2045
+ return;
2046
+ lastRelayCueToastAt = now;
2047
+ // The toast primitive's auto-dismissing variant — this is a cue, not
2048
+ // a second explanation, so it must clear itself. Throttled because a
2049
+ // per-click toast on a host that fails every click is the #426
2050
+ // failure mode the latch exists to prevent.
2051
+ showActionToast(`⚠ ${intent} — not delivered`, 'error');
2052
+ }
1509
2053
  /**
1510
2054
  * Dispatch a submit-action via the empirically-validated bridge
1511
2055
  * chain (validated against claude.ai): a `tools/call` to
@@ -1551,7 +2095,24 @@ export function dispatchSubmitAction(args) {
1551
2095
  // immediately so they know the click was registered, even when
1552
2096
  // submit_action's HTTP round-trip takes a moment. State updates
1553
2097
  // when the response (or fallback) lands.
1554
- showActionToast(`→ ${intent}${dataPart}`, 'pending');
2098
+ //
2099
+ // Skipped once the relay-incapability notice has latched (ggui#440):
2100
+ // we already know this host can't relay, so a fresh "sending…" flash
2101
+ // would clobber the persistent explanation with a transient state
2102
+ // that nothing downstream ever restores — the opposite of "left
2103
+ // standing" (see the latch declaration + terminal branch below).
2104
+ //
2105
+ // Once that explanation has been DISMISSED, though, skipping leaves
2106
+ // the gesture with no feedback at all — the dead zone ggui#442
2107
+ // names. The cue fills it here, at dispatch time, because the
2108
+ // element it pulses is the one the user has focused NOW; by the time
2109
+ // the relay response settles, focus may have moved on.
2110
+ if (!relayIncapabilityAnnounced) {
2111
+ showActionToast(`→ ${intent}${dataPart}`, 'pending');
2112
+ }
2113
+ else if (relayNoticeDismissed) {
2114
+ showPostDismissalCue(intent);
2115
+ }
1555
2116
  // (2) Try submit_action via host relay. Spec-compliant hosts
1556
2117
  // forward the tools/call to the MCP server's submit_action handler;
1557
2118
  // the pipe entry lands with `id: actionId`. On success, branch on
@@ -1590,10 +2151,60 @@ export function dispatchSubmitAction(args) {
1590
2151
  showActionToast(`⚠ ${intent} — transport error`, 'error');
1591
2152
  resp = null;
1592
2153
  }
2154
+ // Self-healing (ggui#440): a result envelope arriving at all is
2155
+ // proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
2156
+ // (an otherwise-healthy relay, expired pipe) proves it exactly as
2157
+ // much as `{ok:true}` — so any earlier (possibly false — see
2158
+ // `isRelayShapedFailure`) "cannot relay" latch no longer holds.
2159
+ // Cleared HERE, at response arrival, so the success path and the
2160
+ // `{ok:false}` path below both release it; clearing also
2161
+ // un-freezes `channelToolsCall`'s guard, which reads this same
2162
+ // latch. Transition-edged: acts (and emits the observability
2163
+ // edge) only when a latch was actually standing. No oscillation
2164
+ // within one dispatch — the latch-set branch below requires
2165
+ // `isRelayShapedFailure(resp)`, which this guard excludes.
2166
+ if (relayIncapabilityAnnounced &&
2167
+ resp !== null &&
2168
+ !isRelayShapedFailure(resp)) {
2169
+ relayIncapabilityAnnounced = false;
2170
+ // Self-heal leaves NO cue residue (ggui#442): the dead zone is
2171
+ // gone, so the state that armed the cue goes with it. A later
2172
+ // re-latch starts from a clean slate.
2173
+ relayNoticeDismissed = false;
2174
+ resetRelayCueThrottles();
2175
+ postObservabilityToParent({
2176
+ kind: 'relay-incapability',
2177
+ state: 'cleared',
2178
+ });
2179
+ // The (1.5) pending toast above was SKIPPED for this gesture —
2180
+ // the latch was still standing at dispatch time — so a bare
2181
+ // clear leaves the now-false "cannot relay" notice standing with
2182
+ // no successor until a `drain_ack` frame that may never arrive
2183
+ // in MCP-Apps relay contexts. On the success sub-case, replace
2184
+ // it with the ordinary pending toast so the drain_ack dismissal
2185
+ // chain below has a predecessor to dismiss. No equivalent is
2186
+ // needed on the {ok:false} sub-case: that path always falls
2187
+ // through to the terminal "could not reach the agent" toast a
2188
+ // few lines down, which already replaces the stale notice.
2189
+ if (classifySubmitActionResponse(resp) === 'success') {
2190
+ showActionToast(`→ ${intent}${dataPart}`, 'pending');
2191
+ }
2192
+ }
1593
2193
  if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
1594
2194
  const consumerPresent = extractConsumerPresent(resp);
1595
2195
  if (consumerPresent === false) {
1596
- showActionToast(`💬 ${intent}${dataPart} — agent not listening, sent to chat`, 'action_required');
2196
+ // No `ggui_consume` long-poll is draining this render's pipe,
2197
+ // so the gesture needs a fresh agent turn. `ui/message` is the
2198
+ // ONLY view→host method that can start one — a host that does
2199
+ // not accept it cannot be woken by us at all, and the user has
2200
+ // to send a message themselves (ggui#440).
2201
+ //
2202
+ // The doorbell is posted either way: capability absence is an
2203
+ // unconfirmed signal, not a proven "cannot", and a dropped
2204
+ // postMessage costs nothing.
2205
+ showActionToast(hostCanReceiveMessages()
2206
+ ? `💬 ${intent}${dataPart} — agent not listening, sent to chat`
2207
+ : `💬 ${intent}${dataPart} — agent not listening. Send a message to continue.`, 'action_required');
1597
2208
  emitUserActionDoorbell({
1598
2209
  intent,
1599
2210
  sessionId,
@@ -1607,12 +2218,135 @@ export function dispatchSubmitAction(args) {
1607
2218
  // it when ggui_consume drains the event.
1608
2219
  return;
1609
2220
  }
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.
2221
+ // Enqueue failed (pipe gone / transport error / host has no relay).
2222
+ // The gesture is not on any pipe, so a doorbell would point at an
2223
+ // empty queue; no `ui/message` is emitted either way.
2224
+ //
2225
+ // Separate the STRUCTURAL case from the transient one (ggui#440):
2226
+ // when the host never advertised `serverTools`, every gesture will
2227
+ // fail identically, so explain it once, persistently, instead of
2228
+ // one identical error toast per click. Latching requires ALL of:
2229
+ //
2230
+ // - `hostCapabilitiesCaptured()` — the handshake has actually
2231
+ // resolved. Before that, capability absence is "not asked
2232
+ // yet", not "advertised nothing"; a gesture that fails in the
2233
+ // mount-to-handshake window would otherwise falsely latch a
2234
+ // fully-capable host that just hasn't finished connecting.
2235
+ // - `!hostCanRelayToolCalls()` — the host never advertised
2236
+ // `serverTools`.
2237
+ // - `isRelayShapedFailure(resp)` — NO well-formed result
2238
+ // envelope arrived. A `{ok:false, code:'PIPE_NOT_FOUND'}`
2239
+ // result (the common expired-pipe case) is proof the host DID
2240
+ // relay the call there and back; latching on that would falsely
2241
+ // brand a working relay as incapable and freeze the channel
2242
+ // router pre-attempt. That case has already CLEARED any
2243
+ // standing latch at the response-arrival guard above, and
2244
+ // keeps routing to the ordinary per-gesture transient toast
2245
+ // below instead.
2246
+ if (hostCapabilitiesCaptured() &&
2247
+ !hostCanRelayToolCalls() &&
2248
+ isRelayShapedFailure(resp)) {
2249
+ if (!relayIncapabilityAnnounced) {
2250
+ relayIncapabilityAnnounced = true;
2251
+ // Transition edge — the paired 'cleared' edge lives at the
2252
+ // response-arrival guard above. NEVER emit off-edge (e.g. per
2253
+ // channel poll tick): the router ticks on an interval and
2254
+ // would spam; the two edges carry the full information.
2255
+ postObservabilityToParent({
2256
+ kind: 'relay-incapability',
2257
+ state: 'latched',
2258
+ });
2259
+ // Establish the flag's invariant where the notice it describes
2260
+ // is created: a freshly-shown notice is undismissed, and its
2261
+ // dead zone starts with an unspent throttle. Today every
2262
+ // re-latch already passes through the clear edge above (which
2263
+ // resets both), so this is the same value arriving by a second
2264
+ // route — kept because the state belongs to THIS notice, and a
2265
+ // future second latch-set path shouldn't have to know that.
2266
+ relayNoticeDismissed = false;
2267
+ resetRelayCueThrottles();
2268
+ showActionToast('This host cannot relay actions to the agent — interactive controls will not work here.', 'action_required', () => {
2269
+ relayNoticeDismissed = true;
2270
+ });
2271
+ }
2272
+ return;
2273
+ }
1613
2274
  showActionToast(`⚠ ${intent} — could not reach the agent`, 'error');
1614
2275
  })();
1615
2276
  }
2277
+ /**
2278
+ * Channel-transport router's `tools/call` invoker (ggui#440) —
2279
+ * iframe-polling transport, `tools/call` against the parent MCP host
2280
+ * via `app.callServerTool` (spec-canonical) when the App handle is
2281
+ * set; falls back to raw postMessage pre-handshake or in tests.
2282
+ * Direct call (no LLM consent loop). Returns the tool's
2283
+ * structuredContent (or `content[0]` if that's where the payload
2284
+ * landed) as a `JsonValue`. On RPC error we throw — the router
2285
+ * catches and silently retries on the next tick.
2286
+ *
2287
+ * Once a REAL gesture has confirmed this host can't relay (the latch
2288
+ * above — set only after {@link dispatchSubmitAction} actually tried
2289
+ * and failed on a host with no `serverTools` advertised, with
2290
+ * capabilities already captured, AND the failure was relay-shaped —
2291
+ * not a well-formed `{ok:false}` result; see `isRelayShapedFailure`),
2292
+ * every subsequent poll will fail identically.
2293
+ *
2294
+ * This guard does NOT stop the router's poll loop, and nothing here
2295
+ * should ever be written so that it could (ggui#443). It does two
2296
+ * things. It makes each doomed tick CHEAP — the poll throws before
2297
+ * the `callServerToolSpec` round-trip. And, by throwing the typed
2298
+ * {@link RelayIncapableError} rather than a bare `Error`, it tells
2299
+ * the router WHICH KIND of failure this is: `channel-transport.ts`'s
2300
+ * tick classifies structural rejections apart from transient ones and
2301
+ * drops the channel to a slow probe cadence, emitting one
2302
+ * `channel-poll-degraded` event per transition. The first poll that
2303
+ * succeeds after the latch clears restores the normal cadence and
2304
+ * emits `channel-poll-recovered`.
2305
+ *
2306
+ * The channel keeps probing throughout, which is what makes that
2307
+ * recovery possible: the latch clears only when a call succeeds, and
2308
+ * only an attempt can discover that. A channel stopped on structural
2309
+ * failure could never come back.
2310
+ *
2311
+ * Keyed on the LATCH, not on `hostCanRelayToolCalls()` directly: raw
2312
+ * advertisement absence never changes after boot, so gating on it
2313
+ * here would throw pre-attempt for the LIFE OF THE SESSION on any
2314
+ * host that under-advertises but genuinely proxies `tools/call`
2315
+ * (ggui's own embed host pre-Task-2 is exactly this case) — silently
2316
+ * killing working channel polls forever. That is "absence of a
2317
+ * capability blocks an attempt", which the fail-safe constraint
2318
+ * forbids. The latch implies `!hostCanRelayToolCalls()` (it only
2319
+ * ever sets inside that guard), so testing it alone is sufficient —
2320
+ * a host that never fails a real gesture never latches, and its
2321
+ * channels keep working.
2322
+ *
2323
+ * Extracted to a named export (rather than an inline closure inside
2324
+ * `bootProduction`) so unit tests can drive it directly through the
2325
+ * lightweight `MockTransport` harness — `bootProduction` dynamic-
2326
+ * imports the full react/design/wire module graph, which is too
2327
+ * heavy to exercise from a spec file (see
2328
+ * `boot-production-context.test.ts`'s docstring for precedent).
2329
+ *
2330
+ * @internal — exported for unit tests + production reuse.
2331
+ */
2332
+ export async function channelToolsCall(args) {
2333
+ if (relayIncapabilityAnnounced) {
2334
+ throw new RelayIncapableError();
2335
+ }
2336
+ const resp = await callServerToolSpec(args.toolName, args.args);
2337
+ if (resp.error !== undefined) {
2338
+ throw new Error(resp.error.message ?? 'tools/call failed');
2339
+ }
2340
+ const result = resp.result;
2341
+ if (result === null || typeof result !== 'object') {
2342
+ return null;
2343
+ }
2344
+ const structured = result
2345
+ .structuredContent;
2346
+ if (structured !== undefined)
2347
+ return structured;
2348
+ return result;
2349
+ }
1616
2350
  /**
1617
2351
  * Resolve the name of the spec-canonical submit-action receiver tool
1618
2352
  * the dispatch path targets with `tools/call`.
@@ -1935,6 +2669,10 @@ function installPostMountListener() {
1935
2669
  meta.sessionId,
1936
2670
  meta.kind ?? '-',
1937
2671
  meta.codeUrl ?? '-',
2672
+ // Inline code participates like codeUrl: an update that ships new
2673
+ // component bytes (re-generation) must re-mount even when
2674
+ // sessionId + props are unchanged.
2675
+ meta.codeB64 ?? '-',
1938
2676
  meta.propsJson ?? '-',
1939
2677
  liveTrio,
1940
2678
  ].join('|');
@@ -2397,28 +3135,11 @@ async function bootProduction(opts) {
2397
3135
  }
2398
3136
  : {}),
2399
3137
  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
- },
3138
+ // See `channelToolsCall`'s docstring (defined alongside
3139
+ // `dispatchSubmitAction`, its sibling consumer of the
3140
+ // relay-incapability latch) for the fail-fast + fail-safe
3141
+ // rationale (ggui#440).
3142
+ toolsCall: channelToolsCall,
2422
3143
  streamBus,
2423
3144
  ...(onObserve !== undefined
2424
3145
  ? {
@@ -2475,9 +3196,6 @@ async function bootProduction(opts) {
2475
3196
  channelRegistry.register(createChannelErrorHandler({
2476
3197
  getChannelTransport: () => channelTransport,
2477
3198
  }));
2478
- channelRegistry.register(createSystemHandler({
2479
- ...(onObserve !== undefined ? { onObserve } : {}),
2480
- }));
2481
3199
  return {
2482
3200
  rootWireConfig: rootConfig,
2483
3201
  streamBus,