@ggui-ai/iframe-runtime 0.1.0-rc.1

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.
Files changed (92) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +33 -0
  3. package/dist/bootstrap.d.ts +72 -0
  4. package/dist/bootstrap.js +500 -0
  5. package/dist/canvas/animator/animator-host.d.ts +63 -0
  6. package/dist/canvas/animator/animator-host.js +105 -0
  7. package/dist/canvas/animator/animator-pill.d.ts +42 -0
  8. package/dist/canvas/animator/animator-pill.js +119 -0
  9. package/dist/canvas/animator/state-machine.d.ts +106 -0
  10. package/dist/canvas/animator/state-machine.js +150 -0
  11. package/dist/canvas/canvas-shell.d.ts +77 -0
  12. package/dist/canvas/canvas-shell.js +117 -0
  13. package/dist/canvas/display-mode-policy.d.ts +104 -0
  14. package/dist/canvas/display-mode-policy.js +98 -0
  15. package/dist/canvas/mount.d.ts +150 -0
  16. package/dist/canvas/mount.js +202 -0
  17. package/dist/canvas/nav-stack.d.ts +133 -0
  18. package/dist/canvas/nav-stack.js +172 -0
  19. package/dist/channel-transport.d.ts +182 -0
  20. package/dist/channel-transport.js +344 -0
  21. package/dist/channels/channel-payload.d.ts +20 -0
  22. package/dist/channels/channel-payload.js +34 -0
  23. package/dist/channels/data.d.ts +48 -0
  24. package/dist/channels/data.js +103 -0
  25. package/dist/channels/drain-ack.d.ts +13 -0
  26. package/dist/channels/drain-ack.js +15 -0
  27. package/dist/channels/feedback.d.ts +14 -0
  28. package/dist/channels/feedback.js +19 -0
  29. package/dist/channels/index.d.ts +7 -0
  30. package/dist/channels/index.js +7 -0
  31. package/dist/channels/props-update.d.ts +53 -0
  32. package/dist/channels/props-update.js +130 -0
  33. package/dist/channels/push.d.ts +47 -0
  34. package/dist/channels/push.js +54 -0
  35. package/dist/channels/system.d.ts +26 -0
  36. package/dist/channels/system.js +44 -0
  37. package/dist/compiled-validators.d.ts +40 -0
  38. package/dist/compiled-validators.js +74 -0
  39. package/dist/context-observer.d.ts +205 -0
  40. package/dist/context-observer.js +312 -0
  41. package/dist/gadget-loader.d.ts +118 -0
  42. package/dist/gadget-loader.js +136 -0
  43. package/dist/globals.d.ts +234 -0
  44. package/dist/globals.js +101 -0
  45. package/dist/host-context-emitter.d.ts +91 -0
  46. package/dist/host-context-emitter.js +213 -0
  47. package/dist/iframe-runtime.js +261 -0
  48. package/dist/index.d.ts +38 -0
  49. package/dist/index.js +4 -0
  50. package/dist/lifecycle.d.ts +66 -0
  51. package/dist/lifecycle.js +49 -0
  52. package/dist/mcp-app-iframe-host.d.ts +69 -0
  53. package/dist/mcp-app-iframe-host.js +214 -0
  54. package/dist/observability.d.ts +225 -0
  55. package/dist/observability.js +28 -0
  56. package/dist/protocol-error.d.ts +199 -0
  57. package/dist/protocol-error.js +116 -0
  58. package/dist/provisional-renderer.d.ts +62 -0
  59. package/dist/provisional-renderer.js +323 -0
  60. package/dist/react-renderer.d.ts +95 -0
  61. package/dist/react-renderer.js +343 -0
  62. package/dist/registry-subscribe.d.ts +102 -0
  63. package/dist/registry-subscribe.js +319 -0
  64. package/dist/runtime.d.ts +531 -0
  65. package/dist/runtime.js +2623 -0
  66. package/dist/server.d.ts +36 -0
  67. package/dist/server.js +72 -0
  68. package/dist/stack-item-renderer.d.ts +103 -0
  69. package/dist/stack-item-renderer.js +270 -0
  70. package/dist/stack.d.ts +90 -0
  71. package/dist/stack.js +118 -0
  72. package/dist/status-dom.d.ts +72 -0
  73. package/dist/status-dom.js +51 -0
  74. package/dist/system-cards/NoCredentialsCard.d.ts +33 -0
  75. package/dist/system-cards/NoCredentialsCard.js +189 -0
  76. package/dist/system-cards/ProtocolProbeCard.d.ts +64 -0
  77. package/dist/system-cards/ProtocolProbeCard.js +463 -0
  78. package/dist/system-cards/QrCode.d.ts +46 -0
  79. package/dist/system-cards/QrCode.js +55 -0
  80. package/dist/system-cards/host-detect.d.ts +31 -0
  81. package/dist/system-cards/host-detect.js +108 -0
  82. package/dist/system-cards/host-intents.d.ts +85 -0
  83. package/dist/system-cards/host-intents.js +170 -0
  84. package/dist/system-cards/index.d.ts +70 -0
  85. package/dist/system-cards/index.js +134 -0
  86. package/dist/types.d.ts +69 -0
  87. package/dist/types.js +3 -0
  88. package/dist/validation.d.ts +83 -0
  89. package/dist/validation.js +106 -0
  90. package/dist/wire-config.d.ts +214 -0
  91. package/dist/wire-config.js +290 -0
  92. package/package.json +81 -0
@@ -0,0 +1,2623 @@
1
+ import { parseBootstrap, parseBootstrapFromGlobal, parseBootstrapFromToolResult, } from './bootstrap.js';
2
+ import { StackModel } from './stack.js';
3
+ import { attachListener as attachHostContextListener, seed as seedHostContext, subscribeLocal as subscribeHostContextLocal, } from './host-context-emitter.js';
4
+ import { buildRootWireConfig, StreamBus, } from './wire-config.js';
5
+ import { createChannelTransportRouter, } from './channel-transport.js';
6
+ import { ChannelRegistry } from '@ggui-ai/channel-client';
7
+ import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createFeedbackHandler, createPropsUpdateHandler, createPushHandler, createSystemHandler, } from './channels/index.js';
8
+ import { connectViaRegistry, } from './registry-subscribe.js';
9
+ import { ensureStatusDom, refreshStackDom, setStatus, setConnectedStatus, } from './status-dom.js';
10
+ import { mergeReservedValidators, setActiveValidatorSet, } from './validation.js';
11
+ import { loadCompiledValidators } from './compiled-validators.js';
12
+ import { StackRenderer, } from './stack-item-renderer.js';
13
+ import { fromBootstrapFailure, } from './protocol-error.js';
14
+ import { postObservabilityToParent, } from './observability.js';
15
+ import { makeLifecycleEvent, postLifecycleToParent, } from './lifecycle.js';
16
+ import { contextSlotLastValues, createContextStateHost, installContextRegistry, reemitLastContextValues, } from './context-observer.js';
17
+ // =============================================================================
18
+ // Build-time-stamped renderer version. Surfaced to the parent in the
19
+ // `ggui:renderer-ready` notification so hosts (and console views)
20
+ // can correlate runtime behavior with a specific bundle.
21
+ //
22
+ // Hard-coded to the package.json version; future build tooling can
23
+ // substitute via esbuild `define` if a non-package value is wanted.
24
+ // =============================================================================
25
+ const RENDERER_VERSION = '0.1.0';
26
+ const drainAckListeners = new Set();
27
+ export function subscribeDrainAck(listener) {
28
+ drainAckListeners.add(listener);
29
+ return () => {
30
+ drainAckListeners.delete(listener);
31
+ };
32
+ }
33
+ function dispatchDrainAck(payload) {
34
+ for (const listener of drainAckListeners) {
35
+ try {
36
+ if (listener(payload) === true)
37
+ return;
38
+ }
39
+ catch {
40
+ // Absorb — a faulty subscriber can't break the dispatch loop.
41
+ }
42
+ }
43
+ }
44
+ /**
45
+ * Post a JSON-RPC request to `window.parent` and resolve with the
46
+ * matching response. The host MUST echo the request `id` on its
47
+ * response per the MCP Apps lifecycle. Pending requests are tracked
48
+ * by id in a module-private map; responses without a matching id are
49
+ * dropped (per the adapter-boundary rule the shell already enforces).
50
+ *
51
+ * Hosts have no obligation to respond (a misbehaving host can hang the
52
+ * call) — `bootSequence` enforces a timeout at the orchestration layer
53
+ * because the runtime's whole-flow deadline is what matters, not
54
+ * per-call.
55
+ */
56
+ function makeJsonRpcCaller() {
57
+ let nextId = 1;
58
+ const pending = new Map();
59
+ window.addEventListener('message', (event) => {
60
+ const data = event.data;
61
+ if (data === null || typeof data !== 'object')
62
+ return;
63
+ const id = data.id;
64
+ if (typeof id !== 'number')
65
+ return;
66
+ // Filter for RESPONSES — must carry `result` or `error`. Bare
67
+ // requests with `method` set are not responses (see the matching
68
+ // filter in `ensurePostRpcToParentListener` for the same reason).
69
+ if (!('result' in data) && !('error' in data))
70
+ return;
71
+ const resolver = pending.get(id);
72
+ if (resolver === undefined)
73
+ return;
74
+ pending.delete(id);
75
+ resolver(data);
76
+ });
77
+ return (method, params) => new Promise((resolve) => {
78
+ const id = nextId;
79
+ nextId += 1;
80
+ pending.set(id, resolve);
81
+ try {
82
+ window.parent.postMessage({
83
+ jsonrpc: '2.0',
84
+ id,
85
+ method,
86
+ params: params ?? {},
87
+ }, '*');
88
+ }
89
+ catch (err) {
90
+ pending.delete(id);
91
+ resolve({ error: { message: err instanceof Error ? err.message : String(err) } });
92
+ }
93
+ });
94
+ }
95
+ /**
96
+ * Notify the parent that the renderer is alive + ready to receive
97
+ * post-handshake messages. Sent immediately after the status DOM
98
+ * mounts, BEFORE `ui/initialize` fires — gives hosts an early signal
99
+ * that the iframe loaded its bundle successfully.
100
+ */
101
+ function postRendererReady() {
102
+ try {
103
+ window.parent.postMessage({ type: 'ggui:renderer-ready', version: RENDERER_VERSION }, '*');
104
+ }
105
+ catch {
106
+ // postMessage failure here means the parent is unreachable; the
107
+ // boot sequence will fail downstream when `ui/initialize` doesn't
108
+ // resolve. Swallowing keeps the renderer from crashing on a
109
+ // benign host close.
110
+ }
111
+ }
112
+ function postBootFailure(reason, message) {
113
+ try {
114
+ const envelope = {
115
+ type: 'ggui:bootstrap-failed',
116
+ reason,
117
+ message,
118
+ };
119
+ window.parent.postMessage(envelope, '*');
120
+ }
121
+ catch {
122
+ // Same posture as postRendererReady — best-effort.
123
+ }
124
+ }
125
+ export async function bootSequence(opts) {
126
+ const { doc, callUiInitialize, notifyParent, triadWiring, onProtocolError, onObserve, onLifecycle } = opts;
127
+ const connectFn = opts.connectFn ?? connectViaRegistry;
128
+ // Emit typed {@link ProtocolError} for every bootstrap-failure site
129
+ // that surfaces a `RendererBootFailedMessage`. The narrow
130
+ // postMessage envelope stays (parent compatibility); the typed
131
+ // emission runs in parallel for host-wrapper consumption. Both
132
+ // paths fire in the SAME order so tests pin the coupling.
133
+ //
134
+ // Lifecycle `error` mirrors the same emission timing — `onLifecycle`
135
+ // (when bound) fires synchronously alongside `notifyParent`. Hosts
136
+ // observing the outer-DOM `data-ggui-mcp-app-iframe-lifecycle`
137
+ // attribute see the transition concurrent with the legacy envelope's
138
+ // `onError` callback.
139
+ const emitBootFailure = (reason, message) => {
140
+ notifyParent({
141
+ type: 'ggui:bootstrap-failed',
142
+ // The legacy envelope's reason type is `RendererBootFailureReason`
143
+ // (a subset of `BootstrapFailureReason`). Every value we emit here
144
+ // is a member of that subset; the cast is a narrow-to-narrow
145
+ // projection, not a widening.
146
+ reason: reason,
147
+ message,
148
+ });
149
+ onProtocolError?.(fromBootstrapFailure(reason, message));
150
+ onLifecycle?.(makeLifecycleEvent('error', { error: { code: reason, message } }));
151
+ };
152
+ const refs = ensureStatusDom(doc);
153
+ // Construct the model empty + unfiltered so the initial "empty
154
+ // placeholder" render + any early-return-on-failure path uses a
155
+ // consistent instance. If the bootstrap parse below succeeds AND
156
+ // pins a `stackItemId`, we swap to a filtered model before any
157
+ // setAll / upsert runs — the filter is immutable per-instance, so
158
+ // swap-then-reassign is the only way to lock the pin.
159
+ let stackModel = new StackModel();
160
+ // Initial empty-placeholder render via the canonical helper.
161
+ // `refreshStackDom` is the public seam handlers use after fold.
162
+ refreshStackDom(refs, stackModel);
163
+ setStatus(refs, 'Negotiating with host…', 'connecting');
164
+ notifyParent({ type: 'ggui:renderer-ready', version: RENDERER_VERSION });
165
+ // Lifecycle `mounting` — the renderer is alive + status DOM is up,
166
+ // but no IO has run yet. Hosts mirroring lifecycle to outer DOM see
167
+ // this transition concurrent with `ggui:renderer-ready`. Idempotent
168
+ // re-emission is the host's concern (per protocol, host treats
169
+ // duplicate same-state envelopes as a no-op).
170
+ onLifecycle?.(makeLifecycleEvent('mounting'));
171
+ const initResp = await callUiInitialize();
172
+ if (initResp.error !== undefined || initResp.result === undefined) {
173
+ const message = initResp.error?.message ?? 'ui/initialize returned no result';
174
+ setStatus(refs, `ui/initialize failed: ${message}`, 'error');
175
+ emitBootFailure('UI_INITIALIZE_FAILED', message);
176
+ return { ok: false, stack: stackModel };
177
+ }
178
+ const parsed = parseBootstrap(initResp.result);
179
+ if (!parsed.ok) {
180
+ const message = `bootstrap parse failed: ${parsed.reason}`;
181
+ setStatus(refs, message, 'error');
182
+ emitBootFailure(parsed.reason, message);
183
+ return { ok: false, stack: stackModel };
184
+ }
185
+ // Install the precompiled, eval-free contract validators shipped on
186
+ // the bootstrap BEFORE any wire traffic is validated. Self-contained
187
+ // ESM modules — no `__ggui__` dependency, so this need not wait for
188
+ // `installGlobalRegistry`. A failed load leaves the validation seam
189
+ // to fall back to in-iframe compilation (CSP-blocked, but no worse
190
+ // than pre-A4).
191
+ setActiveValidatorSet(await loadCompiledValidators(parsed.bootstrap.compiledValidators));
192
+ // Single-item mode. When a per-item resource route served this
193
+ // resource, the bootstrap carries `stackItemId` and the renderer
194
+ // binds to just that stack entry. Absent (whole-session resource)
195
+ // → multi-item render path. The swap happens BEFORE any setAll /
196
+ // upsert so filter semantics apply to the first-frame stack the
197
+ // ack delivers.
198
+ if (parsed.bootstrap.stackItemId !== undefined) {
199
+ stackModel = new StackModel({ filterToItemId: parsed.bootstrap.stackItemId });
200
+ }
201
+ // Canvas mode: when bootstrap.canvasMode
202
+ // AND a canvasMount hook is wired, mount CanvasShell as a React
203
+ // tree into refs.stack BEFORE triadWiring.setup so the triad's
204
+ // renderer writes into the canvas's content slot. The canvas's
205
+ // NavStackModel is kept in sync with stackModel on every applyAck.
206
+ // No canvasMount hook OR canvasMode !== true ⇒ legacy inline path.
207
+ let canvasMountHandle = null;
208
+ let renderIntoEl = refs.stack;
209
+ // Late-bound send surface for canvas's
210
+ // outbound `canvas_navigated` envelopes. The triad's `manager`
211
+ // (constructed inside triadWiring.setup below) is the producer; we
212
+ // capture it here so the canvas callbacks — installed at mount time
213
+ // BEFORE setup — can dispatch through the buffered shim once the
214
+ // WS is up. Calls during the bind gap silently no-op (the user
215
+ // can't physically click back-arrow before the canvas is rendered).
216
+ let canvasOutboundSend = null;
217
+ if (parsed.bootstrap.canvasMode === true && opts.canvasMount !== undefined) {
218
+ canvasMountHandle = opts.canvasMount({
219
+ mountTarget: refs.stack,
220
+ callbacks: {
221
+ // Back-gesture → live-channel
222
+ // `canvas_navigated` outbound. The server updates
223
+ // `session.activeStackItemId` so ggui_consume's active-pipe
224
+ // resolution stays aligned with the user's nav focus.
225
+ onBackGesture: ({ previousActiveItemId, activeItemId }) => {
226
+ canvasOutboundSend?.({
227
+ type: 'canvas_navigated',
228
+ payload: {
229
+ sessionId: parsed.bootstrap.sessionId,
230
+ previousActiveItemId,
231
+ activeItemId,
232
+ },
233
+ });
234
+ },
235
+ // Display-mode escalation → spec
236
+ // postMessage to the host. The escalation policy decides
237
+ // which mode to request based on host capabilities + content
238
+ // state;
239
+ // this just forwards the decision via the existing
240
+ // `requestDisplayModeInParent` helper (paired audit
241
+ // envelope fires automatically). Hosts that don't speak the
242
+ // spec drop the postMessage silently, so the request is
243
+ // safe to fire unconditionally — the policy already gates
244
+ // on `availableDisplayModes`.
245
+ onRequestDisplayMode: (mode) => {
246
+ requestDisplayModeInParent({
247
+ toolName: 'ggui_canvas_shell',
248
+ mode,
249
+ sessionId: parsed.bootstrap.sessionId,
250
+ appId: parsed.bootstrap.appId,
251
+ });
252
+ },
253
+ },
254
+ });
255
+ renderIntoEl = canvasMountHandle.contentEl;
256
+ }
257
+ // Triad wiring — when supplied, the handler routes frames through
258
+ // StackRenderer + WireConfig + StreamBus. When absent, the placeholder
259
+ // path runs (boot.test.ts relies on the latter to keep its import
260
+ // graph tiny). The triad's channelRegistry is the dispatch surface
261
+ // for every WS frame; `connectFn` registers handshake handlers on
262
+ // top of the existing registry then binds the transport.
263
+ // When canvas-mode is active, build the
264
+ // lifecycle-payload sink that translates wire-shape
265
+ // `CanvasLifecyclePayload` envelopes into the animator-event
266
+ // shape and publishes on the mount's event bus. The triad's `data`
267
+ // channel handler invokes this for every envelope on the reserved
268
+ // `_ggui:lifecycle` channel.
269
+ const canvasLifecycleSink = canvasMountHandle !== null
270
+ ? canvasMountHandle.events.publishLifecycle.bind(canvasMountHandle.events)
271
+ : undefined;
272
+ const triad = triadWiring !== undefined
273
+ ? triadWiring.setup({
274
+ bootstrap: parsed.bootstrap,
275
+ stackModel,
276
+ renderInto: renderIntoEl,
277
+ statusRefs: refs,
278
+ ...(onObserve !== undefined ? { onObserve } : {}),
279
+ ...(canvasLifecycleSink !== undefined
280
+ ? { onCanvasLifecycle: canvasLifecycleSink }
281
+ : {}),
282
+ })
283
+ : null;
284
+ setStatus(refs, `Connecting to ${parsed.bootstrap.sessionId}…`, 'connecting');
285
+ // Boot-without-triad path: we still need a ChannelRegistry to
286
+ // receive frames, because the registry is the only dispatch
287
+ // surface. Build a minimal one with just the `push` placeholder
288
+ // handler so non-triad consumers (boot.test.ts) see the
289
+ // `data-ggui-stack-item` upserts.
290
+ const placeholderRegistry = triad === null
291
+ ? createPlaceholderRegistry({
292
+ bootstrap: parsed.bootstrap,
293
+ stackModel,
294
+ statusRefs: refs,
295
+ })
296
+ : null;
297
+ const activeRegistry = triad?.channelRegistry ?? placeholderRegistry;
298
+ // Bind the canvas's outbound send surface to
299
+ // the triad's buffered-send shim. The shim queues frames pre-WS;
300
+ // they flush automatically once `attachManager` swaps in the real
301
+ // socket. No need to await — the canvas's back-arrow won't fire
302
+ // until the user can physically click, which requires render +
303
+ // layout that always come after this binding.
304
+ if (canvasMountHandle !== null && triad !== null) {
305
+ canvasOutboundSend = (msg) => triad.manager.send(msg);
306
+ }
307
+ // Subscribe the canvas mount to local
308
+ // host-context-emitter updates so the display-mode escalation
309
+ // policy reconciles against the latest host capabilities. Fires
310
+ // once on
311
+ // subscribe with the seeded projection (if any) AND on every
312
+ // `host-context-changed` notification. Captures the local handle
313
+ // so the subscription is scoped to this boot pass — re-mounts
314
+ // would re-subscribe.
315
+ if (canvasMountHandle !== null) {
316
+ const localHandle = canvasMountHandle;
317
+ const unsubscribeHostContext = subscribeHostContextLocal((projection) => {
318
+ localHandle.setAvailableDisplayModes(projection.availableDisplayModes);
319
+ localHandle.setDisplayMode(projection.currentDisplayMode);
320
+ });
321
+ localHandle.registerCleanup(unsubscribeHostContext);
322
+ }
323
+ /**
324
+ * Apply an ack's stack snapshot to the runtime — populates the model,
325
+ * refreshes status DOM, and (when triad is wired) re-renders the
326
+ * stack + re-activates the top item's channel-transport entry. Used
327
+ * by:
328
+ *
329
+ * - The initial bootSequence path (first ack after subscribe).
330
+ * - The WS reconnect-with-rebootstrap path (every subsequent ack
331
+ * when the underlying WSTransport reconnects + re-fires
332
+ * subscribe). A push or update that landed during the dropout
333
+ * window flows back through here.
334
+ *
335
+ * Idempotent on identical inputs — `stackModel.setAll` is a snapshot
336
+ * replace; `applyStack` + `applyStackItem` are server-side idempotent
337
+ * on the (stackItemId, channelName) tuple.
338
+ */
339
+ const applyAckStack = async (ackPayload) => {
340
+ if (ackPayload.stack === undefined)
341
+ return;
342
+ stackModel.setAll(ackPayload.stack);
343
+ refreshStackDom(refs, stackModel);
344
+ // Mirror the server snapshot into the canvas's NavStackModel so
345
+ // the React shell re-renders with the active item. `reset` fires
346
+ // `onMutation` → version bump → React bridge re-reads. It also
347
+ // fires `onNavChange` when the top item identity moves, but that
348
+ // listener is intentionally NOT wired in production: server-side
349
+ // stack resets flowing through this ack path do not echo
350
+ // `canvas_navigated` back — the server already owns
351
+ // `activeStackItemId` for state it produced. Outbound
352
+ // `canvas_navigated` originates only from the `onBackGesture`
353
+ // callback (user-driven pop). `onNavChange` remains available for
354
+ // future programmatic navigation triggers that originate inside
355
+ // the canvas without a back-gesture.
356
+ canvasMountHandle?.setStack(ackPayload.stack);
357
+ if (triad !== null) {
358
+ await triad.stackRenderer.applyStack(stackModel.snapshot());
359
+ const snapshot = stackModel.snapshot();
360
+ const top = snapshot[snapshot.length - 1];
361
+ if (top !== undefined &&
362
+ top.type !== 'mcpApps' &&
363
+ top.type !== 'system') {
364
+ triad.channelTransport.applyStackItem({
365
+ stackItemId: top.id,
366
+ ...(top.streamSpec !== undefined
367
+ ? { streamSpec: top.streamSpec }
368
+ : {}),
369
+ });
370
+ }
371
+ }
372
+ };
373
+ let handle;
374
+ try {
375
+ handle = await connectFn({
376
+ bootstrap: parsed.bootstrap,
377
+ registry: activeRegistry,
378
+ onStatusChange: (status) => {
379
+ setStatus(refs, status === 'connected'
380
+ ? `Connected (${stackModel.size()} item${stackModel.size() === 1 ? '' : 's'}).`
381
+ : `Connection ${status}…`, status);
382
+ // Propagate WS status to the per-channel transport router so
383
+ // it can flip WS-bound channels into polling
384
+ // fallback (on disconnect) and re-send `channel_subscribe`
385
+ // on reconnect. No-op when triad wiring is absent.
386
+ if (triad !== null) {
387
+ triad.channelTransport.onWsStatusChange(status);
388
+ }
389
+ },
390
+ // Forward typed errors from subscribe through the runtime's
391
+ // own emitter — connectViaRegistry classifies transport + auth
392
+ // + version + protocol errors on its own, we just plumb them
393
+ // along.
394
+ ...(onProtocolError !== undefined ? { onProtocolError } : {}),
395
+ // Forward observability emissions (schema-version-mismatch,
396
+ // subscribe-failed). The renderer owns `contract-error-emitted`
397
+ // via the data channel handler; connectFn owns the other two.
398
+ ...(onObserve !== undefined ? { onObserve } : {}),
399
+ // Reconnect-with-rebootstrap — on every ack received AFTER the
400
+ // initial handshake settled, reapply the server's authoritative
401
+ // `stack` snapshot. A push or update that landed during a WS
402
+ // dropout window restores here without an agent re-prompt.
403
+ onResubscribeAck: (ack) => {
404
+ void applyAckStack(ack);
405
+ },
406
+ });
407
+ }
408
+ catch (err) {
409
+ if (triad !== null)
410
+ triadWiring?.teardown?.(triad);
411
+ if (isUpgradeRequiredErrorLike(err)) {
412
+ const message = err.message;
413
+ setStatus(refs, message, 'upgrade-required');
414
+ // `UPGRADE_REQUIRED` already emits a typed `version` error via
415
+ // connectFn's onProtocolError path; the bootstrap-failure emit
416
+ // here carries the coarse-grained reason for hosts that only
417
+ // pattern-match `kind: 'bootstrap'`.
418
+ emitBootFailure('UPGRADE_REQUIRED', message);
419
+ return { ok: false, stack: stackModel };
420
+ }
421
+ const message = err instanceof Error ? err.message : String(err);
422
+ setStatus(refs, `WS handshake failed: ${message}`, 'error');
423
+ emitBootFailure('WS_HANDSHAKE_FAILED', message);
424
+ return { ok: false, stack: stackModel };
425
+ }
426
+ // Attach the live transport handle to the triad — flushes any
427
+ // buffered outbound frames (feedback / action) that were queued
428
+ // while the subscribe handshake completed. `handle.handle.send` is
429
+ // the canonical send surface for the bound WS transport.
430
+ if (triad !== null && triadWiring?.attachManager !== undefined) {
431
+ triadWiring.attachManager(triad, { send: (msg) => handle.handle.send(msg) });
432
+ }
433
+ // seed the host-context emitter with the
434
+ // projection `parseBootstrap` captured from `ui/initialize`'s
435
+ // `hostContext` field, and install the `host-context-changed`
436
+ // notification listener so subsequent live updates also echo to
437
+ // the server. Both calls are idempotent and no-op when the host
438
+ // didn't emit a HostContext (parsed.hostContext === undefined).
439
+ if (parsed.hostContext !== undefined) {
440
+ seedHostContext({
441
+ sessionId: parsed.bootstrap.sessionId,
442
+ send: (msg) => handle.handle.send(msg),
443
+ initial: parsed.hostContext,
444
+ });
445
+ attachHostContextListener();
446
+ }
447
+ // First ack — populate the stack from the snapshot the server
448
+ // returned. Under triad mode, also hand the initial stack to the
449
+ // renderer so its mounts match the model on first frame.
450
+ //
451
+ // Reuses the same `applyAckStack` helper the reconnect-rebootstrap
452
+ // path uses — so a server-restart-driven full snapshot replay and
453
+ // the first-boot snapshot apply flow through one implementation.
454
+ await applyAckStack(handle.ack);
455
+ setConnectedStatus(refs, stackModel);
456
+ // Lifecycle `code-ready` — terminal happy state. The bundle has
457
+ // evaluated, the WS handshake completed, the first ack folded into
458
+ // the stack. Hosts pinning selectors on `code-ready` (E2E specs,
459
+ // accessibility scanners) re-resolve here. When the bootstrap pinned
460
+ // a `stackItemId` (single-item mode), forward it so the host can
461
+ // mirror per-card lifecycle on the outer element if it's keyed by
462
+ // stack item id.
463
+ onLifecycle?.(makeLifecycleEvent('code-ready', {
464
+ ...(parsed.bootstrap.stackItemId !== undefined
465
+ ? { stackItemId: parsed.bootstrap.stackItemId }
466
+ : {}),
467
+ }));
468
+ return { ok: true, stack: stackModel };
469
+ }
470
+ /**
471
+ * Build a minimal `ChannelRegistry` for boot paths without triad
472
+ * wiring (boot.test.ts + the C7a placeholder-only spec). The registry
473
+ * carries just the `push` handler (which folds frames into the
474
+ * placeholder DOM) — every other frame type silently drops. Production
475
+ * boots through `bootProduction` which supplies a fully-populated
476
+ * triad with the rich handler set.
477
+ */
478
+ function createPlaceholderRegistry(params) {
479
+ const registry = new ChannelRegistry({
480
+ subscribeFrameBuilder: () => ({
481
+ type: 'subscribe',
482
+ payload: {
483
+ sessionId: params.bootstrap.sessionId,
484
+ appId: params.bootstrap.appId,
485
+ ...(params.bootstrap.token !== undefined
486
+ ? { bootstrap: params.bootstrap.token }
487
+ : {}),
488
+ },
489
+ }),
490
+ });
491
+ registry.register(createPushHandler({
492
+ stackModel: params.stackModel,
493
+ statusRefs: params.statusRefs,
494
+ }));
495
+ return registry;
496
+ }
497
+ /**
498
+ * Frame dispatch lives inside `@ggui-ai/channel-client`'s
499
+ * `ChannelRegistry`. Every WS frame type (`push`, `data`,
500
+ * `props_update`, `drain_ack`, `feedback`, `channel_payload`,
501
+ * `channel_error`, `system`) has a registered handler in
502
+ * `channels/*.ts`; the registry's bound transport routes inbound
503
+ * frames directly to them. Observability emissions
504
+ * (`contract-error-emitted`, `auth-required`) are now inside the
505
+ * data + system handlers respectively.
506
+ *
507
+ * The pre-B3b helpers (`handleServerMessage`, `handleTriadMessage`,
508
+ * `handleObservableMessage`, `emitContractErrorFromDataFrame`,
509
+ * `emitAuthRequiredFromSystemFrame`, `BufferedManagerShim`) lived
510
+ * here and have been retired — see commit message + plan B3b for the
511
+ * full retirement notes.
512
+ */
513
+ /**
514
+ * Type guard for the `UpgradeRequiredError` class without importing
515
+ * the `instanceof` constructor here — the runtime catches via duck-
516
+ * typing because it doesn't want to retain the class reference (which
517
+ * would inflate the bundle). The class itself is checked inside
518
+ * `connectViaRegistry`; this guard is just for the post-throw branch.
519
+ */
520
+ function isUpgradeRequiredErrorLike(value) {
521
+ return (value !== null &&
522
+ typeof value === 'object' &&
523
+ value.name === 'UpgradeRequiredError' &&
524
+ value.code === 'UPGRADE_REQUIRED' &&
525
+ typeof value.message === 'string');
526
+ }
527
+ function shouldAutostart() {
528
+ if (typeof process === 'undefined')
529
+ return true;
530
+ return process.env?.GGUI_RENDERER_AUTOSTART !== 'false';
531
+ }
532
+ /**
533
+ * Read `globalThis.__GGUI_BOOTSTRAP__` synchronously, validate against
534
+ * the unified bootstrap shape, and return the typed bootstrap (or null
535
+ * on absence / malformation).
536
+ *
537
+ * Thin wrapper around {@link parseBootstrapFromGlobal}; preserved for
538
+ * back-compat (downstream consumers + tests). Returns `null` instead
539
+ * of `BootstrapParseResult` to match the historical reader contract
540
+ * — the autostart resolver only needs the "valid bootstrap or fall
541
+ * through" signal.
542
+ */
543
+ export function readSelfContainedBootstrap() {
544
+ const result = parseBootstrapFromGlobal();
545
+ return result.ok ? result.bootstrap : null;
546
+ }
547
+ /**
548
+ * Extract a {@link SelfContainedBootstrap} from a
549
+ * `ui/notifications/tool-result` JSON-RPC params payload — the
550
+ * postMessage delivery shape MCP Apps hosts (Claude Desktop, claude.ai
551
+ * web) use to push the active tool's `_meta` to the iframe.
552
+ *
553
+ * Thin wrapper around {@link parseBootstrapFromToolResult}; same
554
+ * back-compat motivation as {@link readSelfContainedBootstrap}.
555
+ *
556
+ * Owning this extraction inside the runtime (instead of in the shell
557
+ * HTML) is the architectural cure for the "shell-side validator
558
+ * lagged the protocol" bug class: shell + runtime drift becomes
559
+ * impossible because the shell never inspects the bootstrap shape.
560
+ */
561
+ export function extractBootstrapFromToolResult(params) {
562
+ const result = parseBootstrapFromToolResult(params);
563
+ return result.ok ? result.bootstrap : null;
564
+ }
565
+ /**
566
+ * Drain `window.__GGUI_PENDING_TOOL_RESULTS__` — the buffer the
567
+ * minimal shell populates while messages arrive between
568
+ * shell-load and runtime-load. Returns the first valid bootstrap
569
+ * found; later params (if any) are dropped (each new tool-result
570
+ * supersedes the previous).
571
+ *
572
+ * The shell-side buffer contract: an array of
573
+ * `{params: unknown}`-shaped JSON-RPC params from
574
+ * `ui/notifications/tool-result` notifications, populated in
575
+ * arrival order. The runtime drains it on first read.
576
+ */
577
+ function readPendingToolResults() {
578
+ if (typeof window === 'undefined')
579
+ return null;
580
+ const raw = window.__GGUI_PENDING_TOOL_RESULTS__;
581
+ if (!Array.isArray(raw) || raw.length === 0)
582
+ return null;
583
+ for (const params of raw) {
584
+ const bootstrap = extractBootstrapFromToolResult(params);
585
+ if (bootstrap !== null)
586
+ return bootstrap;
587
+ }
588
+ return null;
589
+ }
590
+ /**
591
+ * Listen for a `ui/notifications/tool-result` postMessage from the
592
+ * parent window and resolve to the extracted bootstrap. Times out
593
+ * after `timeoutMs`; resolves `null` on timeout so the caller can
594
+ * fall through to a legacy bootstrap path (e.g.
595
+ * {@link bootProduction}).
596
+ *
597
+ * Pairs with the minimal-shell pattern: shell buffers any tool-
598
+ * results that arrived BEFORE runtime load (read via
599
+ * {@link readPendingToolResults}); this listener catches the ones
600
+ * that arrive AFTER. The two cover the full timing race.
601
+ */
602
+ function awaitToolResultBootstrap(timeoutMs) {
603
+ if (typeof window === 'undefined')
604
+ return Promise.resolve(null);
605
+ return new Promise((resolve) => {
606
+ let settled = false;
607
+ const settle = (value) => {
608
+ if (settled)
609
+ return;
610
+ settled = true;
611
+ window.removeEventListener('message', onMessage);
612
+ clearTimeout(timer);
613
+ resolve(value);
614
+ };
615
+ const onMessage = (ev) => {
616
+ const m = ev.data;
617
+ if (m === null ||
618
+ m === undefined ||
619
+ m.jsonrpc !== '2.0' ||
620
+ m.method !== 'ui/notifications/tool-result') {
621
+ return;
622
+ }
623
+ const bootstrap = extractBootstrapFromToolResult(m.params);
624
+ if (bootstrap !== null)
625
+ settle(bootstrap);
626
+ };
627
+ window.addEventListener('message', onMessage);
628
+ const timer = setTimeout(() => settle(null), timeoutMs);
629
+ });
630
+ }
631
+ /**
632
+ * Post-mount resize plumbing for MCP Apps hosts that size the outer
633
+ * iframe based on the inner content height. claude.ai (web +
634
+ * desktop) honors `ui/notifications/size-changed` notifications;
635
+ * other hosts ignore them harmlessly.
636
+ *
637
+ * Owning this in the runtime (instead of duplicated in every shell
638
+ * builder) is the same architectural fix as
639
+ * {@link extractBootstrapFromToolResult}: anything that observes
640
+ * mounted-component state belongs alongside the mount, not in the
641
+ * shell.
642
+ */
643
+ function installResizeObserver(rootEl) {
644
+ if (typeof window === 'undefined')
645
+ return;
646
+ let lastH = 0;
647
+ const emit = () => {
648
+ const docBody = window.document.body;
649
+ const h = Math.max(rootEl instanceof HTMLElement ? rootEl.scrollHeight : 0, docBody.scrollHeight, 200);
650
+ if (h === lastH)
651
+ return;
652
+ lastH = h;
653
+ try {
654
+ window.parent.postMessage({
655
+ jsonrpc: '2.0',
656
+ method: 'ui/notifications/size-changed',
657
+ params: { height: h },
658
+ }, '*');
659
+ }
660
+ catch {
661
+ // Detached parent — swallow (matches postBootFailure posture).
662
+ }
663
+ };
664
+ emit();
665
+ if (typeof ResizeObserver === 'function') {
666
+ const ro = new ResizeObserver(emit);
667
+ ro.observe(rootEl);
668
+ ro.observe(window.document.body);
669
+ }
670
+ else {
671
+ setInterval(emit, 500);
672
+ }
673
+ }
674
+ /**
675
+ * Module-level guard for {@link installPostMountListener}. Ensures
676
+ * exactly one persistent `ui/notifications/tool-result` listener is
677
+ * attached even when `bootSelfContained` is called multiple times
678
+ * (e.g. an agent fires a second `ggui_push` and we re-mount).
679
+ */
680
+ let postMountListenerInstalled = false;
681
+ /**
682
+ * Module-level guard for {@link installAnchorClickInterceptor}. Same
683
+ * rationale as {@link postMountListenerInstalled}: re-mounts are
684
+ * triggered by `installPostMountListener`, and stacking capture-phase
685
+ * click listeners across re-mounts would multiply the audit envelopes
686
+ * that fire on a single click.
687
+ */
688
+ let anchorClickInterceptInstalled = false;
689
+ /**
690
+ * Module-level guard for {@link installFullscreenInterceptors}. The
691
+ * fullscreen interceptors REPLACE prototype methods on `Element` and
692
+ * `Document`; re-overriding on every re-mount would chain wrappers
693
+ * and (if the captured `args` differ) leak stale `sessionId`/`appId`.
694
+ * The guard ensures exactly one prototype patch.
695
+ */
696
+ let fullscreenInterceptInstalled = false;
697
+ /**
698
+ * Compute a short deterministic action-id from a wired-action
699
+ * payload. FNV-1a 32-bit, 8 hex chars — not cryptographically
700
+ * strong, just collision-resistant enough for in-flight
701
+ * correlation between the silent context-update and the loud
702
+ * consent message that bridge a click to the host's LLM.
703
+ */
704
+ function fnv1aHex(payload) {
705
+ let hash = 0x811c9dc5;
706
+ for (let i = 0; i < payload.length; i += 1) {
707
+ hash ^= payload.charCodeAt(i);
708
+ hash = Math.imul(hash, 0x01000193);
709
+ }
710
+ return (hash >>> 0).toString(16).padStart(8, '0');
711
+ }
712
+ /**
713
+ * Render a wired-action's `data` payload as a short inline string
714
+ * for embedding in a `ui/message` consent prompt. Goal: human-
715
+ * readable, not a JSON dump. Falls back to truncated JSON for
716
+ * nested values so the prompt doesn't drop information silently.
717
+ *
718
+ * Exported for unit-testing — an earlier implementation returned `''`
719
+ * for primitive payloads (strings/numbers/booleans), which silently
720
+ * vaporised the chip's actual text from the consent prompt and made
721
+ * the LLM think every dispatch was a contentless "Please proceed
722
+ * with **<intent>**" request.
723
+ */
724
+ export function formatWiredActionDataInline(data) {
725
+ if (data === null || data === undefined)
726
+ return '';
727
+ // Bare primitives: render verbatim. Strings unquoted (most legible
728
+ // in a "Please proceed with X (foo)" sentence). Numbers / booleans
729
+ // stringified.
730
+ if (typeof data === 'string')
731
+ return data;
732
+ if (typeof data === 'number' || typeof data === 'boolean')
733
+ return String(data);
734
+ // Arrays: short JSON, truncated. Length cap mirrors the per-entry
735
+ // cap below so the consent line stays a single human-readable phrase.
736
+ if (Array.isArray(data)) {
737
+ const json = JSON.stringify(data);
738
+ return json.length > 60 ? `${json.slice(0, 57)}…` : json;
739
+ }
740
+ if (typeof data !== 'object')
741
+ return '';
742
+ const entries = Object.entries(data);
743
+ if (entries.length === 0)
744
+ return '';
745
+ const parts = entries.map(([k, v]) => {
746
+ if (v === null)
747
+ return `${k}: null`;
748
+ if (v === undefined)
749
+ return `${k}: undefined`;
750
+ if (typeof v === 'string')
751
+ return `${k}: ${v}`;
752
+ if (typeof v === 'number' || typeof v === 'boolean')
753
+ return `${k}: ${v}`;
754
+ // `JSON.stringify` returns `undefined` for unrepresentable values
755
+ // (e.g. a `function` field on a form payload object) — guard so
756
+ // the subsequent `.length` access doesn't crash the dispatch path.
757
+ const json = JSON.stringify(v) ?? String(v);
758
+ return `${k}: ${json.length > 40 ? `${json.slice(0, 37)}…` : json}`;
759
+ });
760
+ return parts.join(', ');
761
+ }
762
+ /**
763
+ * Dispatch a wired-action via the empirically-validated bridge
764
+ * chain (validated against claude.ai):
765
+ *
766
+ * 1. `tools/call` to {@link toolName} with the action envelope
767
+ * — server-side gateway log + audit. Hosts MUST honor
768
+ * `_meta.ui.visibility:['app']` per spec §401, otherwise the
769
+ * call is silently rejected (probe found this empirically).
770
+ * 2. `ui/update-model-context` (silent) — drops a structured
771
+ * `[ggui:pending-action]` payload into the LLM's persistent
772
+ * context. Carries the exact intent + data + actionId so the
773
+ * LLM has unambiguous tool args (no natural-language
774
+ * paraphrase risk).
775
+ * 3. `ui/message` (consent prompt) — natural-language
776
+ * authorization the user confirms in chat. Spec §1032 + §401
777
+ * together: this is the prompt-injection firewall — the
778
+ * iframe can ASK the LLM to act, but the user is the one who
779
+ * introduces the message into the LLM's context.
780
+ *
781
+ * Why all three: the probe empirically proved that `tools/call`
782
+ * alone never reaches the LLM (host-side scope:['app'] firewall),
783
+ * and `ui/message` alone forces the LLM to parse natural language
784
+ * for tool args (lossy + hallucination-prone). Pairing them with
785
+ * a hash-bound id (#3) gives the LLM both verifiable args AND
786
+ * explicit user authorization.
787
+ *
788
+ * Parts 2+3 fail-soft: if the host rejects either, the user can
789
+ * still observe the click via #1's audit log on the server side,
790
+ * but the LLM won't act on it. Acceptable degraded UX vs. the
791
+ * pre-bridge "silent button click" status quo.
792
+ */
793
+ /**
794
+ * Post an arbitrary JSON-RPC envelope to the iframe's parent
795
+ * window. Internal helper shared across {@link dispatchWiredAction},
796
+ * {@link emitAudit}, and the native-idiom interceptors (`openLink` /
797
+ * `requestDisplayMode`). Detached parent → silent drop (non-fatal).
798
+ */
799
+ function postToParent(envelope) {
800
+ if (typeof window === 'undefined')
801
+ return;
802
+ try {
803
+ window.parent.postMessage(envelope, '*');
804
+ }
805
+ catch {
806
+ // Detached parent — non-fatal, drop silently.
807
+ }
808
+ }
809
+ /**
810
+ * Request-response JSON-RPC postMessage. Fires the envelope up to
811
+ * `window.parent` and resolves when the parent echoes back a message
812
+ * with the matching `id`. Used by the channel-transport router's
813
+ * iframe-polling fallback to fire `tools/call` directly (Pattern α)
814
+ * without an LLM consent loop.
815
+ *
816
+ * Detached parent → rejects with a synchronous error. Single
817
+ * shared listener per iframe lifecycle (added on first call) so the
818
+ * router doesn't churn `'message'` handlers per poll tick.
819
+ *
820
+ * Distinct from {@link makeJsonRpcCaller} which is a CLOSURE-scoped
821
+ * caller bound to the bootstrap-mode `ui/initialize` flow. This one
822
+ * is a top-level helper the triad setup uses for every `tools/call`
823
+ * — moving it here keeps the channel-transport router pure (no
824
+ * `window.addEventListener` inside).
825
+ */
826
+ let postRpcToParentInited = false;
827
+ const postRpcToParentPending = new Map();
828
+ let postRpcToParentNextId = 1_000_000;
829
+ function ensurePostRpcToParentListener() {
830
+ if (postRpcToParentInited)
831
+ return;
832
+ if (typeof window === 'undefined')
833
+ return;
834
+ postRpcToParentInited = true;
835
+ window.addEventListener('message', (event) => {
836
+ const data = event.data;
837
+ if (data === null || typeof data !== 'object')
838
+ return;
839
+ const id = data.id;
840
+ if (typeof id !== 'number')
841
+ return;
842
+ // Filter for RESPONSES — must carry `result` or `error`. Bare
843
+ // requests with `method` set are not responses. This matters in
844
+ // `/r/<shortCode>` top-level mode where `window.parent === window`,
845
+ // so the outbound `tools/call` echoes back through this same
846
+ // listener before any relay's response arrives; without the filter
847
+ // the pending promise resolves with the request itself and the
848
+ // real response is dropped.
849
+ if (!('result' in data) && !('error' in data))
850
+ return;
851
+ const resolver = postRpcToParentPending.get(id);
852
+ if (resolver === undefined)
853
+ return;
854
+ postRpcToParentPending.delete(id);
855
+ resolver(data);
856
+ });
857
+ }
858
+ function postRpcToParent(method, params) {
859
+ if (typeof window === 'undefined') {
860
+ return Promise.resolve({
861
+ error: { message: 'postRpcToParent: no window' },
862
+ });
863
+ }
864
+ ensurePostRpcToParentListener();
865
+ return new Promise((resolve) => {
866
+ const id = postRpcToParentNextId;
867
+ postRpcToParentNextId += 1;
868
+ postRpcToParentPending.set(id, resolve);
869
+ try {
870
+ window.parent.postMessage({ jsonrpc: '2.0', id, method, params: params ?? {} }, '*');
871
+ }
872
+ catch (err) {
873
+ postRpcToParentPending.delete(id);
874
+ resolve({
875
+ error: {
876
+ message: err instanceof Error ? err.message : String(err),
877
+ },
878
+ });
879
+ }
880
+ });
881
+ }
882
+ /**
883
+ * Fire a single canonical action envelope (`tools/call
884
+ * ggui_runtime_submit_action`) carrying the typed `{kind, payload, …}` shape
885
+ * defined in `@ggui-ai/protocol/integrations/mcp-apps`. Every
886
+ * user-driven gesture (Pattern α/β dispatch, native-idiom anchor click,
887
+ * native-idiom fullscreen request) calls this alongside its primary
888
+ * host effect, so operators get **uniform server-side observability**
889
+ * across every gesture kind.
890
+ *
891
+ * Fail-soft: a rejected audit fire MUST NOT block the primary host
892
+ * effect. Detached-parent / host-rejected audit envelopes are
893
+ * dropped silently here; the primary effect proceeds via its own
894
+ * `postToParent` call from the caller.
895
+ */
896
+ /** @internal — exported for unit tests. */
897
+ export function emitAudit(args) {
898
+ postToParent({
899
+ jsonrpc: '2.0',
900
+ id: Math.floor(Math.random() * 1e9),
901
+ method: 'tools/call',
902
+ params: {
903
+ name: args.toolName,
904
+ arguments: {
905
+ kind: args.kind,
906
+ payload: args.payload,
907
+ sessionId: args.sessionId,
908
+ ...(args.stackItemId !== undefined
909
+ ? { stackItemId: args.stackItemId }
910
+ : {}),
911
+ appId: args.appId,
912
+ actionId: args.actionId,
913
+ firedAt: args.firedAt,
914
+ },
915
+ },
916
+ });
917
+ }
918
+ /**
919
+ * PIPE-2 response shape inspector. The host relays `tools/call` via
920
+ * postMessage; spec-compliant MCP relays return the tool's response
921
+ * either as `{result: {structuredContent: {...}}}` or `{result: {...}}`
922
+ * depending on how the host shapes the envelope. We accept both and
923
+ * read the `ok` / `code` fields the submit_action handler returns.
924
+ *
925
+ * Returns `'success'` when the handler said `ok:true`. Returns
926
+ * `'fallback'` for any other outcome — pipe missing
927
+ * (`PIPE_NOT_FOUND`), envelope rejected (`INVALID_ACTION_KIND`),
928
+ * relay error, no host relay wired, or any unexpected shape. The
929
+ * iframe-runtime then falls through to `ui/message` so the gesture
930
+ * still reaches the agent on its next turn.
931
+ */
932
+ function classifySubmitActionResponse(resp) {
933
+ if (resp === null || typeof resp !== 'object')
934
+ return 'fallback';
935
+ if ('error' in resp &&
936
+ resp.error !== undefined &&
937
+ resp.error !== null) {
938
+ return 'fallback';
939
+ }
940
+ const result = resp.result;
941
+ if (result === null || typeof result !== 'object')
942
+ return 'fallback';
943
+ const r = result;
944
+ // Spec-canonical hosts wrap tool output under `structuredContent`;
945
+ // looser hosts may surface fields directly on `result`.
946
+ const inner = r.structuredContent && typeof r.structuredContent === 'object'
947
+ ? r.structuredContent
948
+ : r;
949
+ return inner.ok === true ? 'success' : 'fallback';
950
+ }
951
+ /**
952
+ * Extract `consumerPresent` from a successful submit_action response.
953
+ * Returns the boolean if present + well-typed; `undefined` otherwise
954
+ * (server didn't wire the registry, agnostic host stripped the field,
955
+ * or any non-`ok:true` shape — callers fall back to the 10s timer
956
+ * path on `undefined`). Mirrors the structuredContent → result
957
+ * unwrap of {@link classifySubmitActionResponse} so both reads agree
958
+ * on which envelope tier carries the success payload.
959
+ */
960
+ function extractConsumerPresent(resp) {
961
+ if (resp === null || typeof resp !== 'object')
962
+ return undefined;
963
+ const result = resp.result;
964
+ if (result === null || typeof result !== 'object')
965
+ return undefined;
966
+ const r = result;
967
+ const inner = r.structuredContent && typeof r.structuredContent === 'object'
968
+ ? r.structuredContent
969
+ : r;
970
+ const flag = inner.consumerPresent;
971
+ return typeof flag === 'boolean' ? flag : undefined;
972
+ }
973
+ function showActionToast(text, kind) {
974
+ if (typeof document === 'undefined')
975
+ return;
976
+ const w = window;
977
+ if (w.__GGUI_TOAST_DISABLED__)
978
+ return;
979
+ const id = '__ggui-action-toast__';
980
+ let el = document.getElementById(id);
981
+ if (!el) {
982
+ el = document.createElement('div');
983
+ el.id = id;
984
+ el.style.cssText = [
985
+ 'position:fixed',
986
+ 'bottom:16px',
987
+ 'left:50%',
988
+ 'transform:translateX(-50%)',
989
+ 'z-index:2147483647',
990
+ 'pointer-events:auto',
991
+ 'max-width:90%',
992
+ 'padding:8px 14px',
993
+ 'border-radius:8px',
994
+ 'font:13px/1.4 system-ui,sans-serif',
995
+ 'color:#fff',
996
+ 'box-shadow:0 4px 12px rgba(0,0,0,.18)',
997
+ 'transition:opacity 180ms ease,transform 180ms ease',
998
+ 'opacity:0',
999
+ 'white-space:nowrap',
1000
+ 'overflow:hidden',
1001
+ 'text-overflow:ellipsis',
1002
+ 'cursor:pointer',
1003
+ ].join(';');
1004
+ document.body.appendChild(el);
1005
+ }
1006
+ const bg = kind === 'pending'
1007
+ ? 'rgba(60,60,68,.92)'
1008
+ : kind === 'success'
1009
+ ? 'rgba(34,139,84,.94)'
1010
+ : kind === 'fallback' || kind === 'action_required'
1011
+ ? 'rgba(110,89,165,.94)'
1012
+ : 'rgba(178,54,54,.94)';
1013
+ el.style.background = bg;
1014
+ el.textContent = text;
1015
+ el.style.opacity = '1';
1016
+ el.style.transform = 'translateX(-50%) translateY(0)';
1017
+ // Clear any prior auto-dismiss timer so a fresh pending toast
1018
+ // doesn't get hidden mid-flight.
1019
+ const elWithTimer = el;
1020
+ if (elWithTimer.__toastTimer)
1021
+ clearTimeout(elWithTimer.__toastTimer);
1022
+ // `action_required` PERSISTS until the user clicks the toast
1023
+ // (manual dismiss). Per MCP Apps spec, `ui/message` is a
1024
+ // PREPARED user prompt — the host renders it into the chat input
1025
+ // but the user must press send for it to reach the agent. So the
1026
+ // toast can't auto-dismiss; doing so would imply the gesture went
1027
+ // through when it actually requires a user follow-up.
1028
+ if (kind === 'action_required') {
1029
+ el.onclick = () => {
1030
+ if (!el)
1031
+ return;
1032
+ el.style.opacity = '0';
1033
+ el.style.transform = 'translateX(-50%) translateY(8px)';
1034
+ el.onclick = null;
1035
+ };
1036
+ }
1037
+ else {
1038
+ el.onclick = null;
1039
+ if (kind === 'success' || kind === 'fallback' || kind === 'error') {
1040
+ elWithTimer.__toastTimer = window.setTimeout(() => {
1041
+ if (!el)
1042
+ return;
1043
+ el.style.opacity = '0';
1044
+ el.style.transform = 'translateX(-50%) translateY(8px)';
1045
+ }, 2500);
1046
+ }
1047
+ }
1048
+ }
1049
+ /**
1050
+ * Dismiss the global action toast immediately. Used by the per-action
1051
+ * state machine when a `drain_ack` frame resolves the action as
1052
+ * `consumed`.
1053
+ */
1054
+ function dismissActionToast() {
1055
+ if (typeof document === 'undefined')
1056
+ return;
1057
+ const el = document.getElementById('__ggui-action-toast__');
1058
+ if (!el)
1059
+ return;
1060
+ el.style.opacity = '0';
1061
+ el.style.transform = 'translateX(-50%) translateY(8px)';
1062
+ const elWithTimer = el;
1063
+ if (elWithTimer.__toastTimer) {
1064
+ clearTimeout(elWithTimer.__toastTimer);
1065
+ elWithTimer.__toastTimer = undefined;
1066
+ }
1067
+ }
1068
+ // Drain-ack listener: the server's `drain_ack` frame carries the
1069
+ // `eventId` (= the iframe-computed `actionId`). We match on it and
1070
+ // dismiss the toast. Returning `true` short-circuits other listeners
1071
+ // (eventId is a primary key) — the pipe is the canonical data path,
1072
+ // drain_ack is the optional UI-resolution signal.
1073
+ subscribeDrainAck((payload) => {
1074
+ if (typeof payload.eventId !== 'string' || payload.eventId.length === 0) {
1075
+ return false;
1076
+ }
1077
+ dismissActionToast();
1078
+ return true;
1079
+ });
1080
+ /**
1081
+ * Build the iframe-local snapshot of every contextSpec slot value as
1082
+ * of right now. Captured atomically with the gesture so the agent
1083
+ * sees WHAT the user did AND WHAT THEY WERE LOOKING AT in one pipe
1084
+ * entry. Empty object when no slots have been registered.
1085
+ */
1086
+ function readLocalUiContext() {
1087
+ const out = {};
1088
+ for (const [slotName, value] of contextSlotLastValues) {
1089
+ out[slotName] = value;
1090
+ }
1091
+ return out;
1092
+ }
1093
+ /**
1094
+ * Emit a `ui/message` envelope tagged with
1095
+ * `_meta.ggui.userAction.kind === 'inline'`. Fires when `submit_action`
1096
+ * did NOT successfully append to the pipe (PIPE_NOT_FOUND, transport
1097
+ * error, or the host has no relay). The full action payload travels
1098
+ * inline so the agent can act WITHOUT calling `ggui_consume` for this
1099
+ * stack item (the pipe is gone).
1100
+ *
1101
+ * `nextStep` is optional — when the contract bound `actionSpec[intent]`
1102
+ * to a specific agent tool we forward that hint; absent when the
1103
+ * author left it free.
1104
+ */
1105
+ function emitUserActionInline(args) {
1106
+ // Prose-only text. Earlier revisions embedded a fenced ```json``` block
1107
+ // with the full payload so hosts that strip `_meta` could still read
1108
+ // it. That format mirrored Anthropic's tool-use JSON shape too closely
1109
+ // and tripped claude.ai's prompt-injection classifier — even on the
1110
+ // user-trusted `ui/message` channel — so the message got flagged
1111
+ // before reasoning. The fix is structural: keep structured data
1112
+ // ONLY on `_meta.ggui.userAction.payload` (where MCP Apps hosts read
1113
+ // it through the spec's trusted path) and let the text carry a
1114
+ // natural-language summary that no classifier can mistake for a
1115
+ // tool-call injection.
1116
+ const description = `User fired ${args.intent} on ${args.stackItemId}`;
1117
+ const text = `User fired "${args.intent}" on stack item ${args.stackItemId}. ` +
1118
+ `The action pipe was unavailable, so the gesture is inlined on this ` +
1119
+ `message instead of queued on the consume pipe. Use the userAction ` +
1120
+ `payload to handle it directly; do NOT call ggui_consume for this ` +
1121
+ `stack item.`;
1122
+ postToParent({
1123
+ jsonrpc: '2.0',
1124
+ id: Math.floor(Math.random() * 1e9),
1125
+ method: 'ui/message',
1126
+ params: {
1127
+ role: 'user',
1128
+ content: [{ type: 'text', text }],
1129
+ _meta: {
1130
+ ggui: {
1131
+ userAction: {
1132
+ kind: 'inline',
1133
+ description,
1134
+ stackItemId: args.stackItemId,
1135
+ actionId: args.actionId,
1136
+ submittedAt: args.submittedAt,
1137
+ intent: args.intent,
1138
+ payload: {
1139
+ actionData: args.actionData ?? null,
1140
+ uiContext: args.uiContext,
1141
+ },
1142
+ ...(args.nextStep !== undefined ? { nextStep: args.nextStep } : {}),
1143
+ },
1144
+ },
1145
+ },
1146
+ },
1147
+ });
1148
+ }
1149
+ /**
1150
+ * Emit a `ui/message` envelope tagged with
1151
+ * `_meta.ggui.userAction.kind === 'queued'`. Fires when `submit_action`
1152
+ * succeeded BUT the server reported `consumerPresent: false` — the
1153
+ * action IS on the pipe; the agent just needs to call `ggui_consume`
1154
+ * to drain it. Carries the prepared `{tool, args}` so the SDK can
1155
+ * dispatch verbatim.
1156
+ */
1157
+ function emitUserActionQueued(args) {
1158
+ // Prose-only text. Earlier revisions appended a fenced ```json``` block
1159
+ // shaped as `{"tool":"ggui_consume","args":{...}}` — a verbatim copy
1160
+ // of Anthropic's tool-call wire shape. claude.ai's prompt-injection
1161
+ // classifier flagged this even on the user-trusted `ui/message`
1162
+ // channel: data that looks like a tool-call injection IS treated as
1163
+ // one, regardless of who supplied it. The stackItemId + next-tool
1164
+ // hint stay in prose; the canonical machine-readable form lives on
1165
+ // `_meta.ggui.userAction.nextStep`.
1166
+ const description = `User fired ${args.intent} on ${args.stackItemId}`;
1167
+ const text = `User fired "${args.intent}" on stack item ${args.stackItemId}. ` +
1168
+ `The gesture is queued on the consume pipe but no consumer is active — ` +
1169
+ `call ggui_consume with this stackItemId next to drain the canonical payload.`;
1170
+ postToParent({
1171
+ jsonrpc: '2.0',
1172
+ id: Math.floor(Math.random() * 1e9),
1173
+ method: 'ui/message',
1174
+ params: {
1175
+ role: 'user',
1176
+ content: [{ type: 'text', text }],
1177
+ _meta: {
1178
+ ggui: {
1179
+ userAction: {
1180
+ kind: 'queued',
1181
+ description,
1182
+ stackItemId: args.stackItemId,
1183
+ actionId: args.actionId,
1184
+ submittedAt: args.submittedAt,
1185
+ intent: args.intent,
1186
+ nextStep: {
1187
+ tool: 'ggui_consume',
1188
+ args: { stackItemId: args.stackItemId },
1189
+ },
1190
+ },
1191
+ },
1192
+ },
1193
+ },
1194
+ });
1195
+ }
1196
+ /** @internal — exported for unit tests. */
1197
+ export function dispatchWiredAction(args) {
1198
+ if (typeof window === 'undefined')
1199
+ return;
1200
+ const { toolName, intent, data, sessionId, stackItemId, appId, nextStep } = args;
1201
+ const firedAt = new Date().toISOString();
1202
+ const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
1203
+ const inlineData = formatWiredActionDataInline(data);
1204
+ const dataPart = inlineData === '' ? '' : ` (${inlineData})`;
1205
+ const uiContext = readLocalUiContext();
1206
+ // (1) Silent context update — fires FIRST and ALWAYS. Primes the
1207
+ // LLM's widget-context surface regardless of which downstream path
1208
+ // catches the event (pipe or ui/message). Fire-and-forget; no
1209
+ // response needed.
1210
+ postToParent({
1211
+ jsonrpc: '2.0',
1212
+ id: Math.floor(Math.random() * 1e9),
1213
+ method: 'ui/update-model-context',
1214
+ params: {
1215
+ content: [
1216
+ {
1217
+ type: 'text',
1218
+ text: `[ggui:pending-action] ${JSON.stringify({
1219
+ actionId,
1220
+ intent,
1221
+ data: data ?? null,
1222
+ firedAt,
1223
+ sessionId,
1224
+ appId,
1225
+ })}`,
1226
+ },
1227
+ ],
1228
+ },
1229
+ });
1230
+ // (1.5) Toast — pending state. User sees "→ Sending: archive"
1231
+ // immediately so they know the click was registered, even when
1232
+ // submit_action's HTTP round-trip takes a moment. State updates
1233
+ // when the response (or fallback) lands.
1234
+ showActionToast(`→ ${intent}${dataPart}`, 'pending');
1235
+ // (2) Try submit_action via host relay. Spec-compliant hosts
1236
+ // forward the tools/call to the MCP server's submit_action handler;
1237
+ // the pipe entry lands with `id: actionId`. On success, branch on
1238
+ // `consumerPresent`:
1239
+ // - true (or undefined): toast stays `pending`; drain_ack will
1240
+ // dismiss it when the agent's ggui_consume drains the event.
1241
+ // - false: no consumer is registered; emit a queued userAction
1242
+ // nudge IMMEDIATELY so the agent's next turn drains the pipe.
1243
+ // No timer, no rescue — the pipe holds the data.
1244
+ //
1245
+ // (3) On any non-success outcome (PIPE_NOT_FOUND, transport error,
1246
+ // host has no relay), emit an inline userAction with the full
1247
+ // gesture payload + uiContext so the agent can act without calling
1248
+ // ggui_consume (the pipe is gone).
1249
+ void (async () => {
1250
+ let resp = null;
1251
+ try {
1252
+ resp = await postRpcToParent('tools/call', {
1253
+ name: toolName,
1254
+ arguments: {
1255
+ kind: 'dispatch',
1256
+ payload: {
1257
+ intent,
1258
+ actionData: data ?? null,
1259
+ uiContext,
1260
+ },
1261
+ sessionId,
1262
+ ...(stackItemId !== undefined ? { stackItemId } : {}),
1263
+ appId,
1264
+ actionId,
1265
+ firedAt,
1266
+ },
1267
+ });
1268
+ }
1269
+ catch {
1270
+ showActionToast(`⚠ ${intent} — transport error`, 'error');
1271
+ resp = null;
1272
+ }
1273
+ if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
1274
+ const consumerPresent = extractConsumerPresent(resp);
1275
+ if (consumerPresent === false && stackItemId !== undefined) {
1276
+ showActionToast(`💬 ${intent}${dataPart} — agent not listening, sent to chat`, 'action_required');
1277
+ emitUserActionQueued({
1278
+ intent,
1279
+ stackItemId,
1280
+ actionId,
1281
+ submittedAt: firedAt,
1282
+ });
1283
+ return;
1284
+ }
1285
+ if (stackItemId === undefined) {
1286
+ // Bootstrap didn't carry a stackItemId — no specific pipe to
1287
+ // drain. Treat as a one-shot success.
1288
+ showActionToast(`✓ ${intent} queued for agent`, 'success');
1289
+ }
1290
+ // Otherwise toast stays `pending`; drain_ack listener dismisses
1291
+ // it when ggui_consume drains the event.
1292
+ return;
1293
+ }
1294
+ // Pipe gone or transport failed. Emit inline userAction so the
1295
+ // agent can act on the gesture without calling ggui_consume.
1296
+ showActionToast(`💬 ${intent}${dataPart} — press send in chat to forward`, 'action_required');
1297
+ emitUserActionInline({
1298
+ intent,
1299
+ actionData: data ?? null,
1300
+ uiContext,
1301
+ actionId,
1302
+ stackItemId: stackItemId ?? '',
1303
+ submittedAt: firedAt,
1304
+ ...(nextStep !== undefined ? { nextStep } : {}),
1305
+ });
1306
+ })();
1307
+ }
1308
+ /**
1309
+ * Fire a wired action via Pattern α — direct `tools/call` against a
1310
+ * same-server, app-visible target tool. The iframe is allowed to fire
1311
+ * the tool directly from this server connection per MCP-Apps spec
1312
+ * §2026-01-26 visibility rules. Skips the submit_action pipe entirely:
1313
+ * the tool fires without the agent's involvement (the whole point of
1314
+ * Pattern α), chat stays clean.
1315
+ *
1316
+ * PIPE-2 design note: Pattern α deliberately does NOT fire
1317
+ * `submit_action` — a pipe append would queue the gesture for the
1318
+ * agent's `ggui_consume` long-poll, causing double-processing
1319
+ * (host's tool relay AND agent's reaction). SessionInspector loses
1320
+ * Pattern α observability for now; re-add via a dedicated audit-only
1321
+ * gesture kind if operators need it.
1322
+ *
1323
+ * @internal — exported for unit tests.
1324
+ */
1325
+ export function fireDirectToolCall(args) {
1326
+ if (typeof window === 'undefined')
1327
+ return;
1328
+ const { targetToolName, data } = args;
1329
+ // Direct tools/call — fires on the same MCP server connection the
1330
+ // iframe was bootstrapped against. Spec §2026-01-26: "app"
1331
+ // visibility tools are callable by the app from the same server
1332
+ // connection only.
1333
+ postToParent({
1334
+ jsonrpc: '2.0',
1335
+ id: Math.floor(Math.random() * 1e9),
1336
+ method: 'tools/call',
1337
+ params: {
1338
+ name: targetToolName,
1339
+ arguments: (data ?? {}),
1340
+ },
1341
+ });
1342
+ }
1343
+ /**
1344
+ * Pure routing helper for `WireConfig.dispatch` — chooses Pattern α
1345
+ * (direct `tools/call`) when the action's wired tool is in
1346
+ * `appCallableTools`, otherwise falls back to Pattern β (the 3-message
1347
+ * bridge).
1348
+ *
1349
+ * The routing logic was previously inlined as a closure body inside
1350
+ * `bootSelfContained`'s `wireConfig.dispatch`, with a verbatim
1351
+ * re-creation in `__tests__/dispatch-routing.test.ts`. Tests could
1352
+ * pass against stale code if either drifted. Extraction means
1353
+ * production + tests now exercise the same code path.
1354
+ *
1355
+ * Pattern α (same-server, app-visible target tool): chat stays clean,
1356
+ * no LLM consent loop. Pattern β (anything else: cross-server tool, no
1357
+ * wired tool, or tool not in `appCallableTools`): the canonical
1358
+ * 3-message workaround the LLM brokers when the tool isn't directly
1359
+ * callable from the iframe.
1360
+ *
1361
+ * @internal — exported for unit tests + production reuse.
1362
+ */
1363
+ export function routeDispatch(args) {
1364
+ const { actionName, data, bootstrap, dispatchToolName } = args;
1365
+ const tool = bootstrap.actionNextSteps?.[actionName];
1366
+ const appCallable = typeof tool === 'string' &&
1367
+ (bootstrap.appCallableTools ?? []).includes(tool);
1368
+ if (appCallable && tool !== undefined) {
1369
+ fireDirectToolCall({
1370
+ targetToolName: tool,
1371
+ data,
1372
+ });
1373
+ }
1374
+ else {
1375
+ // `actionNextSteps[actionName]` is the agent-side nextStep hint —
1376
+ // when present, surface it on the inline-userAction envelope so
1377
+ // the agent has a strong steer toward the right tool if the pipe
1378
+ // is unavailable. Pattern β (this branch) is also the path where
1379
+ // `actionSpec[*].dispatch.kind === 'agent'` lands — in that case
1380
+ // `actionNextSteps[actionName]` is absent and `nextStep` is too.
1381
+ dispatchWiredAction({
1382
+ toolName: dispatchToolName,
1383
+ intent: actionName,
1384
+ data,
1385
+ sessionId: bootstrap.sessionId,
1386
+ stackItemId: bootstrap.stackItemId,
1387
+ appId: bootstrap.appId,
1388
+ ...(tool !== undefined ? { nextStep: tool } : {}),
1389
+ });
1390
+ }
1391
+ }
1392
+ /**
1393
+ * `ui/open-link` direct dispatch helper — used by the anchor-click
1394
+ * interceptor. Fires a parallel `kind:'openLink'` audit so operators
1395
+ * retain uniform observability across every gesture kind.
1396
+ */
1397
+ /** @internal — exported for unit tests. */
1398
+ export function openLinkInParent(args) {
1399
+ if (typeof window === 'undefined')
1400
+ return;
1401
+ const { toolName, url, sessionId, stackItemId, appId } = args;
1402
+ if (typeof url !== 'string' || url.length === 0) {
1403
+ throw new RangeError('wire.openLink(url): `url` must be a non-empty string.');
1404
+ }
1405
+ const firedAt = new Date().toISOString();
1406
+ const actionId = fnv1aHex(`openLink|${url}|${firedAt}`);
1407
+ emitAudit({
1408
+ toolName,
1409
+ kind: 'openLink',
1410
+ payload: { url },
1411
+ sessionId,
1412
+ stackItemId,
1413
+ appId,
1414
+ actionId,
1415
+ firedAt,
1416
+ });
1417
+ postToParent({
1418
+ jsonrpc: '2.0',
1419
+ id: Math.floor(Math.random() * 1e9),
1420
+ method: 'ui/open-link',
1421
+ params: { url },
1422
+ });
1423
+ }
1424
+ /**
1425
+ * `ui/request-display-mode` direct dispatch helper — used by the
1426
+ * `Element.requestFullscreen` / `Document.exitFullscreen`
1427
+ * interceptors. Fires a parallel `kind:'requestDisplayMode'` audit so
1428
+ * operators retain uniform observability across every gesture kind.
1429
+ */
1430
+ /** @internal — exported for unit tests. */
1431
+ export function requestDisplayModeInParent(args) {
1432
+ if (typeof window === 'undefined')
1433
+ return;
1434
+ const { toolName, mode, sessionId, stackItemId, appId } = args;
1435
+ const firedAt = new Date().toISOString();
1436
+ const actionId = fnv1aHex(`requestDisplayMode|${mode}|${firedAt}`);
1437
+ emitAudit({
1438
+ toolName,
1439
+ kind: 'requestDisplayMode',
1440
+ payload: { mode },
1441
+ sessionId,
1442
+ stackItemId,
1443
+ appId,
1444
+ actionId,
1445
+ firedAt,
1446
+ });
1447
+ postToParent({
1448
+ jsonrpc: '2.0',
1449
+ id: Math.floor(Math.random() * 1e9),
1450
+ method: 'ui/request-display-mode',
1451
+ params: { mode },
1452
+ });
1453
+ }
1454
+ /**
1455
+ * Native-idiom interceptor for anchor clicks.
1456
+ *
1457
+ * Install a capture-phase `click` listener on `document` that traps
1458
+ * clicks targeting an `<a href>` whose href is an external (cross-
1459
+ * origin OR `target="_blank"`) http(s) URL. Intercepted clicks are
1460
+ * routed through {@link openLinkInParent} — full audit envelope +
1461
+ * `ui/open-link` postMessage. Generated components use plain
1462
+ * `<a href="https://example.com" target="_blank">`; the runtime
1463
+ * intercepts the click and routes it to the host.
1464
+ *
1465
+ * Decision rules (DOM event → routing):
1466
+ * - No anchor in ancestor chain → skip (nothing to do).
1467
+ * - `event.defaultPrevented` → skip (component handler already
1468
+ * consumed the click; respect its choice).
1469
+ * - href starts with `#` → skip (same-document fragment; preserve
1470
+ * bookmark / scroll-into-view behavior).
1471
+ * - href scheme is not `http(s):` → skip (`mailto:`, `tel:`,
1472
+ * `javascript:`, `data:` are out of the spec's domain).
1473
+ * - href origin === `window.location.origin` AND `target !== '_blank'`
1474
+ * → skip (in-frame navigation; preserve SPA links).
1475
+ * - Otherwise → preventDefault + fire {@link openLinkInParent}.
1476
+ *
1477
+ * Capture phase is load-bearing: component-defined `onClick` handlers
1478
+ * may call `preventDefault()`, which would mask the native intercept
1479
+ * if the listener were on the bubble phase. By running first, we
1480
+ * either intercept (then component handlers see `defaultPrevented`)
1481
+ * or honor an upstream `defaultPrevented` from a higher-priority
1482
+ * handler.
1483
+ *
1484
+ * Idempotent: re-mounts (via `installPostMountListener`) call this
1485
+ * helper again; the {@link anchorClickInterceptInstalled} guard makes
1486
+ * subsequent calls a no-op.
1487
+ */
1488
+ /** @internal — exported for unit tests. */
1489
+ export function installAnchorClickInterceptor(args) {
1490
+ if (anchorClickInterceptInstalled)
1491
+ return;
1492
+ if (typeof window === 'undefined')
1493
+ return;
1494
+ if (typeof document === 'undefined')
1495
+ return;
1496
+ anchorClickInterceptInstalled = true;
1497
+ const { dispatchToolName, sessionId, stackItemId, appId } = args;
1498
+ const onClick = (event) => {
1499
+ if (event.defaultPrevented)
1500
+ return;
1501
+ const target = event.target;
1502
+ if (target === null || !(target instanceof Element))
1503
+ return;
1504
+ const link = target.closest('a[href]');
1505
+ if (link === null || !(link instanceof HTMLAnchorElement))
1506
+ return;
1507
+ // `link.href` is the BROWSER-RESOLVED absolute URL — relative
1508
+ // hrefs become absolute against the document base, scheme-only
1509
+ // hrefs (`mailto:`, `tel:`) stay verbatim. The raw `getAttribute`
1510
+ // is what matters for fragment detection (resolution would
1511
+ // produce a same-document URL with the fragment fused on).
1512
+ const rawHref = link.getAttribute('href');
1513
+ if (rawHref === null)
1514
+ return;
1515
+ if (rawHref.startsWith('#'))
1516
+ return;
1517
+ const absoluteHref = link.href;
1518
+ let parsedUrl;
1519
+ try {
1520
+ parsedUrl = new URL(absoluteHref);
1521
+ }
1522
+ catch {
1523
+ // Unparseable href → not our concern.
1524
+ return;
1525
+ }
1526
+ // Only http(s). `mailto:`, `tel:`, `javascript:`, `data:` etc.
1527
+ // fall through to the browser's default handling.
1528
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
1529
+ return;
1530
+ }
1531
+ const targetAttr = link.getAttribute('target');
1532
+ const isBlank = targetAttr === '_blank';
1533
+ const isCrossOrigin = parsedUrl.origin !== window.location.origin;
1534
+ if (!isBlank && !isCrossOrigin) {
1535
+ // Same-origin in-frame navigation — preserve SPA-style links.
1536
+ return;
1537
+ }
1538
+ event.preventDefault();
1539
+ openLinkInParent({
1540
+ toolName: dispatchToolName,
1541
+ url: absoluteHref,
1542
+ sessionId,
1543
+ stackItemId,
1544
+ appId,
1545
+ });
1546
+ };
1547
+ // Capture phase so we run BEFORE component-defined onClicks.
1548
+ document.addEventListener('click', onClick, { capture: true });
1549
+ }
1550
+ /**
1551
+ * Native-idiom interceptor for the Fullscreen API.
1552
+ *
1553
+ * Override `Element.prototype.requestFullscreen` and
1554
+ * `Document.prototype.exitFullscreen` so generated components calling
1555
+ * either go through {@link requestDisplayModeInParent} (fires the
1556
+ * `ui/request-display-mode` postMessage + paired audit envelope).
1557
+ *
1558
+ * The native fullscreen API would not work from inside the iframe
1559
+ * regardless — sandboxed iframes need an explicit
1560
+ * `allow="fullscreen"` permission, and even when granted the host
1561
+ * (claude.ai, ggui demo shell) typically owns the chrome. Routing
1562
+ * through the parent host lets it decide the actual presentation
1563
+ * (true fullscreen, modal, expanded panel).
1564
+ *
1565
+ * Both overrides return `Promise.resolve()` so callers using the
1566
+ * standard `.then()` / `await` form don't break. The native call is
1567
+ * NOT delegated — there's no useful behavior to preserve and a real
1568
+ * native fullscreen attempt would race with the host's handling.
1569
+ *
1570
+ * Mode mapping:
1571
+ * - `requestFullscreen()` → `mode: 'fullscreen'`
1572
+ * - `exitFullscreen()` → `mode: 'inline'` (symmetric inverse
1573
+ * in the existing `requestDisplayModeInParent` mode union).
1574
+ *
1575
+ * Note: `'pip'` is YAGNI for v1. There's no clean native API for
1576
+ * arbitrary-content Picture-in-Picture (Document PiP exists but is
1577
+ * Chromium-only and requires an explicit container handoff); we
1578
+ * surface the postMessage `mode: 'pip'` only when a future native
1579
+ * idiom emerges.
1580
+ *
1581
+ * Idempotent: the {@link fullscreenInterceptInstalled} guard prevents
1582
+ * re-mounts from chaining wrappers (which would also leak the prior
1583
+ * mount's `sessionId`/`appId` if they ever differed).
1584
+ */
1585
+ /** @internal — exported for unit tests. */
1586
+ export function installFullscreenInterceptors(args) {
1587
+ if (fullscreenInterceptInstalled)
1588
+ return;
1589
+ if (typeof window === 'undefined')
1590
+ return;
1591
+ if (typeof Element === 'undefined' || typeof Document === 'undefined') {
1592
+ return;
1593
+ }
1594
+ fullscreenInterceptInstalled = true;
1595
+ const { dispatchToolName, sessionId, stackItemId, appId } = args;
1596
+ Element.prototype.requestFullscreen = function (_options) {
1597
+ requestDisplayModeInParent({
1598
+ toolName: dispatchToolName,
1599
+ mode: 'fullscreen',
1600
+ sessionId,
1601
+ stackItemId,
1602
+ appId,
1603
+ });
1604
+ return Promise.resolve();
1605
+ };
1606
+ Document.prototype.exitFullscreen = function () {
1607
+ requestDisplayModeInParent({
1608
+ toolName: dispatchToolName,
1609
+ mode: 'inline',
1610
+ sessionId,
1611
+ stackItemId,
1612
+ appId,
1613
+ });
1614
+ return Promise.resolve();
1615
+ };
1616
+ }
1617
+ /**
1618
+ * @internal — exposed for tests to reset module-level interceptor
1619
+ * guards between specs. Production code never calls this; resetting
1620
+ * during a real iframe lifecycle would cause stacked click listeners
1621
+ * (the prior listener is NOT removed by the reset, only forgotten).
1622
+ */
1623
+ export function __resetInterceptorsForTest() {
1624
+ anchorClickInterceptInstalled = false;
1625
+ fullscreenInterceptInstalled = false;
1626
+ }
1627
+ /**
1628
+ * Install a persistent `message` listener that catches
1629
+ * `ui/notifications/tool-result` notifications arriving AFTER the
1630
+ * initial mount. Each new tool-result that carries a different
1631
+ * bootstrap (different stackItemId / codeUrl / kind) triggers a
1632
+ * re-mount via {@link bootSelfContained}. This closes the boot-only-
1633
+ * listener gap that prevented live re-render when an agent issued
1634
+ * a second `ggui_push` to the same session-resource.
1635
+ *
1636
+ * Idempotent: subsequent calls no-op via {@link postMountListenerInstalled}.
1637
+ *
1638
+ * Why module-level vs scoped to a single mount: bootSelfContained
1639
+ * may itself trigger a re-mount, and the listener should outlive any
1640
+ * single mount cycle. The compaction ensures we don't stack listeners
1641
+ * across re-mounts.
1642
+ */
1643
+ function installPostMountListener() {
1644
+ if (postMountListenerInstalled)
1645
+ return;
1646
+ if (typeof window === 'undefined')
1647
+ return;
1648
+ postMountListenerInstalled = true;
1649
+ let lastBootstrapKey = null;
1650
+ const onMessage = (event) => {
1651
+ const data = event.data;
1652
+ if (data === null ||
1653
+ data === undefined ||
1654
+ data.jsonrpc !== '2.0' ||
1655
+ data.method !== 'ui/notifications/tool-result') {
1656
+ return;
1657
+ }
1658
+ const bootstrap = extractBootstrapFromToolResult(data.params);
1659
+ if (bootstrap === null)
1660
+ return;
1661
+ // Cheap dedupe — the host may emit the same tool-result more
1662
+ // than once (claude.ai re-broadcasts on iframe re-attach).
1663
+ // Re-mounting the same bootstrap would flicker without changing
1664
+ // anything visible.
1665
+ //
1666
+ // `liveTrio` is load-bearing in the dedupe key: hosts (sample-agent,
1667
+ // claude.ai) often emit the initial bootstrap WITHOUT the wsUrl+token
1668
+ // pair (the Anthropic SDK strips `_meta` from tool results), then
1669
+ // refetch + re-emit the FULL envelope. The two envelopes share
1670
+ // stackItemId/kind/codeUrl/propsJson but differ on the live trio —
1671
+ // without trio in the key, the second arrival deduped silently and
1672
+ // bootSelfContained never opened the WS, so `ggui_update` props_update
1673
+ // frames fanned to zero subscribers.
1674
+ const codeUrl = 'codeUrl' in bootstrap ? bootstrap.codeUrl : undefined;
1675
+ const liveTrio = typeof bootstrap.wsUrl === 'string' &&
1676
+ bootstrap.wsUrl.length > 0 &&
1677
+ typeof bootstrap.token === 'string' &&
1678
+ bootstrap.token.length > 0
1679
+ ? 'live'
1680
+ : '-';
1681
+ const key = [
1682
+ bootstrap.stackItemId ?? '-',
1683
+ bootstrap.kind ?? '-',
1684
+ codeUrl ?? '-',
1685
+ bootstrap.propsJson ?? '-',
1686
+ liveTrio,
1687
+ ].join('|');
1688
+ if (key === lastBootstrapKey)
1689
+ return;
1690
+ lastBootstrapKey = key;
1691
+ void bootSelfContained(window.document, bootstrap).then(() => {
1692
+ // Re-emit last-known contextSpec values after a successful
1693
+ // re-mount. The host's WS to the agent server is opaque to the
1694
+ // iframe, so "WS reopen ≈ re-mount" is the closest available
1695
+ // proxy. Re-emitting keeps the LLM context fresh after a
1696
+ // host-driven reconnect — the new mount's SingleSlotProviders
1697
+ // will then take over via the regular debounced flow once the
1698
+ // user's component begins mutating values.
1699
+ //
1700
+ // Filter to slot names declared by the FRESHLY mounted contract.
1701
+ // An earlier version walked the entire `contextSlotLastValues`
1702
+ // map, which leaked stale slot values across re-mounts with
1703
+ // different contextSpecs. The new mount's SingleSlotProvider
1704
+ // re-seeds from `slot.default` regardless, so cross-mount
1705
+ // survival of stale entries was never load-bearing — only the
1706
+ // active slot names are.
1707
+ const activeSlotNames = new Set((bootstrap.contextSlots ?? []).map((s) => s.name));
1708
+ const reemitIdentity = bootstrap.stackItemId !== undefined
1709
+ ? {
1710
+ sessionId: bootstrap.sessionId,
1711
+ appId: bootstrap.appId,
1712
+ stackItemId: bootstrap.stackItemId,
1713
+ }
1714
+ : undefined;
1715
+ reemitLastContextValues(postToParent, activeSlotNames, reemitIdentity);
1716
+ });
1717
+ };
1718
+ window.addEventListener('message', onMessage);
1719
+ }
1720
+ /**
1721
+ * Self-contained boot path. Mounts the compiled component into
1722
+ * `<div id="ggui-root">` (or a fresh container when absent) using the
1723
+ * same `mountReactRoot` pipeline the WS-driven path uses. No postMessage,
1724
+ * no WebSocket, no subscribe — the runtime is fully self-sufficient
1725
+ * once the bootstrap is in hand.
1726
+ *
1727
+ * Status surface: a parent-bound `ggui:renderer-ready` followed by a
1728
+ * `code-ready` lifecycle envelope on success. Failures emit a
1729
+ * `ggui:bootstrap-failed` envelope (`SELF_CONTAINED_MOUNT_FAILED`) +
1730
+ * an `error` lifecycle event so hosts pinning lifecycle selectors
1731
+ * still observe the terminal state.
1732
+ *
1733
+ * The implementation lives next to `bootProduction` so dynamic-imports
1734
+ * for the heavy module graph happen exactly once across both code
1735
+ * paths — the postMessage path's `import('react')` etc. resolves
1736
+ * against the same module instance.
1737
+ */
1738
+ async function bootSelfContained(doc, bootstrap) {
1739
+ // Lifecycle: `mounting` first, paired with `ggui:renderer-ready` so
1740
+ // outer-DOM observers see the same sequence as the postMessage path.
1741
+ postRendererReady();
1742
+ postLifecycleToParent(makeLifecycleEvent('mounting'));
1743
+ // Resolve the mount container. Hosts inline `<div id="ggui-root">`
1744
+ // in the self-contained shell; if absent (defensive), append one to
1745
+ // the body so the React root has somewhere to mount.
1746
+ let container = doc.getElementById('ggui-root');
1747
+ if (container === null) {
1748
+ container = doc.createElement('div');
1749
+ container.id = 'ggui-root';
1750
+ doc.body.appendChild(container);
1751
+ }
1752
+ try {
1753
+ // Install precompiled, eval-free contract validators before any
1754
+ // wire dispatch can run. Self-contained ESM modules — no bundler /
1755
+ // `__ggui__` dependency. System-card bootstraps carry none (the
1756
+ // loader returns the empty set); harmless to call unconditionally.
1757
+ setActiveValidatorSet(await loadCompiledValidators(bootstrap.compiledValidators));
1758
+ // Parse propsJson up-front — both branches (system card + compiled
1759
+ // component) consume props from the same field, just in different
1760
+ // shapes downstream.
1761
+ let props;
1762
+ if (bootstrap.propsJson !== undefined) {
1763
+ try {
1764
+ const parsed = JSON.parse(bootstrap.propsJson);
1765
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
1766
+ props = parsed;
1767
+ }
1768
+ }
1769
+ catch {
1770
+ // Malformed propsJson is shape-preserving-skip.
1771
+ }
1772
+ }
1773
+ // System-card branch — kind keyed against the built-in registry.
1774
+ // No componentCode evaluation, no `globalThis.__ggui__` shim install,
1775
+ // no GguiWireProvider (system cards are render-only and don't
1776
+ // dispatch wire actions). The registry handles unknown kinds via
1777
+ // a typed fallback, so a new server emitting a kind this runtime
1778
+ // doesn't know still surfaces something visible.
1779
+ if (bootstrap.kind !== undefined) {
1780
+ const [reactMod, reactDomClient, systemCardsMod] = await Promise.all([
1781
+ import('react'),
1782
+ import('react-dom/client'),
1783
+ import('./system-cards/index.js'),
1784
+ ]);
1785
+ const root = reactDomClient.createRoot(container);
1786
+ root.render(reactMod.createElement(systemCardsMod.SystemCardHost, { kind: bootstrap.kind, props: props ?? {}, themeId: bootstrap.themeId }));
1787
+ postLifecycleToParent(makeLifecycleEvent('code-ready', {
1788
+ ...(bootstrap.stackItemId !== undefined
1789
+ ? { stackItemId: bootstrap.stackItemId }
1790
+ : {}),
1791
+ }));
1792
+ installResizeObserver(container);
1793
+ installPostMountListener();
1794
+ return;
1795
+ }
1796
+ // Compiled-component branch. Fetch the bytes from `codeUrl`
1797
+ // (content-addressable, immutable cache). T3-1 (2026-05-13) retired
1798
+ // the inline base64 `componentCode` channel — every static-component
1799
+ // bootstrap is delivered via the URL.
1800
+ if (bootstrap.codeUrl === undefined) {
1801
+ throw new Error('bootSelfContained: bootstrap missing codeUrl (static-component mode requires the URL channel)');
1802
+ }
1803
+ const res = await fetch(bootstrap.codeUrl);
1804
+ if (!res.ok) {
1805
+ throw new Error(`bootSelfContained: codeUrl fetch failed (${res.status}): ${bootstrap.codeUrl}`);
1806
+ }
1807
+ const componentCode = await res.text();
1808
+ // Dynamic-imports — same set as `bootProduction`, kept here so a
1809
+ // postMessage path running in parallel hits the module cache.
1810
+ const [reactMod, reactDomClient, designPrimitives, designComponents, designCompositions, designInteract, designTokens, wireMod, gadgetsMod,] = await Promise.all([
1811
+ import('react'),
1812
+ import('react-dom/client'),
1813
+ import('@ggui-ai/design/primitives'),
1814
+ import('@ggui-ai/design/components'),
1815
+ import('@ggui-ai/design/compositions'),
1816
+ import('@ggui-ai/design/interact'),
1817
+ import('@ggui-ai/design/tokens'),
1818
+ import('@ggui-ai/wire'),
1819
+ // Pre-load STDLIB gadget hooks so the data-URL shim resolves
1820
+ // `import { useGeolocation } from '@ggui-ai/gadgets'` to a real
1821
+ // callable at iframe boot. Without this, `__ggui__.gadgets` is
1822
+ // empty and every hook lookup returns undefined, crashing the
1823
+ // component.
1824
+ import('@ggui-ai/gadgets'),
1825
+ ]);
1826
+ const [globalsMod, reactRendererMod, gadgetLoaderMod] = await Promise.all([
1827
+ import('./globals.js'),
1828
+ import('./react-renderer.js'),
1829
+ import('./gadget-loader.js'),
1830
+ ]);
1831
+ // Compose the gadget-package registry: STDLIB seed PLUS any
1832
+ // operator-registered packages from the bootstrap. This path is
1833
+ // async, so we await the merge BEFORE installing — the per-package
1834
+ // data-URL shims see a fully-populated catalog from first render.
1835
+ // (The WS-driven path uses an in-place mutation fallback because
1836
+ // its `setup` callback is synchronous.)
1837
+ const composedGadgets = await gadgetLoaderMod.loadGadgetRegistry(gadgetsMod, bootstrap.gadgets ?? []);
1838
+ // Install `globalThis.__ggui__` BEFORE mountReactRoot — the data-
1839
+ // URL shim rewrite reads it synchronously during module load.
1840
+ // Same TOCTOU-critical ordering as the WS-driven path.
1841
+ globalsMod.installGlobalRegistry({
1842
+ react: reactMod,
1843
+ reactDom: reactDomClient,
1844
+ primitives: designPrimitives,
1845
+ components: designComponents,
1846
+ compositions: designCompositions,
1847
+ interact: designInteract,
1848
+ tokens: designTokens,
1849
+ wire: wireMod,
1850
+ // Per-package gadget registry — STDLIB namespace merged with
1851
+ // operator-registered packages (composed above).
1852
+ gadgets: composedGadgets,
1853
+ // Server-filtered public env values for wrapper hooks to read
1854
+ // via `getPublicEnv(key)`. Absent ⇒ empty record; wrappers
1855
+ // needing values throw at hook-mount.
1856
+ publicEnv: bootstrap.publicEnv ?? {},
1857
+ });
1858
+ // Synthesize one React.createContext(default) per declared
1859
+ // contextSpec slot and register under
1860
+ // `globalThis.__ggui__.contexts[contextName]`. Idempotent: already-
1861
+ // registered context names REUSE the existing Context (the LLM's
1862
+ // destructured references must stay stable across re-mounts). Boot-
1863
+ // ordering: AFTER installGlobalRegistry, BEFORE mountReactRoot, so
1864
+ // the boilerplate's `globalThis.__ggui__.contexts` destructure
1865
+ // resolves on the first mount paint.
1866
+ const registry = globalsMod.getGlobalRegistry();
1867
+ const resolvedSlots = registry !== undefined && bootstrap.contextSlots !== undefined
1868
+ ? installContextRegistry(registry.contexts, reactMod, bootstrap.contextSlots)
1869
+ : [];
1870
+ // Build a minimal WireConfig and pass it through `renderWrapper` so
1871
+ // generated components that call `useAction` / `useStream` resolve
1872
+ // their context. Without this, every self-contained mount whose
1873
+ // component touches `@ggui-ai/wire` throws "useWireContext must be
1874
+ // used within a WireProvider".
1875
+ //
1876
+ // Dispatch funnels through {@link dispatchWiredAction} — the
1877
+ // empirically-validated three-message bridge (`tools/call` +
1878
+ // `ui/update-model-context` + `ui/message`). See helper docstring
1879
+ // for the spec §401 / §1032 reasoning. An earlier single-message
1880
+ // dispatch only fired #1 and the LLM never saw the click on
1881
+ // claude.ai (the host-side scope:['app'] firewall blocks
1882
+ // tool-result feedback from reaching the model). Empirically
1883
+ // confirmed in a protocol probe.
1884
+ const dispatchToolName = typeof window !== 'undefined' &&
1885
+ typeof window.__GGUI_DISPATCH_TOOL__ === 'string'
1886
+ ? window
1887
+ .__GGUI_DISPATCH_TOOL__
1888
+ : 'ggui_runtime_submit_action';
1889
+ const wireConfig = {
1890
+ app: { appId: bootstrap.appId, appName: bootstrap.appId },
1891
+ session: { sessionId: bootstrap.sessionId, isConnected: true },
1892
+ auth: { isAuthenticated: false },
1893
+ dispatch: (actionName, data) => {
1894
+ // Per-action routing — extracted to {@link routeDispatch} as
1895
+ // a pure helper so production + the dispatch-routing unit
1896
+ // tests share one code path. Pattern α: same-server,
1897
+ // app-visible target tool → direct `tools/call`. Pattern β:
1898
+ // anything else → 3-message bridge.
1899
+ routeDispatch({
1900
+ actionName,
1901
+ data,
1902
+ bootstrap: {
1903
+ sessionId: bootstrap.sessionId,
1904
+ appId: bootstrap.appId,
1905
+ ...(bootstrap.stackItemId !== undefined
1906
+ ? { stackItemId: bootstrap.stackItemId }
1907
+ : {}),
1908
+ ...(bootstrap.appCallableTools !== undefined
1909
+ ? { appCallableTools: bootstrap.appCallableTools }
1910
+ : {}),
1911
+ ...(bootstrap.actionNextSteps !== undefined
1912
+ ? { actionNextSteps: bootstrap.actionNextSteps }
1913
+ : {}),
1914
+ },
1915
+ dispatchToolName,
1916
+ });
1917
+ },
1918
+ // Subscribe + wired-tools are no-ops in self-contained mode
1919
+ // (no live channel). Generated components that subscribe
1920
+ // receive nothing; the contract stays honest.
1921
+ subscribe: () => () => { },
1922
+ // `callWiredTool` is retired — `agentTools` is now a catalog
1923
+ // the AGENT invokes, not a component hook surface.
1924
+ // The `openLink` and `requestDisplayMode` host-control primitives
1925
+ // ride native idioms (anchor click + Element.requestFullscreen) —
1926
+ // see `installAnchorClickInterceptor` and
1927
+ // `installFullscreenInterceptors` below. No first-class
1928
+ // chat-shortcut primitive in v1; chat-shortcut UX degrades to the
1929
+ // Pattern β consent prompt.
1930
+ };
1931
+ // Runtime owns Provider tree + useState per slot. ContextStateHost
1932
+ // composes one SingleSlotProvider per declared slot around the
1933
+ // user's component, so the user's `useGguiContext` reads each
1934
+ // slot's live `[value, setValue]` tuple. An earlier design had
1935
+ // the boilerplate emit useState + Provider INSIDE the user
1936
+ // component while the runtime mounted observers as SIBLINGS —
1937
+ // every observer read the createContext default, never the live
1938
+ // state. Hoisting both useState and Provider into the runtime
1939
+ // makes the boilerplate's destructure line resolve to the live
1940
+ // tuple by construction.
1941
+ const ContextStateHost = createContextStateHost({
1942
+ react: reactMod,
1943
+ postToParent,
1944
+ consoleWarn: typeof console !== 'undefined' && typeof console.warn === 'function'
1945
+ ? console.warn.bind(console)
1946
+ : undefined,
1947
+ ...(bootstrap.stackItemId !== undefined
1948
+ ? {
1949
+ identity: {
1950
+ sessionId: bootstrap.sessionId,
1951
+ appId: bootstrap.appId,
1952
+ stackItemId: bootstrap.stackItemId,
1953
+ },
1954
+ }
1955
+ : {}),
1956
+ });
1957
+ const renderWrapper = (mountedComponent) => reactMod.createElement(wireMod.GguiWireProvider, {
1958
+ config: wireConfig,
1959
+ children: reactMod.createElement(ContextStateHost, {
1960
+ slots: resolvedSlots,
1961
+ children: mountedComponent,
1962
+ }),
1963
+ });
1964
+ // Native-idiom interceptors. Install BEFORE mountReactRoot so the
1965
+ // listeners are live the instant any generated component begins
1966
+ // rendering. Both helpers are idempotent (module-level guards) —
1967
+ // re-mounts via `installPostMountListener` re-call this without
1968
+ // stacking.
1969
+ installAnchorClickInterceptor({
1970
+ dispatchToolName,
1971
+ sessionId: bootstrap.sessionId,
1972
+ ...(bootstrap.stackItemId !== undefined
1973
+ ? { stackItemId: bootstrap.stackItemId }
1974
+ : {}),
1975
+ appId: bootstrap.appId,
1976
+ });
1977
+ installFullscreenInterceptors({
1978
+ dispatchToolName,
1979
+ sessionId: bootstrap.sessionId,
1980
+ ...(bootstrap.stackItemId !== undefined
1981
+ ? { stackItemId: bootstrap.stackItemId }
1982
+ : {}),
1983
+ appId: bootstrap.appId,
1984
+ });
1985
+ const mountOpts = {
1986
+ stackItem: {
1987
+ ...(bootstrap.stackItemId !== undefined ? { id: bootstrap.stackItemId } : {}),
1988
+ componentCode,
1989
+ ...(props !== undefined ? { props } : {}),
1990
+ },
1991
+ renderWrapper,
1992
+ ...(bootstrap.themeId !== undefined ? { themeId: bootstrap.themeId } : {}),
1993
+ ...(bootstrap.themeMode !== undefined ? { themeMode: bootstrap.themeMode } : {}),
1994
+ // GG.8.2 — operator-registered 3rd-party gadget packages so the
1995
+ // rewriter resolves each direct gadget import to its per-package
1996
+ // shim. STDLIB `@ggui-ai/gadgets` is always rewritten regardless.
1997
+ ...(bootstrap.gadgets !== undefined
1998
+ ? { gadgetPackages: bootstrap.gadgets.map((g) => g.package) }
1999
+ : {}),
2000
+ };
2001
+ const mount = await reactRendererMod.mountReactRoot(container, mountOpts);
2002
+ postLifecycleToParent(makeLifecycleEvent('code-ready', {
2003
+ ...(bootstrap.stackItemId !== undefined
2004
+ ? { stackItemId: bootstrap.stackItemId }
2005
+ : {}),
2006
+ }));
2007
+ installResizeObserver(container);
2008
+ installPostMountListener();
2009
+ // B4 — when the bootstrap carries a live trio (wsUrl + token),
2010
+ // open a WS subscription so `ggui_update` props_update frames
2011
+ // reach the iframe. Without this the McpAppIframe-nested mount
2012
+ // never subscribed and `ggui_update` silently dropped on the
2013
+ // server's outbound side.
2014
+ //
2015
+ // Targeted scope: this path mounts a SINGLE component (not the
2016
+ // full stack), so the only frame type we need is `props_update`
2017
+ // matching THIS stackItemId. Other frame types are silently
2018
+ // dropped — no handler registered means the registry's dispatch
2019
+ // is a no-op for them.
2020
+ //
2021
+ // The subscribe call is fire-and-forget; failure is logged but
2022
+ // doesn't break the mount (the static component renders fine
2023
+ // without live updates). When the WS goes down, mount.update()
2024
+ // simply stops firing — gracefully degrades.
2025
+ if (typeof bootstrap.wsUrl === 'string' &&
2026
+ bootstrap.wsUrl.length > 0 &&
2027
+ typeof bootstrap.token === 'string' &&
2028
+ bootstrap.token.length > 0 &&
2029
+ bootstrap.stackItemId !== undefined) {
2030
+ const targetStackItemId = bootstrap.stackItemId;
2031
+ const subscribeRegistry = new ChannelRegistry({
2032
+ subscribeFrameBuilder: () => ({
2033
+ type: 'subscribe',
2034
+ payload: {
2035
+ sessionId: bootstrap.sessionId,
2036
+ appId: bootstrap.appId,
2037
+ bootstrap: bootstrap.token,
2038
+ },
2039
+ }),
2040
+ });
2041
+ // Register only the `props_update` handler — `connectViaRegistry`
2042
+ // registers `ack` + `error` for the handshake. Other frame
2043
+ // types arriving on the WS will be no-ops (no handler).
2044
+ subscribeRegistry.register({
2045
+ type: 'props_update',
2046
+ onMessage: (payload) => {
2047
+ const shaped = payload;
2048
+ if (shaped.stackItemId !== targetStackItemId)
2049
+ return;
2050
+ if (shaped.props === null || typeof shaped.props !== 'object')
2051
+ return;
2052
+ void mount.update({
2053
+ ...mountOpts,
2054
+ stackItem: {
2055
+ ...mountOpts.stackItem,
2056
+ props: shaped.props,
2057
+ },
2058
+ });
2059
+ },
2060
+ });
2061
+ void connectViaRegistry({
2062
+ bootstrap,
2063
+ registry: subscribeRegistry,
2064
+ onStatusChange: () => {
2065
+ /* no-op — placeholder UI is not used in self-contained mode */
2066
+ },
2067
+ }).catch((err) => {
2068
+ // Self-contained mode tolerates WS failure — the component
2069
+ // still renders with initial props; only live updates stop.
2070
+ // eslint-disable-next-line no-console -- operator-visible degradation hint
2071
+ console.warn('[ggui:bootSelfContained] live-update WS subscribe failed; props_update frames will not reach the mount:', err instanceof Error ? err.message : String(err));
2072
+ });
2073
+ }
2074
+ }
2075
+ catch (err) {
2076
+ const message = err instanceof Error ? err.message : String(err);
2077
+ // Reuse the existing failure envelope. The reason is a synthesized
2078
+ // value because this code path is wholly outside the
2079
+ // `BootstrapParseFailureReason` set (no postMessage parse failed
2080
+ // — there was no postMessage). Hosts pattern-matching legacy
2081
+ // reason strings see a new value and ignore it; new hosts can
2082
+ // route on the explicit code.
2083
+ postBootFailure('UI_INITIALIZE_FAILED', `self-contained mount failed: ${message}`);
2084
+ postLifecycleToParent(makeLifecycleEvent('error', {
2085
+ error: { code: 'SELF_CONTAINED_MOUNT_FAILED', message },
2086
+ }));
2087
+ }
2088
+ }
2089
+ /**
2090
+ * Detect a `GguiBootstrapMeta` shape inlined onto `__GGUI_BOOTSTRAP__`.
2091
+ * The first-party session shells (`/s/<shortCode>`,
2092
+ * `ui://ggui/session/<sessionId>`, the embedded-ui SessionViewer's
2093
+ * thin shell) populate this synchronously before the runtime loads —
2094
+ * but the shape is the WS-driven bootstrap (`wsUrl`+`token`+
2095
+ * `sessionId`+`appId`+`runtimeUrl`), NOT a `SelfContainedBootstrap`.
2096
+ *
2097
+ * This predicate exists so the autostart path can distinguish
2098
+ * "shell-inlined a WS bootstrap, run `bootProduction` immediately"
2099
+ * from "no bootstrap yet, race the tool-result postMessage with a
2100
+ * 30s timeout". Without the distinction, OSS shells that never emit
2101
+ * a separate `ui/notifications/tool-result` (because the bootstrap
2102
+ * is delivered via the `ui/initialize` Reading-B path) hang at
2103
+ * `mounting` for the full 30s before `code-ready`.
2104
+ */
2105
+ function readGguiBootstrapMetaShape() {
2106
+ if (typeof window === 'undefined')
2107
+ return false;
2108
+ const raw = window
2109
+ .__GGUI_BOOTSTRAP__;
2110
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
2111
+ return false;
2112
+ }
2113
+ const bag = raw;
2114
+ return (typeof bag['wsUrl'] === 'string' &&
2115
+ bag['wsUrl'].length > 0 &&
2116
+ typeof bag['token'] === 'string' &&
2117
+ bag['token'].length > 0 &&
2118
+ typeof bag['sessionId'] === 'string' &&
2119
+ bag['sessionId'].length > 0 &&
2120
+ typeof bag['appId'] === 'string' &&
2121
+ bag['appId'].length > 0);
2122
+ }
2123
+ /**
2124
+ * Hand off to `bootProduction` with the standard wiring (postRendererReady
2125
+ * + postBootFailure + observability + lifecycle). Extracted so both the
2126
+ * GguiBootstrapMeta-inlined fast path and the post-tool-result-timeout
2127
+ * fallback share one call site — keeps the WS-driven boot semantics
2128
+ * single-sourced.
2129
+ */
2130
+ function runBootProduction() {
2131
+ const callUiInitialize = () => {
2132
+ const caller = makeJsonRpcCaller();
2133
+ // Per MCP Apps spec (specification/2026-01-26/apps.mdx:554-563),
2134
+ // ui/initialize params are { appInfo, appCapabilities,
2135
+ // protocolVersion } — all three required. Spec-compliant
2136
+ // hosts (claude.ai's validator) reject empty params with
2137
+ // "Invalid input" for each missing field.
2138
+ return caller('ui/initialize', {
2139
+ appInfo: { name: 'ggui-iframe-runtime', version: '1.0.0' },
2140
+ // Declare the display modes ggui supports so spec-compliant
2141
+ // hosts know `ui/request-display-mode` requests for these
2142
+ // values are honored. The runtime emits `request-display-mode`
2143
+ // from the `Element.requestFullscreen` interceptor + the
2144
+ // canvas-mode display-mode escalation policy; both target this
2145
+ // enum.
2146
+ appCapabilities: {
2147
+ availableDisplayModes: ['inline', 'fullscreen', 'pip'],
2148
+ },
2149
+ protocolVersion: '2026-01-26',
2150
+ });
2151
+ };
2152
+ void bootProduction({
2153
+ doc: document,
2154
+ callUiInitialize,
2155
+ notifyParent: (msg) => {
2156
+ if (msg.type === 'ggui:renderer-ready') {
2157
+ postRendererReady();
2158
+ return;
2159
+ }
2160
+ postBootFailure(msg.reason, msg.message);
2161
+ },
2162
+ onObserve: postObservabilityToParent,
2163
+ onLifecycle: postLifecycleToParent,
2164
+ });
2165
+ }
2166
+ if (shouldAutostart() && typeof window !== 'undefined') {
2167
+ // Boot-source resolution, in priority order. Each tier is a
2168
+ // distinct way the host can deliver a {@link SelfContainedBootstrap}
2169
+ // to the runtime; downstream `bootSelfContained` is the one mount
2170
+ // path for all of them.
2171
+ //
2172
+ // 1. Inline `__GGUI_BOOTSTRAP__` global — per-session shells
2173
+ // (`/r/<shortCode>`, `ui://ggui/session/<sessionId>`) populate
2174
+ // this synchronously before the runtime loads. Zero round-
2175
+ // trip; no postMessage.
2176
+ //
2177
+ // 2. Buffered `__GGUI_PENDING_TOOL_RESULTS__` — minimal-shell
2178
+ // pattern. The shell installs a postMessage listener at
2179
+ // first-paint, buffers any `ui/notifications/tool-result`
2180
+ // arrivals into the array, and the runtime drains on load.
2181
+ // Covers the case where the host posts the tool-result
2182
+ // BEFORE the runtime bundle finishes parsing.
2183
+ //
2184
+ // 3. Live postMessage listener — `awaitToolResultBootstrap`
2185
+ // catches tool-results that arrive AFTER the runtime is
2186
+ // ready (the more common case on Claude Desktop, where the
2187
+ // iframe loads before the agent's tool call resolves).
2188
+ //
2189
+ // 4. `bootProduction` (legacy `ui/initialize` round-trip + WS
2190
+ // subscribe) — first-party hosts (Studio, Portal, console)
2191
+ // that drive the live-channel path. Kept as the terminal
2192
+ // fallback for backwards compatibility.
2193
+ //
2194
+ // The architectural goal: ALL bootstrap-shape narrowing happens
2195
+ // inside the runtime (this file). Shells never validate, mount,
2196
+ // or transform — they only deliver. That's the cure for the
2197
+ // "shell-side validator lagged the protocol" bug class.
2198
+ // T3-1 (2026-05-13) — bootSelfContained requires static content
2199
+ // (codeUrl or kind) to mount. Live-only bootstraps (wsUrl+token
2200
+ // without static content) MUST go through `bootProduction` so the
2201
+ // stack item arrives via the live-channel WS subscribe. Pre-T3-1 the
2202
+ // inline `componentCode` channel made every push static, so
2203
+ // bootSelfContained always had something to mount; post-drop, we
2204
+ // dispatch on the discriminator explicitly.
2205
+ const inline = readSelfContainedBootstrap();
2206
+ const inlineHasStatic = inline !== null
2207
+ && (typeof inline.codeUrl === 'string'
2208
+ || typeof inline.kind === 'string');
2209
+ if (inline !== null && inlineHasStatic) {
2210
+ void bootSelfContained(document, inline);
2211
+ }
2212
+ else if (inline !== null) {
2213
+ // Live-only inline bootstrap — hand to bootProduction so it
2214
+ // re-issues ui/initialize and walks the WS path.
2215
+ runBootProduction();
2216
+ }
2217
+ else {
2218
+ const buffered = readPendingToolResults();
2219
+ const bufferedHasStatic = buffered !== null
2220
+ && (typeof buffered.codeUrl === 'string'
2221
+ || typeof buffered.kind === 'string');
2222
+ if (buffered !== null && bufferedHasStatic) {
2223
+ void bootSelfContained(document, buffered);
2224
+ }
2225
+ else if (buffered !== null) {
2226
+ runBootProduction();
2227
+ }
2228
+ else {
2229
+ // Pre-empt the postMessage tool-result race when `__GGUI_BOOTSTRAP__`
2230
+ // already carries a `GguiBootstrapMeta` (wsUrl + token + sessionId
2231
+ // + appId) — that shape isn't a `SelfContainedBootstrap`, so
2232
+ // `readSelfContainedBootstrap` returned null, but it IS the
2233
+ // signal that a first-party shell (`/s/<shortCode>`,
2234
+ // `ui://ggui/session/<sessionId>`) has already inlined the WS-
2235
+ // driven boot envelope. Skip the 30s tool-result wait and hand
2236
+ // off to `bootProduction` directly — `bootProduction` re-issues
2237
+ // `ui/initialize` to the host, which re-emits the same bootstrap
2238
+ // back via Reading-B, and the WS path proceeds without delay.
2239
+ // Without this short-circuit, the OSS embedded-ui (which never
2240
+ // sends a separate `ui/notifications/tool-result`) hangs at
2241
+ // `mounting` for the full 30s timeout before `code-ready`.
2242
+ const hasGguiBootstrapMeta = readGguiBootstrapMetaShape();
2243
+ if (hasGguiBootstrapMeta) {
2244
+ runBootProduction();
2245
+ }
2246
+ else {
2247
+ // Race a postMessage listener against the legacy production
2248
+ // boot. Tool-result wins if it arrives within the timeout;
2249
+ // otherwise we hand off to the WS-driven path.
2250
+ const POSTMESSAGE_BOOT_TIMEOUT_MS = 30_000;
2251
+ void awaitToolResultBootstrap(POSTMESSAGE_BOOT_TIMEOUT_MS).then((postMessageBootstrap) => {
2252
+ const hasStatic = postMessageBootstrap !== null
2253
+ && (typeof postMessageBootstrap.codeUrl === 'string'
2254
+ || typeof postMessageBootstrap.kind === 'string');
2255
+ if (postMessageBootstrap !== null && hasStatic) {
2256
+ void bootSelfContained(document, postMessageBootstrap);
2257
+ return;
2258
+ }
2259
+ runBootProduction();
2260
+ });
2261
+ }
2262
+ }
2263
+ }
2264
+ }
2265
+ async function bootProduction(opts) {
2266
+ // Dynamic-import the heavy module graph. Done here rather than at
2267
+ // top-level so spec files importing runtime.ts for `bootSequence`
2268
+ // don't pay the React + design + wire + preview-a2ui import cost.
2269
+ const [reactMod, reactDomClient, designPrimitives, designComponents, designCompositions, designInteract, designTokens, wireMod, gadgetsMod] = await Promise.all([
2270
+ import('react'),
2271
+ import('react-dom/client'),
2272
+ import('@ggui-ai/design/primitives'),
2273
+ import('@ggui-ai/design/components'),
2274
+ import('@ggui-ai/design/compositions'),
2275
+ import('@ggui-ai/design/interact'),
2276
+ import('@ggui-ai/design/tokens'),
2277
+ import('@ggui-ai/wire'),
2278
+ // STDLIB gadget hooks. See the `bootSelfContained` callsite
2279
+ // above for the rationale.
2280
+ import('@ggui-ai/gadgets'),
2281
+ ]);
2282
+ const [globalsMod, gadgetLoaderMod] = await Promise.all([
2283
+ import('./globals.js'),
2284
+ import('./gadget-loader.js'),
2285
+ ]);
2286
+ // Triad wiring hook — constructs buses + stack renderer + wire
2287
+ // config on demand inside bootSequence.
2288
+ const triadWiring = {
2289
+ setup: ({ bootstrap, stackModel, renderInto, statusRefs, onObserve, onCanvasLifecycle }) => {
2290
+ // Compose the gadget registry: STDLIB seed PLUS any
2291
+ // operator-registered wrappers carried on the bootstrap.
2292
+ // Awaited inside the synchronous `setup` callback via an IIFE
2293
+ // because the boot orchestration calls `setup` synchronously and
2294
+ // we can't change its contract here; the wrappers dynamic-import
2295
+ // is fast (already-resolved bundles hit the module cache).
2296
+ //
2297
+ // For now we fall back to the STDLIB-only seed if the bootstrap
2298
+ // omits `gadgets`. The install happens BEFORE any stack
2299
+ // item renders so `rewrite-imports.ts` reads a fully-populated
2300
+ // registry on the first `loadModule` call.
2301
+ const composedGadgets = gadgetLoaderMod
2302
+ // Empty registrations → resolves to a Promise of the STDLIB
2303
+ // seed only; the boot path doesn't wait on wrapper imports.
2304
+ .loadGadgetRegistry(gadgetsMod, bootstrap.gadgets ?? []);
2305
+ // Install with the synchronous STDLIB seed first; the WS-driven
2306
+ // boot path doesn't await wrapper loads here (the registry
2307
+ // install is TOCTOU-critical and synchronous). The Promise's
2308
+ // resolved value replaces `__ggui__.gadgets` in-place
2309
+ // once dynamic imports finish.
2310
+ globalsMod.installGlobalRegistry({
2311
+ react: reactMod,
2312
+ reactDom: reactDomClient,
2313
+ primitives: designPrimitives,
2314
+ components: designComponents,
2315
+ compositions: designCompositions,
2316
+ interact: designInteract,
2317
+ tokens: designTokens,
2318
+ wire: wireMod,
2319
+ // Per-package gadget registry — the STDLIB namespace under its
2320
+ // package key lands synchronously; 3rd-party packages merge in
2321
+ // via the Promise below.
2322
+ gadgets: { '@ggui-ai/gadgets': gadgetsMod },
2323
+ // Public env values from the bootstrap. The WS-driven path
2324
+ // receives bootstrap synchronously via
2325
+ // setup({bootstrap, ...}) so installing here is correct.
2326
+ publicEnv: bootstrap.publicEnv ?? {},
2327
+ });
2328
+ // Merge 3rd-party package namespaces into the LIVE slot object —
2329
+ // not a slot replacement. The per-package data-URL shims read
2330
+ // `__ggui__.gadgets[package]` lazily at call/render time, so
2331
+ // mutating the live slot's contents (adding package keys)
2332
+ // propagates. Replacing the slot reference would NOT.
2333
+ void composedGadgets.then((merged) => {
2334
+ const reg = globalsMod.getGlobalRegistry();
2335
+ if (reg === undefined)
2336
+ return;
2337
+ const live = reg.gadgets;
2338
+ for (const [key, value] of Object.entries(merged)) {
2339
+ if (!(key in live))
2340
+ live[key] = value;
2341
+ }
2342
+ });
2343
+ // Install the React Context registry + build a ContextStateHost
2344
+ // so the WS-driven shells (Studio, Portal, OSS console)
2345
+ // participate in contextSpec the same way `bootSelfContained`
2346
+ // does. Without this the WS-driven path installed neither —
2347
+ // `globalThis.__ggui__.contexts` stayed empty, the boilerplate's
2348
+ // destructure resolved to `undefined`, and any declared
2349
+ // `contextSpec` slot was silently dead. The registry entries
2350
+ // seed default values; the ContextStateHost (one
2351
+ // SingleSlotProvider per declared slot) hoists useState into the
2352
+ // runtime so `useGguiContext(slot)` reads the live tuple.
2353
+ const registry = globalsMod.getGlobalRegistry();
2354
+ const resolvedSlots = registry !== undefined && bootstrap.contextSlots !== undefined
2355
+ ? installContextRegistry(registry.contexts, reactMod, bootstrap.contextSlots)
2356
+ : [];
2357
+ const ContextStateHost = createContextStateHost({
2358
+ react: reactMod,
2359
+ postToParent,
2360
+ consoleWarn: typeof console !== 'undefined' && typeof console.warn === 'function'
2361
+ ? console.warn.bind(console)
2362
+ : undefined,
2363
+ ...(bootstrap.stackItemId !== undefined
2364
+ ? {
2365
+ identity: {
2366
+ sessionId: bootstrap.sessionId,
2367
+ appId: bootstrap.appId,
2368
+ stackItemId: bootstrap.stackItemId,
2369
+ },
2370
+ }
2371
+ : {}),
2372
+ });
2373
+ const streamBus = new StreamBus();
2374
+ // Buffered send shim — the real WS handle isn't available until
2375
+ // `connectViaRegistry` resolves. Frames sent pre-ack are
2376
+ // buffered and flushed by `attachManager` when the handle
2377
+ // lands. Mirrors the pre-B3b BufferedManagerShim posture; the
2378
+ // rename clarifies that this is now a pure send-surface, not a
2379
+ // full manager class.
2380
+ const buffered = [];
2381
+ let realManager = null;
2382
+ const manager = {
2383
+ send: (msg) => {
2384
+ if (realManager !== null) {
2385
+ realManager.send(msg);
2386
+ return;
2387
+ }
2388
+ buffered.push(msg);
2389
+ },
2390
+ __attachReal(real) {
2391
+ realManager = real;
2392
+ while (buffered.length > 0) {
2393
+ const msg = buffered.shift();
2394
+ if (msg !== undefined)
2395
+ real.send(msg);
2396
+ }
2397
+ },
2398
+ };
2399
+ const { config: rootConfig, buildScopedConfig } = buildRootWireConfig({
2400
+ sessionId: bootstrap.sessionId,
2401
+ appId: bootstrap.appId,
2402
+ getStack: () => stackModel.snapshot(),
2403
+ manager,
2404
+ streamBus,
2405
+ ...(onObserve !== undefined ? { onObserve } : {}),
2406
+ });
2407
+ // containerFor: mint a `<div data-ggui-stack-item-root="<id>">`
2408
+ // child inside `renderInto` for each stack item id. Reuse on
2409
+ // re-apply.
2410
+ const containersById = new Map();
2411
+ const containerFor = (id) => {
2412
+ const existing = containersById.get(id);
2413
+ if (existing !== undefined)
2414
+ return existing;
2415
+ const el = renderInto.ownerDocument.createElement('div');
2416
+ el.setAttribute('data-ggui-stack-item-root', id);
2417
+ renderInto.appendChild(el);
2418
+ containersById.set(id, el);
2419
+ return el;
2420
+ };
2421
+ const stackCtx = {
2422
+ containerFor,
2423
+ getScopedWireConfig: (item) => {
2424
+ // item.type === 'mcpApps' stack items get NO wire config —
2425
+ // their iframe host has its own contract (adapter-boundary
2426
+ // rule). Component items scope via `buildScopedConfig`
2427
+ // (see `packages/wire/src/context.ts`). `contractHash` is
2428
+ // intentionally absent — it lives on the event envelope,
2429
+ // not on the stack item shape.
2430
+ if (item.type === 'mcpApps' || item.type === 'system')
2431
+ return null;
2432
+ return buildScopedConfig({
2433
+ stackItemId: item.id,
2434
+ ...(item.actionSpec !== undefined ? { actionSpec: item.actionSpec } : {}),
2435
+ });
2436
+ },
2437
+ // Wrap every per-item React mount in
2438
+ // `<ContextStateHost slots={resolvedSlots}>` so contextSpec
2439
+ // values flow through `ui/update-model-context` exactly like
2440
+ // the self-contained path. mcpApps + system items skip the
2441
+ // wrap (their renderers don't run user component code that
2442
+ // reads contexts). When `resolvedSlots` is empty
2443
+ // ContextStateHost short-circuits to a Fragment, so the wrap
2444
+ // is free for items with no contextSpec.
2445
+ getOuterWrapper: (item) => {
2446
+ if (item.type === 'mcpApps' || item.type === 'system')
2447
+ return undefined;
2448
+ return (mountedTree) => reactMod.createElement(ContextStateHost, {
2449
+ slots: resolvedSlots,
2450
+ children: mountedTree,
2451
+ });
2452
+ },
2453
+ streamBus,
2454
+ sessionId: bootstrap.sessionId,
2455
+ };
2456
+ const stackRenderer = new StackRenderer(stackCtx);
2457
+ // Validator context — A2UI default for `_ggui:preview`; no
2458
+ // bootstrap-supplied overrides today (the
2459
+ // `extraReservedValidators` injection slot is reserved for a
2460
+ // future extension).
2461
+ const validatorCtx = {
2462
+ reservedValidators: mergeReservedValidators(undefined, undefined),
2463
+ };
2464
+ // Per-channel transport router. Created here so it
2465
+ // shares the buffered manager shim (and survives the
2466
+ // pre-attachManager send buffering) + the same StreamBus the
2467
+ // wire config emits onto. The router consults
2468
+ // `bootstrap.streamWebSocketLocalTools` to decide WS-subscribe
2469
+ // vs iframe-polling per channel; absent ⇒ universal polling
2470
+ // fallback. Activated lazily by `handleTriadMessage`'s push
2471
+ // case (which calls `channelTransport.applyStackItem` on every
2472
+ // stack-fold).
2473
+ const channelTransport = createChannelTransportRouter({
2474
+ sessionId: bootstrap.sessionId,
2475
+ appId: bootstrap.appId,
2476
+ ...(bootstrap.streamWebSocketLocalTools !== undefined
2477
+ ? {
2478
+ streamWebSocketLocalTools: bootstrap.streamWebSocketLocalTools,
2479
+ }
2480
+ : {}),
2481
+ send: (msg) => manager.send(msg),
2482
+ toolsCall: async ({ toolName, args }) => {
2483
+ // Iframe-polling transport — `tools/call` over the parent
2484
+ // MCP host's JSON-RPC channel. Pattern α direct call
2485
+ // (no LLM consent loop). Returns the tool's
2486
+ // structuredContent (or `content[0]` if that's where the
2487
+ // payload landed) as a JsonValue. On RPC error we throw —
2488
+ // the router catches and silently retries on the next tick.
2489
+ const resp = await postRpcToParent('tools/call', {
2490
+ name: toolName,
2491
+ arguments: args,
2492
+ });
2493
+ if (resp.error !== undefined) {
2494
+ throw new Error(resp.error.message ?? 'tools/call failed');
2495
+ }
2496
+ const result = resp.result;
2497
+ if (result === null || typeof result !== 'object') {
2498
+ return null;
2499
+ }
2500
+ const structured = result.structuredContent;
2501
+ if (structured !== undefined)
2502
+ return structured;
2503
+ return result;
2504
+ },
2505
+ streamBus,
2506
+ ...(onObserve !== undefined
2507
+ ? {
2508
+ // Forward each channel-transport event verbatim — the
2509
+ // `ObservabilityEvent` union has dedicated branches for
2510
+ // the three channel-transport kinds (picked / fallback
2511
+ // / resubscribed), so consumers (host inspector)
2512
+ // receive typed events without needing a cast.
2513
+ onObserve,
2514
+ }
2515
+ : {}),
2516
+ });
2517
+ // B3b — channel-client registry owns dispatch for every routable
2518
+ // WS frame type. Each handler closes over the triad state it
2519
+ // needs. `bootSequence` calls `connectFn` (default
2520
+ // `connectViaRegistry`) which registers the `ack` + `error`
2521
+ // handshake handlers and binds the WS transport — frames then
2522
+ // arrive directly through the registered handlers without an
2523
+ // intermediate `onMessage` fan-out.
2524
+ //
2525
+ // CLIENT_SUPPORTED_VERSIONS handshake is enforced inside
2526
+ // `connectViaRegistry`'s ack-handler closure (NOT here).
2527
+ const channelRegistry = new ChannelRegistry({
2528
+ subscribeFrameBuilder: () => ({
2529
+ type: 'subscribe',
2530
+ payload: {
2531
+ sessionId: bootstrap.sessionId,
2532
+ appId: bootstrap.appId,
2533
+ ...(bootstrap.token !== undefined
2534
+ ? { bootstrap: bootstrap.token }
2535
+ : {}),
2536
+ },
2537
+ }),
2538
+ });
2539
+ channelRegistry.register(createPushHandler({
2540
+ stackModel,
2541
+ statusRefs,
2542
+ getStackRenderer: () => stackRenderer,
2543
+ getChannelTransport: () => channelTransport,
2544
+ }));
2545
+ channelRegistry.register(createDataHandler({
2546
+ stackModel,
2547
+ streamBus,
2548
+ validatorCtx,
2549
+ ...(onObserve !== undefined ? { onObserve } : {}),
2550
+ // Canvas-mode lifecycle envelopes
2551
+ // → AnimatorEventBus publish (via the sink built in
2552
+ // bootSequence from the canvas mount handle).
2553
+ ...(onCanvasLifecycle !== undefined
2554
+ ? { onCanvasLifecycle }
2555
+ : {}),
2556
+ }));
2557
+ channelRegistry.register(createPropsUpdateHandler({
2558
+ stackModel,
2559
+ getStackRenderer: () => stackRenderer,
2560
+ // Server stamps `pollingUrl` on the bootstrap envelope when the
2561
+ // bootstrap is minted via `/api/bootstrap/<shortCode>` — this is
2562
+ // the URL the iframe-runtime's `PollingTransport` fetches when
2563
+ // the WebSocket transport is unavailable (host blocks `wss://`)
2564
+ // or fails irrecoverably (FailoverHandle swap). Without this
2565
+ // wired, polling has no URL and live updates silently no-op
2566
+ // outside of WS hosts.
2567
+ ...(bootstrap.pollingUrl !== undefined
2568
+ ? { pollingUrl: bootstrap.pollingUrl }
2569
+ : {}),
2570
+ }));
2571
+ channelRegistry.register(createDrainAckHandler({ dispatch: dispatchDrainAck }));
2572
+ channelRegistry.register(createFeedbackHandler());
2573
+ channelRegistry.register(createChannelPayloadHandler({
2574
+ getChannelTransport: () => channelTransport,
2575
+ }));
2576
+ channelRegistry.register(createChannelErrorHandler({
2577
+ getChannelTransport: () => channelTransport,
2578
+ }));
2579
+ channelRegistry.register(createSystemHandler({
2580
+ ...(onObserve !== undefined ? { onObserve } : {}),
2581
+ }));
2582
+ return {
2583
+ rootWireConfig: rootConfig,
2584
+ streamBus,
2585
+ stackRenderer,
2586
+ validatorCtx,
2587
+ manager,
2588
+ channelTransport,
2589
+ channelRegistry,
2590
+ };
2591
+ },
2592
+ attachManager: (handle, realManager) => {
2593
+ // handle.manager is the buffered shim created in setup().
2594
+ const shim = handle.manager;
2595
+ if (typeof shim.__attachReal === 'function') {
2596
+ shim.__attachReal(realManager);
2597
+ }
2598
+ },
2599
+ teardown: (handle) => {
2600
+ handle.stackRenderer.unmountAll();
2601
+ // Tear down per-channel polling timers + clear subscription
2602
+ // registry. No-op when no channel was ever activated.
2603
+ handle.channelTransport.dispose();
2604
+ },
2605
+ };
2606
+ // Canvas-mode mount hook. Constructed
2607
+ // here because React + the canvas modules are already dynamic-
2608
+ // imported above; bootSequence calls it lazily only when the parsed
2609
+ // bootstrap carries `canvasMode: true`. Non-canvas sessions never
2610
+ // pay the canvas-mount cost; canvas sessions never re-import React
2611
+ // separately.
2612
+ const canvasMountMod = await import('./canvas/mount.js');
2613
+ const canvasMount = ({ mountTarget, callbacks }) => canvasMountMod.mountCanvas(mountTarget, { react: reactMod, reactDomClient }, callbacks);
2614
+ await bootSequence({
2615
+ doc: opts.doc,
2616
+ callUiInitialize: opts.callUiInitialize,
2617
+ notifyParent: opts.notifyParent,
2618
+ triadWiring,
2619
+ canvasMount,
2620
+ ...(opts.onObserve !== undefined ? { onObserve: opts.onObserve } : {}),
2621
+ ...(opts.onLifecycle !== undefined ? { onLifecycle: opts.onLifecycle } : {}),
2622
+ });
2623
+ }