@ggui-ai/iframe-runtime 0.1.0-rc.3 → 0.2.0-alpha.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 (83) hide show
  1. package/README.md +7 -6
  2. package/dist/channel-transport.d.ts +15 -15
  3. package/dist/channel-transport.js +32 -35
  4. package/dist/channels/channel-payload.d.ts +2 -2
  5. package/dist/channels/channel-payload.js +1 -1
  6. package/dist/channels/data.d.ts +14 -19
  7. package/dist/channels/data.js +13 -31
  8. package/dist/channels/drain-ack.d.ts +2 -2
  9. package/dist/channels/drain-ack.js +1 -1
  10. package/dist/channels/feedback.d.ts +1 -1
  11. package/dist/channels/index.d.ts +1 -1
  12. package/dist/channels/index.js +1 -1
  13. package/dist/channels/props-update.d.ts +33 -37
  14. package/dist/channels/props-update.js +30 -106
  15. package/dist/channels/render-frame.d.ts +52 -0
  16. package/dist/channels/render-frame.js +53 -0
  17. package/dist/channels/system.d.ts +1 -1
  18. package/dist/compiled-validators.d.ts +15 -0
  19. package/dist/compiled-validators.js +31 -0
  20. package/dist/context-observer.d.ts +68 -8
  21. package/dist/context-observer.js +25 -45
  22. package/dist/events-polling.d.ts +95 -0
  23. package/dist/events-polling.js +143 -0
  24. package/dist/gadget-loader.d.ts +1 -1
  25. package/dist/globals.d.ts +4 -4
  26. package/dist/host-context-emitter.d.ts +84 -20
  27. package/dist/host-context-emitter.js +159 -29
  28. package/dist/iframe-runtime.js +98 -59
  29. package/dist/index.d.ts +18 -13
  30. package/dist/index.js +1 -1
  31. package/dist/lifecycle.d.ts +2 -2
  32. package/dist/lifecycle.js +5 -5
  33. package/dist/mcp-app-iframe-host.d.ts +9 -9
  34. package/dist/mcp-app-iframe-host.js +16 -17
  35. package/dist/meta-parse.d.ts +44 -0
  36. package/dist/meta-parse.js +390 -0
  37. package/dist/observability.d.ts +6 -6
  38. package/dist/protocol-error.d.ts +7 -7
  39. package/dist/provisional-renderer.d.ts +1 -1
  40. package/dist/provisional-renderer.js +1 -1
  41. package/dist/react-renderer.d.ts +10 -11
  42. package/dist/react-renderer.js +8 -8
  43. package/dist/registry-subscribe.d.ts +27 -9
  44. package/dist/registry-subscribe.js +32 -27
  45. package/dist/render-item.d.ts +83 -0
  46. package/dist/{stack-item-renderer.js → render-item.js} +106 -106
  47. package/dist/runtime.d.ts +183 -131
  48. package/dist/runtime.js +1264 -1330
  49. package/dist/status-dom.d.ts +52 -47
  50. package/dist/status-dom.js +51 -34
  51. package/dist/system-cards/NoCredentialsCard.d.ts +1 -1
  52. package/dist/system-cards/NoCredentialsCard.js +1 -1
  53. package/dist/system-cards/ProtocolProbeCard.js +2 -2
  54. package/dist/system-cards/index.d.ts +4 -4
  55. package/dist/system-cards/index.js +4 -4
  56. package/dist/types.d.ts +70 -34
  57. package/dist/types.js +1 -1
  58. package/dist/validation.d.ts +2 -2
  59. package/dist/validation.js +2 -2
  60. package/dist/wire-config.d.ts +54 -82
  61. package/dist/wire-config.js +61 -93
  62. package/package.json +9 -8
  63. package/dist/bootstrap.d.ts +0 -72
  64. package/dist/bootstrap.js +0 -500
  65. package/dist/canvas/animator/animator-host.d.ts +0 -63
  66. package/dist/canvas/animator/animator-host.js +0 -105
  67. package/dist/canvas/animator/animator-pill.d.ts +0 -42
  68. package/dist/canvas/animator/animator-pill.js +0 -119
  69. package/dist/canvas/animator/state-machine.d.ts +0 -106
  70. package/dist/canvas/animator/state-machine.js +0 -150
  71. package/dist/canvas/canvas-shell.d.ts +0 -77
  72. package/dist/canvas/canvas-shell.js +0 -117
  73. package/dist/canvas/display-mode-policy.d.ts +0 -104
  74. package/dist/canvas/display-mode-policy.js +0 -98
  75. package/dist/canvas/mount.d.ts +0 -150
  76. package/dist/canvas/mount.js +0 -202
  77. package/dist/canvas/nav-stack.d.ts +0 -133
  78. package/dist/canvas/nav-stack.js +0 -172
  79. package/dist/channels/push.d.ts +0 -47
  80. package/dist/channels/push.js +0 -54
  81. package/dist/stack-item-renderer.d.ts +0 -103
  82. package/dist/stack.d.ts +0 -90
  83. package/dist/stack.js +0 -118
package/dist/runtime.js CHANGED
@@ -1,15 +1,17 @@
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';
1
+ import { parseMetaFromGlobal, parseMetaFromToolResult, } from './meta-parse.js';
2
+ import { projectHostContext } from '@ggui-ai/protocol';
3
+ import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
4
+ import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
4
5
  import { buildRootWireConfig, StreamBus, } from './wire-config.js';
5
6
  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';
7
+ import { ChannelRegistry } from '@ggui-ai/live-channel';
8
+ import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createFeedbackHandler, createPropsUpdateHandler, createRenderHandler, createSystemHandler, } from './channels/index.js';
8
9
  import { connectViaRegistry, } from './registry-subscribe.js';
9
- import { ensureStatusDom, refreshStackDom, setStatus, setConnectedStatus, } from './status-dom.js';
10
+ import { buildEventsPolling } from './events-polling.js';
11
+ import { ensureStatusDom, setStatus, setConnectedStatus, } from './status-dom.js';
10
12
  import { mergeReservedValidators, setActiveValidatorSet, } from './validation.js';
11
- import { loadCompiledValidators } from './compiled-validators.js';
12
- import { StackRenderer, } from './stack-item-renderer.js';
13
+ import { loadCompiledValidatorsFromUrl } from './compiled-validators.js';
14
+ import { mountRender, } from './render-item.js';
13
15
  import { fromBootstrapFailure, } from './protocol-error.js';
14
16
  import { postObservabilityToParent, } from './observability.js';
15
17
  import { makeLifecycleEvent, postLifecycleToParent, } from './lifecycle.js';
@@ -23,6 +25,19 @@ import { contextSlotLastValues, createContextStateHost, installContextRegistry,
23
25
  // substitute via esbuild `define` if a non-package value is wanted.
24
26
  // =============================================================================
25
27
  const RENDERER_VERSION = '0.1.0';
28
+ /**
29
+ * How long the spec-canonical postMessage tier (Tier 3 in
30
+ * `bootSequence`'s resolver chain + the autostart resolver's race
31
+ * against `bootProduction`) waits for an inbound
32
+ * `ui/notifications/tool-result` notification before falling through.
33
+ *
34
+ * 30 seconds chosen to span the slowest spec-compliant hosts that
35
+ * post the tool result LATE (after their `ui/initialize` response
36
+ * resolves + after the tool call round-trips through the LLM
37
+ * provider). Faster hosts return immediately on arrival; the timeout
38
+ * only fires when the host never posts at all.
39
+ */
40
+ const POSTMESSAGE_BOOT_TIMEOUT_MS = 30_000;
26
41
  const drainAckListeners = new Set();
27
42
  export function subscribeDrainAck(listener) {
28
43
  drainAckListeners.add(listener);
@@ -41,56 +56,106 @@ function dispatchDrainAck(payload) {
41
56
  }
42
57
  }
43
58
  }
59
+ // =============================================================================
60
+ // App-class plumbing (Phase 1.19b.3). The renderer used to roll its own
61
+ // JSON-RPC pump (a closure-scoped `makeJsonRpcCaller` for the single
62
+ // `ui/initialize` request + a module-level `ensurePostRpcToParentListener`
63
+ // for `tools/call` and a `installPostMountListener` for inbound
64
+ // `ui/notifications/tool-result`). Every one of those was a hand-rolled
65
+ // reimplementation of a primitive `@modelcontextprotocol/ext-apps`'s
66
+ // `App` class already ships — drift across them produced the Reading-B
67
+ // vs spec-canonical schism we spent the postMessage tier-3 resolver
68
+ // patching around. Post-1.19b.3 every host-iframe message hops through
69
+ // one `App` instance.
70
+ //
71
+ // Two-piece construction:
72
+ // - `createDefaultApp` builds an `App` + `PostMessageTransport` against
73
+ // `window.parent`. Production wires this; tests inject their own.
74
+ // - `connectApp` runs `App.connect(transport)` and surfaces failures
75
+ // as the typed `ConnectAppResult` discriminated union (since App
76
+ // throws and we want bootSequence's `UI_INITIALIZE_FAILED` envelope
77
+ // to surface a string `message` without `instanceof` gymnastics).
78
+ // =============================================================================
79
+ const APP_INFO = { name: 'ggui-iframe-runtime', version: RENDERER_VERSION };
80
+ /**
81
+ * Available display modes the iframe-runtime supports — declared on
82
+ * `ui/initialize` so spec-compliant hosts know `ui/request-display-mode`
83
+ * requests for these values are honored. The runtime emits
84
+ * `ui/request-display-mode` from the `Element.requestFullscreen`
85
+ * interceptor + the canvas-mode display-mode escalation policy; both
86
+ * target this enum.
87
+ */
88
+ const APP_CAPABILITIES = {
89
+ availableDisplayModes: ['inline', 'fullscreen', 'pip'],
90
+ };
91
+ /**
92
+ * Build the default production `App` + `PostMessageTransport` pair
93
+ * targeting `window.parent`. Tests inject their own; production calls
94
+ * this lazily so test files that import this module without running
95
+ * the autostart side-effect don't construct an iframe-bound App.
96
+ */
97
+ function createDefaultApp() {
98
+ const app = new App(APP_INFO, APP_CAPABILITIES, { autoResize: true });
99
+ const transport = new PostMessageTransport(window.parent, window.parent);
100
+ return { app, transport };
101
+ }
44
102
  /**
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).
103
+ * Module-level connected App handle. Set by `bootProduction` /
104
+ * `bootSelfContained` once `app.connect()` resolves; consumed by the
105
+ * outbound dispatch path (`tools/call` via `app.callServerTool`) so
106
+ * spec-compliant frame routing is used in place of the legacy
107
+ * hand-rolled JSON-RPC pump.
50
108
  *
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.
109
+ * `null` between the moment the module loads and the moment one of
110
+ * the boot paths assigns it — `dispatchWiredAction`'s callsites are
111
+ * gated on this, so calls fired pre-boot drop with a console warning.
55
112
  */
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
- });
113
+ let currentApp = null;
114
+ /**
115
+ * Assign the module-level App handle. Replaces any previous handle —
116
+ * production runs bootSequence exactly once per iframe lifecycle, so
117
+ * a replace only happens in tests that reuse the module across
118
+ * scenarios (and want each spec to bind its own App).
119
+ *
120
+ * @internal — production callers are the boot paths
121
+ * (`bootProduction`, `bootSelfContained`); tests inject directly to
122
+ * drive outbound `tools/call` through a `MockTransport`-bound App
123
+ * without invoking the full boot pipeline.
124
+ */
125
+ export function setCurrentApp(app) {
126
+ currentApp = app;
127
+ }
128
+ /**
129
+ * Read the connected App handle. Returns `null` when neither boot
130
+ * path has run yet. Outbound dispatch sites that depend on it MUST
131
+ * handle the null case (typically by logging + dropping the call).
132
+ */
133
+ function getCurrentApp() {
134
+ return currentApp;
135
+ }
136
+ /**
137
+ * @internal — exported for unit tests to reset module state between
138
+ * scenarios.
139
+ */
140
+ export function __resetAppForTest() {
141
+ currentApp = null;
142
+ }
143
+ /**
144
+ * Connect an App over its transport; map any thrown error to the
145
+ * `ConnectAppResult` shape so callers don't need an `instanceof Error`
146
+ * dance to fill `UI_INITIALIZE_FAILED`.
147
+ */
148
+ async function connectApp(app, transport) {
149
+ try {
150
+ await app.connect(transport);
151
+ return { ok: true };
152
+ }
153
+ catch (err) {
154
+ return {
155
+ ok: false,
156
+ message: err instanceof Error ? err.message : String(err),
157
+ };
158
+ }
94
159
  }
95
160
  /**
96
161
  * Notify the parent that the renderer is alive + ready to receive
@@ -123,8 +188,9 @@ function postBootFailure(reason, message) {
123
188
  }
124
189
  }
125
190
  export async function bootSequence(opts) {
126
- const { doc, callUiInitialize, notifyParent, triadWiring, onProtocolError, onObserve, onLifecycle } = opts;
191
+ const { doc, app, transport, notifyParent, renderer: rendererHooks, onProtocolError, onObserve, onLifecycle } = opts;
127
192
  const connectFn = opts.connectFn ?? connectViaRegistry;
193
+ const toolResultTimeoutMs = opts.toolResultTimeoutMs ?? POSTMESSAGE_BOOT_TIMEOUT_MS;
128
194
  // Emit typed {@link ProtocolError} for every bootstrap-failure site
129
195
  // that surfaces a `RendererBootFailedMessage`. The narrow
130
196
  // postMessage envelope stays (parent compatibility); the typed
@@ -150,16 +216,12 @@ export async function bootSequence(opts) {
150
216
  onLifecycle?.(makeLifecycleEvent('error', { error: { code: reason, message } }));
151
217
  };
152
218
  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);
219
+ // The mounted render is established after the first ack lands —
220
+ // populated by `applyAck` below + read back by every channel handler
221
+ // that needs `propsSpec` / `streamSpec`. Tracked as a closure-scoped
222
+ // ref so the failure-path returns can carry the same value through
223
+ // `BootSequenceResult`.
224
+ let mountedRender = null;
163
225
  setStatus(refs, 'Negotiating with host…', 'connecting');
164
226
  notifyParent({ type: 'ggui:renderer-ready', version: RENDERER_VERSION });
165
227
  // Lifecycle `mounting` — the renderer is alive + status DOM is up,
@@ -168,223 +230,303 @@ export async function bootSequence(opts) {
168
230
  // re-emission is the host's concern (per protocol, host treats
169
231
  // duplicate same-state envelopes as a no-op).
170
232
  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 };
233
+ // Install the spec-canonical toolresult listener on App BEFORE
234
+ // calling `app.connect(transport)`. App's `_assertHandlerTiming`
235
+ // warns if the first handler for a one-shot event registers AFTER
236
+ // the `ui/initialize`→`ui/notifications/initialized` handshake
237
+ // completes (the host may have already fired the notification by
238
+ // then). Registering early is the spec-canonical answer.
239
+ //
240
+ // The Promise resolves on the FIRST inbound `ui/notifications/tool-result`
241
+ // notification carrying valid `_meta["ai.ggui/render"]`. The race
242
+ // versus the synchronous `__GGUI_META__` tier is resolved AFTER the
243
+ // App handshake settles — whichever yields first wins; the listener
244
+ // is removed on resolve.
245
+ //
246
+ // Skipped entirely when `preResolvedMeta` is set (autostart caught
247
+ // the toolresult or read the global early) — no listener installed,
248
+ // no race to run.
249
+ const toolResultPromise = opts.preResolvedMeta !== undefined
250
+ ? Promise.resolve(null)
251
+ : awaitToolResultMetaFromApp(app, toolResultTimeoutMs);
252
+ const initResult = await connectApp(app, transport);
253
+ if (!initResult.ok) {
254
+ setStatus(refs, `ui/initialize failed: ${initResult.message}`, 'error');
255
+ emitBootFailure('UI_INITIALIZE_FAILED', initResult.message);
256
+ return { ok: false, mountedRender };
257
+ }
258
+ // Connected — expose the App on the module-level slot so outbound
259
+ // `tools/call` (dispatchWiredAction, channel-transport router)
260
+ // routes through `app.callServerTool` instead of the legacy raw
261
+ // postMessage pump. Idempotent: re-boot with the same App is a
262
+ // no-op; a different App throws (the iframe is single-tenant).
263
+ setCurrentApp(app);
264
+ // Slice-meta resolution — spec-canonical primary, no in-house
265
+ // Reading-B (retired Phase 1.19b.3 with the App-class swap; the
266
+ // McpUiInitializeResult schema doesn't define `toolOutput`).
267
+ //
268
+ // Tier 0 preResolvedMeta — autostart-layer pre-resolution. When
269
+ // the autostart's own toolresult race or inline `__GGUI_META__`
270
+ // parse caught the meta before bootSequence ran, it threads
271
+ // the parsed slice meta here so we don't re-await the same
272
+ // delivery. Skips every tier below.
273
+ //
274
+ // Tier 1 parseMetaFromGlobal — synchronous `__GGUI_META__` inline
275
+ // global. Self-contained shells (`buildSelfContainedHtml`,
276
+ // per-session resource shells) populate this before this
277
+ // bundle's `<script type="module">` evaluates.
278
+ // Opportunistic — its absence (the common case for
279
+ // postMessage-delivered hosts) never surfaces as a parse
280
+ // failure to the caller.
281
+ //
282
+ // Tier 2 spec-canonical `ui/notifications/tool-result` — observed
283
+ // by App via `addEventListener('toolresult', …)`. The ONLY
284
+ // remaining post-handshake path, used by every spec-strict
285
+ // host (`<AppRenderer>`, ChatGPT MCP-Apps connector,
286
+ // claude.ai post-spec, and any future host that issues
287
+ // `ui/initialize` without echoing `toolOutput`).
288
+ //
289
+ // When both tiers fail, we surface the Tier 2 timeout/failure reason
290
+ // (`MISSING_META_GGUI_BOOTSTRAP`) — it's the spec-canonical channel
291
+ // and its absence is the diagnostic worth showing.
292
+ let parsed;
293
+ if (opts.preResolvedMeta !== undefined) {
294
+ parsed = { ok: true, meta: opts.preResolvedMeta };
295
+ }
296
+ else {
297
+ const inline = parseMetaFromGlobal();
298
+ if (inline.ok) {
299
+ parsed = inline;
300
+ }
301
+ else {
302
+ const fromToolResult = await toolResultPromise;
303
+ if (fromToolResult !== null) {
304
+ parsed = { ok: true, meta: fromToolResult };
305
+ }
306
+ else {
307
+ parsed = {
308
+ ok: false,
309
+ reason: 'MISSING_META_GGUI_BOOTSTRAP',
310
+ };
311
+ }
312
+ }
313
+ }
314
+ // hostContext is captured opportunistically from
315
+ // `app.getHostContext()` — populated by App's `ui/initialize`
316
+ // response capture + kept fresh by the `hostcontextchanged`
317
+ // notification handler App ships internally. Apply the RAW context
318
+ // to the iframe DOM via the spec-canonical ext-apps helpers (theme
319
+ // + style variables + fonts) regardless of whether projection
320
+ // picked up new fields. Projection drops theme / styles (they live
321
+ // in ggui's own theming pipeline); the DOM-apply path is what
322
+ // surfaces them to LLM-generated UI so host-native primitives
323
+ // render consistently with the rest of chat.
324
+ const rawHostContext = app.getHostContext();
325
+ if (rawHostContext !== undefined) {
326
+ applyHostContextStyling(rawHostContext);
327
+ }
328
+ if (parsed.ok && parsed.hostContext === undefined) {
329
+ const hostContext = projectHostContext(rawHostContext);
330
+ if (hostContext !== undefined) {
331
+ parsed = { ...parsed, hostContext };
332
+ }
177
333
  }
178
- const parsed = parseBootstrap(initResp.result);
179
334
  if (!parsed.ok) {
180
- const message = `bootstrap parse failed: ${parsed.reason}`;
335
+ const message = `slice-meta parse failed: ${parsed.reason}`;
181
336
  setStatus(refs, message, 'error');
182
337
  emitBootFailure(parsed.reason, message);
183
- return { ok: false, stack: stackModel };
338
+ return { ok: false, mountedRender };
184
339
  }
340
+ // Single render slice — post-Phase-B the wire merged the
341
+ // `ai.ggui/session` + `ai.ggui/stack-item` pair into one
342
+ // `ai.ggui/render` slice. The parser surfaces it directly on
343
+ // `parsed.meta`.
344
+ const meta = parsed.meta;
185
345
  // Install the precompiled, eval-free contract validators shipped on
186
346
  // the bootstrap BEFORE any wire traffic is validated. Self-contained
187
347
  // ESM modules — no `__ggui__` dependency, so this need not wait for
188
348
  // `installGlobalRegistry`. A failed load leaves the validation seam
189
349
  // to fall back to in-iframe compilation (CSP-blocked, but no worse
190
350
  // 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,
351
+ setActiveValidatorSet(await loadCompiledValidatorsFromUrl(meta.validatorsUrl));
352
+ // Pin — this iframe binds to exactly one render id for its lifetime
353
+ // (post-render-identity-collapse each iframe = one mounted render,
354
+ // per [[kill-displaymode-divergence]]). The bootstrap's `renderId`
355
+ // is the pin; the render handler drops any frame addressed
356
+ // elsewhere.
357
+ const pinnedRenderId = meta.renderId;
358
+ // Renderer wiring — when supplied, the handler routes frames through
359
+ // the single-render mount surface + WireConfig + StreamBus. When
360
+ // absent, the placeholder path runs (boot.test.ts relies on the
361
+ // latter to keep its import graph tiny). The renderer's
362
+ // channelRegistry is the dispatch surface for every WS frame;
363
+ // `connectFn` registers handshake handlers on top of the existing
364
+ // registry then binds the transport.
365
+ const renderer = rendererHooks !== undefined
366
+ ? rendererHooks.setup({
367
+ meta,
368
+ renderInto: refs.stack,
277
369
  statusRefs: refs,
278
370
  ...(onObserve !== undefined ? { onObserve } : {}),
279
- ...(canvasLifecycleSink !== undefined
280
- ? { onCanvasLifecycle: canvasLifecycleSink }
281
- : {}),
282
371
  })
283
372
  : null;
284
- setStatus(refs, `Connecting to ${parsed.bootstrap.sessionId}…`, 'connecting');
285
- // Boot-without-triad path: we still need a ChannelRegistry to
373
+ setStatus(refs, `Connecting to ${meta.renderId}…`, 'connecting');
374
+ // Boot-without-renderer path: we still need a ChannelRegistry to
286
375
  // 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
376
+ // surface. Build a minimal one with just the `render` placeholder
377
+ // handler so non-renderer consumers (boot.test.ts) can observe
378
+ // bootstrap-orchestration outcomes without paying React import
379
+ // cost. The handler logs status but does not mount React.
380
+ const placeholderRegistry = renderer === null
291
381
  ? createPlaceholderRegistry({
292
- bootstrap: parsed.bootstrap,
293
- stackModel,
382
+ meta,
294
383
  statusRefs: refs,
384
+ pinnedRenderId,
295
385
  })
296
386
  : 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
- }
387
+ const activeRegistry = renderer?.channelRegistry ?? placeholderRegistry;
323
388
  /**
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:
389
+ * Apply an ack's render snapshot to the runtime — when the ack
390
+ * carries a render matching `pinnedRenderId`, mount it through the
391
+ * renderer. Used by:
328
392
  *
329
393
  * - The initial bootSequence path (first ack after subscribe).
330
394
  * - The WS reconnect-with-rebootstrap path (every subsequent ack
331
395
  * when the underlying WSTransport reconnects + re-fires
332
- * subscribe). A push or update that landed during the dropout
333
- * window flows back through here.
396
+ * subscribe). A render or update that landed during the
397
+ * dropout window flows back through here.
334
398
  *
335
- * Idempotent on identical inputs — `stackModel.setAll` is a snapshot
336
- * replace; `applyStack` + `applyStackItem` are server-side idempotent
337
- * on the (stackItemId, channelName) tuple.
399
+ * Idempotent on identical inputs — `applyRender` patches in place
400
+ * via {@link RenderItemHandle.update} when called with the same
401
+ * render id; `channelTransport.applyRender` is server-side
402
+ * idempotent on the (renderId, channelName) tuple.
338
403
  */
339
- const applyAckStack = async (ackPayload) => {
340
- if (ackPayload.stack === undefined)
404
+ const applyAck = async (ackPayload) => {
405
+ const target = ackPayload.render;
406
+ if (target === undefined)
407
+ return;
408
+ if (target.id !== pinnedRenderId) {
409
+ // Server's snapshot is for a different render — likely a stale
410
+ // re-subscribe after the server pruned ours. Nothing to mount.
341
411
  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 }
412
+ }
413
+ mountedRender = target;
414
+ if (renderer !== null) {
415
+ await renderer.applyRender(target);
416
+ if (target.type !== 'mcpApps' && target.type !== 'system') {
417
+ renderer.channelTransport.applyRender({
418
+ renderId: target.id,
419
+ ...(target.streamSpec !== undefined
420
+ ? { streamSpec: target.streamSpec }
368
421
  : {}),
369
422
  });
370
423
  }
371
424
  }
372
425
  };
426
+ // `code-ready` is emitted exactly once per boot. The static seed mount
427
+ // emits it; the WS-ack reconcile path then becomes a silent repaint.
428
+ // Without this guard a static+live meta would fire `code-ready` twice
429
+ // (seed + ack), double-triggering host selectors / accessibility scanners.
430
+ let codeReadyEmitted = false;
431
+ const emitCodeReadyOnce = () => {
432
+ if (codeReadyEmitted)
433
+ return;
434
+ codeReadyEmitted = true;
435
+ onLifecycle?.(makeLifecycleEvent('code-ready', { renderId: meta.renderId }));
436
+ };
437
+ // Mode discriminators. The mount surface is DECOUPLED from the live
438
+ // channel: static content (codeUrl/kind) paints immediately with no WS;
439
+ // the live trio (wsUrl+wsToken) is an OPTIONAL enhancement that
440
+ // delivers props_update / data / re-render frames. A bootstrap with
441
+ // NEITHER has nothing to show and nowhere to subscribe.
442
+ const hasStaticContent = (typeof meta.codeUrl === 'string' && meta.codeUrl.length > 0) ||
443
+ (typeof meta.kind === 'string' && meta.kind.length > 0);
444
+ const hasLiveTrio = typeof meta.wsUrl === 'string' &&
445
+ meta.wsUrl.length > 0 &&
446
+ typeof meta.wsToken === 'string' &&
447
+ meta.wsToken.length > 0;
448
+ // ── Static seed mount — zero-round-trip paint, no WS required. ──────
449
+ // The ONLY mount path for spec-compliant MCP-Apps hosts that expose no
450
+ // ggui live channel (claude.ai / ChatGPT / Claude Desktop), AND the
451
+ // instant first paint for first-party hosts that ALSO open a WS (the
452
+ // WS ack then reconciles in place — `applyRender` is idempotent on the
453
+ // pinned renderId, and the ack's componentCode is byte-identical to the
454
+ // seed's `codeUrl` bytes, so it's a props-only update, no remount).
455
+ if (renderer !== null && hasStaticContent) {
456
+ // Await the 3rd-party gadget merge before first paint when the
457
+ // bootstrap declares operator-registered packages — otherwise a
458
+ // component importing a non-STDLIB gadget hits an undefined shim and
459
+ // crashes. STDLIB-only bootstraps resolve this promise synchronously.
460
+ if (meta.gadgets !== undefined && meta.gadgets.length > 0) {
461
+ await renderer.composedGadgets;
462
+ }
463
+ let seed = null;
464
+ try {
465
+ seed = await buildRenderSeedInput(meta);
466
+ }
467
+ catch (err) {
468
+ const message = err instanceof Error ? err.message : String(err);
469
+ if (!hasLiveTrio) {
470
+ // No WS fallback — a failed static fetch is terminal.
471
+ setStatus(refs, `static mount failed: ${message}`, 'error');
472
+ emitBootFailure('UI_INITIALIZE_FAILED', message);
473
+ return { ok: false, mountedRender };
474
+ }
475
+ // A live trio is present — the WS ack will deliver the render.
476
+ // The seed was best-effort; fall through to subscribe.
477
+ seed = null;
478
+ }
479
+ if (seed !== null) {
480
+ await renderer.applyRender(seed);
481
+ mountedRender = seed;
482
+ emitCodeReadyOnce();
483
+ }
484
+ }
485
+ // ── No live channel → static-only host. ────────────────────────────
486
+ // The static seed already painted (or there was nothing to paint).
487
+ // A bootstrap with neither static content nor a live trio is a
488
+ // misconfiguration — surface a typed boot failure.
489
+ if (!hasLiveTrio) {
490
+ if (mountedRender === null) {
491
+ const message = 'bootstrap carries neither static content (codeUrl/kind) nor a live trio (wsUrl/wsToken)';
492
+ setStatus(refs, message, 'error');
493
+ emitBootFailure('MISSING_META_GGUI_BOOTSTRAP', message);
494
+ return { ok: false, mountedRender };
495
+ }
496
+ // Host-context wiring for the no-WS static mount (claude.ai / ChatGPT
497
+ // / Claude Desktop). The INITIAL host theme/style/fonts were already
498
+ // applied at boot (applyHostContextStyling, above); attach the
499
+ // spec-canonical `hostcontextchanged` listener so a post-boot host
500
+ // theme toggle still re-paints the iframe. There is no WS to echo
501
+ // context back to a server, so the emitter's `send` is a no-op —
502
+ // `seed()` needs it only to initialize emitter state, and
503
+ // `handleHostContextChangedParams` applies the DOM styling BEFORE the
504
+ // (no-op) echo. WS hosts get the real echo on the live-trio path below.
505
+ if (parsed.hostContext !== undefined) {
506
+ seedHostContext({
507
+ renderId: meta.renderId,
508
+ send: () => { },
509
+ initial: parsed.hostContext,
510
+ });
511
+ attachHostContextListener({ app });
512
+ }
513
+ setConnectedStatus(refs);
514
+ return { ok: true, mountedRender };
515
+ }
516
+ // ── Live-channel subscribe (conditional enhancement). ──────────────
373
517
  let handle;
374
518
  try {
375
519
  handle = await connectFn({
376
- bootstrap: parsed.bootstrap,
520
+ meta,
377
521
  registry: activeRegistry,
378
522
  onStatusChange: (status) => {
379
- setStatus(refs, status === 'connected'
380
- ? `Connected (${stackModel.size()} item${stackModel.size() === 1 ? '' : 's'}).`
381
- : `Connection ${status}…`, status);
523
+ setStatus(refs, status === 'connected' ? 'Connected.' : `Connection ${status}…`, status);
382
524
  // Propagate WS status to the per-channel transport router so
383
525
  // it can flip WS-bound channels into polling
384
526
  // 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);
527
+ // on reconnect. No-op when renderer wiring is absent.
528
+ if (renderer !== null) {
529
+ renderer.channelTransport.onWsStatusChange(status);
388
530
  }
389
531
  },
390
532
  // Forward typed errors from subscribe through the runtime's
@@ -398,17 +540,38 @@ export async function bootSequence(opts) {
398
540
  ...(onObserve !== undefined ? { onObserve } : {}),
399
541
  // Reconnect-with-rebootstrap — on every ack received AFTER the
400
542
  // initial handshake settled, reapply the server's authoritative
401
- // `stack` snapshot. A push or update that landed during a WS
543
+ // `render` snapshot. A render or update that landed during a WS
402
544
  // dropout window restores here without an agent re-prompt.
403
545
  onResubscribeAck: (ack) => {
404
- void applyAckStack(ack);
546
+ void applyAck(ack);
405
547
  },
548
+ // R7 — registry-level events-polling fallback. Composed once at
549
+ // bind time from `meta.pollingUrl` (server-stamped wsToken-
550
+ // gated /api/renders/<id>/events URL) + `meta.lastSequence`
551
+ // (cursor seed). FailoverHandle uses this when WS reaches
552
+ // 'failed'; absent → no polling fallback (WS-only mode).
553
+ //
554
+ // Same cursor model as the WS subscribe `sinceSequence` replay
555
+ // path — switching transports does not lose events.
556
+ ...(typeof meta.pollingUrl === 'string' && meta.pollingUrl.length > 0
557
+ ? {
558
+ polling: buildEventsPolling({
559
+ baseUrl: meta.pollingUrl,
560
+ ...(meta.lastSequence !== undefined
561
+ ? { initialSinceSequence: meta.lastSequence }
562
+ : {}),
563
+ }),
564
+ }
565
+ : {}),
406
566
  });
407
567
  }
408
568
  catch (err) {
409
- if (triad !== null)
410
- triadWiring?.teardown?.(triad);
569
+ // UPGRADE_REQUIRED is TERMINAL even when static content already
570
+ // painted — the mounted code may be wire-incompatible with this
571
+ // runtime, so surfacing the upgrade prompt wins over a stale mount.
411
572
  if (isUpgradeRequiredErrorLike(err)) {
573
+ if (renderer !== null)
574
+ rendererHooks?.teardown?.(renderer);
412
575
  const message = err.message;
413
576
  setStatus(refs, message, 'upgrade-required');
414
577
  // `UPGRADE_REQUIRED` already emits a typed `version` error via
@@ -416,87 +579,96 @@ export async function bootSequence(opts) {
416
579
  // here carries the coarse-grained reason for hosts that only
417
580
  // pattern-match `kind: 'bootstrap'`.
418
581
  emitBootFailure('UPGRADE_REQUIRED', message);
419
- return { ok: false, stack: stackModel };
582
+ return { ok: false, mountedRender };
420
583
  }
421
584
  const message = err instanceof Error ? err.message : String(err);
585
+ if (mountedRender !== null) {
586
+ // DEGRADE — a transport/auth failure AFTER a static seed already
587
+ // painted. Keep the mounted content, skip live updates (props_update
588
+ // simply stops arriving), and do NOT tear down or surface a
589
+ // bootstrap failure. connectFn already emitted the typed
590
+ // `subscribe-failed` observability event on its own onObserve path.
591
+ setStatus(refs, `live updates unavailable: ${message}`, 'connecting');
592
+ emitCodeReadyOnce();
593
+ return { ok: true, mountedRender };
594
+ }
595
+ if (renderer !== null)
596
+ rendererHooks?.teardown?.(renderer);
422
597
  setStatus(refs, `WS handshake failed: ${message}`, 'error');
423
598
  emitBootFailure('WS_HANDSHAKE_FAILED', message);
424
- return { ok: false, stack: stackModel };
599
+ return { ok: false, mountedRender };
425
600
  }
426
- // Attach the live transport handle to the triad — flushes any
601
+ // Attach the live transport handle to the renderer — flushes any
427
602
  // buffered outbound frames (feedback / action) that were queued
428
603
  // while the subscribe handshake completed. `handle.handle.send` is
429
604
  // 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) });
605
+ if (renderer !== null && rendererHooks?.attachManager !== undefined) {
606
+ rendererHooks.attachManager(renderer, { send: (msg) => handle.handle.send(msg) });
432
607
  }
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`
608
+ // seed the host-context emitter with the projection captured from
609
+ // `app.getHostContext()` (the App class's spec-canonical
610
+ // `ui/initialize` capture), and install the `hostcontextchanged`
436
611
  // notification listener so subsequent live updates also echo to
437
612
  // the server. Both calls are idempotent and no-op when the host
438
613
  // didn't emit a HostContext (parsed.hostContext === undefined).
439
614
  if (parsed.hostContext !== undefined) {
440
615
  seedHostContext({
441
- sessionId: parsed.bootstrap.sessionId,
616
+ renderId: meta.renderId,
442
617
  send: (msg) => handle.handle.send(msg),
443
618
  initial: parsed.hostContext,
444
619
  });
445
- attachHostContextListener();
620
+ // Bind via App's spec-canonical `hostcontextchanged` event surface.
621
+ // App's onEventDispatch pre-merges the params into its internal
622
+ // `_hostContext` before our handler runs, so `app.getHostContext()`
623
+ // is always fresh by the time we project + WS-echo.
624
+ attachHostContextListener({ app });
446
625
  }
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 };
626
+ // First ack — apply the server's render snapshot (when matching
627
+ // `pinnedRenderId`) and mount it. Reuses the same `applyAck` helper
628
+ // the reconnect-rebootstrap path uses — so a server-restart-driven
629
+ // snapshot replay and the first-boot snapshot apply flow through
630
+ // one implementation.
631
+ await applyAck(handle.ack);
632
+ setConnectedStatus(refs);
633
+ // Lifecycle `code-ready` — terminal happy state. Emitted ONCE per boot
634
+ // (a static+live meta already fired it from the seed mount, so this is
635
+ // a no-op there); for a live-only meta this is the first + only emit.
636
+ // Hosts pinning selectors on `code-ready` (E2E specs, accessibility
637
+ // scanners) re-resolve here.
638
+ emitCodeReadyOnce();
639
+ return { ok: true, mountedRender };
469
640
  }
470
641
  /**
471
- * Build a minimal `ChannelRegistry` for boot paths without triad
642
+ * Build a minimal `ChannelRegistry` for boot paths without renderer
472
643
  * 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.
644
+ * carries just the `push` handler — which logs status but does not
645
+ * mount React — so consumers can observe bootstrap-orchestration
646
+ * outcomes without paying React import cost. Every other frame type
647
+ * silently drops. Production boots through `bootProduction` which
648
+ * supplies a fully-populated renderer with the rich handler set.
477
649
  */
478
650
  function createPlaceholderRegistry(params) {
479
651
  const registry = new ChannelRegistry({
480
652
  subscribeFrameBuilder: () => ({
481
653
  type: 'subscribe',
482
654
  payload: {
483
- sessionId: params.bootstrap.sessionId,
484
- appId: params.bootstrap.appId,
485
- ...(params.bootstrap.token !== undefined
486
- ? { bootstrap: params.bootstrap.token }
655
+ renderId: params.meta.renderId,
656
+ appId: params.meta.appId,
657
+ ...(params.meta.wsToken !== undefined
658
+ ? { wsToken: params.meta.wsToken }
487
659
  : {}),
488
660
  },
489
661
  }),
490
662
  });
491
- registry.register(createPushHandler({
492
- stackModel: params.stackModel,
663
+ registry.register(createRenderHandler({
493
664
  statusRefs: params.statusRefs,
665
+ pinnedRenderId: params.pinnedRenderId,
494
666
  }));
495
667
  return registry;
496
668
  }
497
669
  /**
498
- * Frame dispatch lives inside `@ggui-ai/channel-client`'s
499
- * `ChannelRegistry`. Every WS frame type (`push`, `data`,
670
+ * Frame dispatch lives inside `@ggui-ai/live-channel`'s
671
+ * `ChannelRegistry`. Every WS frame type (`render`, `data`,
500
672
  * `props_update`, `drain_ack`, `feedback`, `channel_payload`,
501
673
  * `channel_error`, `system`) has a registered handler in
502
674
  * `channels/*.ts`; the registry's bound transport routes inbound
@@ -504,7 +676,7 @@ function createPlaceholderRegistry(params) {
504
676
  * (`contract-error-emitted`, `auth-required`) are now inside the
505
677
  * data + system handlers respectively.
506
678
  *
507
- * The pre-B3b helpers (`handleServerMessage`, `handleTriadMessage`,
679
+ * The pre-B3b helpers (`handleServerMessage`, `handleRendererMessage`,
508
680
  * `handleObservableMessage`, `emitContractErrorFromDataFrame`,
509
681
  * `emitAuthRequiredFromSystemFrame`, `BufferedManagerShim`) lived
510
682
  * here and have been retired — see commit message + plan B3b for the
@@ -530,42 +702,109 @@ function shouldAutostart() {
530
702
  return process.env?.GGUI_RENDERER_AUTOSTART !== 'false';
531
703
  }
532
704
  /**
533
- * Read `globalThis.__GGUI_BOOTSTRAP__` synchronously, validate against
534
- * the unified bootstrap shape, and return the typed bootstrap (or null
705
+ * Read `globalThis.__GGUI_META__` synchronously, validate against
706
+ * the unified slice-meta shape, and return the typed meta (or null
535
707
  * on absence / malformation).
536
708
  *
537
- * Thin wrapper around {@link parseBootstrapFromGlobal}; preserved for
709
+ * Thin wrapper around {@link parseMetaFromGlobal}; preserved for
538
710
  * 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.
711
+ * of {@link McpAppAiGguiMetaParseResult} to match the historical
712
+ * reader contract — the autostart resolver only needs the "valid
713
+ * meta or fall through" signal.
714
+ */
715
+ export function readSelfContainedMeta() {
716
+ const result = parseMetaFromGlobal();
717
+ return result.ok ? result.meta : null;
718
+ }
719
+ /**
720
+ * Parse the bootstrap meta's `propsJson` string into a {@link JsonObject}.
721
+ * Malformed JSON or a non-object payload is a shape-preserving skip
722
+ * (returns `undefined`) — a bad `propsJson` must never block the mount.
723
+ * `JsonValue` narrows to `JsonObject` via the object/non-null/non-array
724
+ * guard, so no cast is needed.
542
725
  */
543
- export function readSelfContainedBootstrap() {
544
- const result = parseBootstrapFromGlobal();
545
- return result.ok ? result.bootstrap : null;
726
+ function parseSeedProps(propsJson) {
727
+ if (propsJson === undefined)
728
+ return undefined;
729
+ try {
730
+ const parsed = JSON.parse(propsJson);
731
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
732
+ return parsed;
733
+ }
734
+ return undefined;
735
+ }
736
+ catch {
737
+ // Malformed propsJson is a shape-preserving skip — mount with no props.
738
+ return undefined;
739
+ }
546
740
  }
547
741
  /**
548
- * Extract a {@link SelfContainedBootstrap} from a
742
+ * Project the inline `__GGUI_META__` bootstrap into a
743
+ * {@link RenderSeedInput} the mount surface can paint immediately,
744
+ * BEFORE the authoritative wire `Render` arrives over the WS — and with
745
+ * no WS at all for spec-compliant MCP-Apps hosts that expose no ggui
746
+ * live channel (claude.ai / ChatGPT / Claude Desktop).
747
+ *
748
+ * Two static-content shapes (the autostart discriminator):
749
+ * - `kind` → a system-card seed (`type:'system'`); no fetch.
750
+ * - `codeUrl` → a compiled-component seed; fetches the
751
+ * content-addressable component bytes.
752
+ *
753
+ * Returns `null` when the meta carries NEITHER (a live-only meta has
754
+ * nothing to seed — the first WS ack mounts it). Throws when a `codeUrl`
755
+ * fetch fails so the caller can surface a typed boot failure. The four
756
+ * server-assigned ledger fields are intentionally absent — the first
757
+ * ack reconciles the seed to a full `Render` (no fabrication).
758
+ */
759
+ export async function buildRenderSeedInput(meta) {
760
+ const props = parseSeedProps(meta.propsJson);
761
+ // System-card mode — `kind` keyed against the built-in registry.
762
+ if (meta.kind !== undefined) {
763
+ return {
764
+ id: meta.renderId,
765
+ appId: meta.appId,
766
+ type: 'system',
767
+ kind: meta.kind,
768
+ ...(props !== undefined ? { props } : {}),
769
+ };
770
+ }
771
+ // Compiled-component mode — fetch the content-addressable bytes.
772
+ if (meta.codeUrl === undefined)
773
+ return null;
774
+ const res = await fetch(meta.codeUrl);
775
+ if (!res.ok) {
776
+ throw new Error(`buildRenderSeedInput: codeUrl fetch failed (${res.status}): ${meta.codeUrl}`);
777
+ }
778
+ const componentCode = await res.text();
779
+ return {
780
+ id: meta.renderId,
781
+ appId: meta.appId,
782
+ componentCode,
783
+ ...(props !== undefined ? { props } : {}),
784
+ };
785
+ }
786
+ /**
787
+ * Extract a {@link SelfContainedMcpAppAiGguiMeta} from a
549
788
  * `ui/notifications/tool-result` JSON-RPC params payload — the
550
789
  * postMessage delivery shape MCP Apps hosts (Claude Desktop, claude.ai
551
790
  * web) use to push the active tool's `_meta` to the iframe.
552
791
  *
553
- * Thin wrapper around {@link parseBootstrapFromToolResult}; same
554
- * back-compat motivation as {@link readSelfContainedBootstrap}.
792
+ * Thin wrapper around {@link parseMetaFromToolResult}; same
793
+ * back-compat motivation as {@link readSelfContainedMeta}.
555
794
  *
556
795
  * Owning this extraction inside the runtime (instead of in the shell
557
796
  * HTML) is the architectural cure for the "shell-side validator
558
797
  * lagged the protocol" bug class: shell + runtime drift becomes
559
- * impossible because the shell never inspects the bootstrap shape.
798
+ * impossible because the shell never inspects the slice-meta shape.
560
799
  */
561
- export function extractBootstrapFromToolResult(params) {
562
- const result = parseBootstrapFromToolResult(params);
563
- return result.ok ? result.bootstrap : null;
800
+ export function extractMetaFromToolResult(params) {
801
+ const result = parseMetaFromToolResult(params);
802
+ return result.ok ? result.meta : null;
564
803
  }
565
804
  /**
566
805
  * Drain `window.__GGUI_PENDING_TOOL_RESULTS__` — the buffer the
567
806
  * minimal shell populates while messages arrive between
568
- * shell-load and runtime-load. Returns the first valid bootstrap
807
+ * shell-load and runtime-load. Returns the first valid slice meta
569
808
  * found; later params (if any) are dropped (each new tool-result
570
809
  * supersedes the previous).
571
810
  *
@@ -581,25 +820,58 @@ function readPendingToolResults() {
581
820
  if (!Array.isArray(raw) || raw.length === 0)
582
821
  return null;
583
822
  for (const params of raw) {
584
- const bootstrap = extractBootstrapFromToolResult(params);
585
- if (bootstrap !== null)
586
- return bootstrap;
823
+ const meta = extractMetaFromToolResult(params);
824
+ if (meta !== null)
825
+ return meta;
587
826
  }
588
827
  return null;
589
828
  }
590
829
  /**
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}).
830
+ * Listen for a `ui/notifications/tool-result` notification via the
831
+ * App's spec-canonical event surface and resolve to the extracted
832
+ * slice meta. Times out after `timeoutMs`; resolves `null` on timeout
833
+ * so the caller can fall through to a legacy boot path.
596
834
  *
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.
835
+ * Used by `bootSequence` for the spec-canonical Tier 2 fallback when
836
+ * the synchronous `__GGUI_META__` inline global yields nothing. Must
837
+ * be called BEFORE `app.connect(transport)` — App's
838
+ * `_assertHandlerTiming` warns when the first handler for a one-shot
839
+ * event registers after the handshake.
840
+ */
841
+ function awaitToolResultMetaFromApp(app, timeoutMs) {
842
+ return new Promise((resolve) => {
843
+ let settled = false;
844
+ const handler = (params) => {
845
+ if (settled)
846
+ return;
847
+ const meta = extractMetaFromToolResult(params);
848
+ if (meta === null)
849
+ return;
850
+ settled = true;
851
+ app.removeEventListener('toolresult', handler);
852
+ clearTimeout(timer);
853
+ resolve(meta);
854
+ };
855
+ app.addEventListener('toolresult', handler);
856
+ const timer = setTimeout(() => {
857
+ if (settled)
858
+ return;
859
+ settled = true;
860
+ app.removeEventListener('toolresult', handler);
861
+ resolve(null);
862
+ }, timeoutMs);
863
+ });
864
+ }
865
+ /**
866
+ * Pre-handshake variant — listens to raw `window.message` for the
867
+ * autostart layer. Used BEFORE the App is constructed, so this can't
868
+ * route through `app.addEventListener`. Pairs with the minimal-shell
869
+ * pattern: the shell buffers any tool-results that arrived BEFORE
870
+ * runtime load (read via {@link readPendingToolResults}); this
871
+ * listener catches the ones that arrive AFTER the bundle parses but
872
+ * BEFORE bootSequence runs.
601
873
  */
602
- function awaitToolResultBootstrap(timeoutMs) {
874
+ function awaitToolResultMeta(timeoutMs) {
603
875
  if (typeof window === 'undefined')
604
876
  return Promise.resolve(null);
605
877
  return new Promise((resolve) => {
@@ -620,64 +892,35 @@ function awaitToolResultBootstrap(timeoutMs) {
620
892
  m.method !== 'ui/notifications/tool-result') {
621
893
  return;
622
894
  }
623
- const bootstrap = extractBootstrapFromToolResult(m.params);
624
- if (bootstrap !== null)
625
- settle(bootstrap);
895
+ const meta = extractMetaFromToolResult(m.params);
896
+ if (meta !== null)
897
+ settle(meta);
626
898
  };
627
899
  window.addEventListener('message', onMessage);
628
900
  const timer = setTimeout(() => settle(null), timeoutMs);
629
901
  });
630
902
  }
631
903
  /**
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.
904
+ * Module-level guard for the App-mediated post-mount toolresult
905
+ * re-listener. Ensures exactly one persistent
906
+ * `app.addEventListener('toolresult', …)` registration even when
907
+ * `bootSelfContained` is called multiple times (e.g. an agent fires a
908
+ * second `ggui_render` and we re-mount).
642
909
  */
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
- }
910
+ let postMountListenerInstalled = false;
674
911
  /**
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).
912
+ * Module-level handle to the active renderer's `applyRender`, published
913
+ * by `bootProduction`'s `setup()` once the renderer is built. The
914
+ * module-level {@link installPostMountListener} resolves this to re-mount
915
+ * on a host-re-emitted tool-result WITHOUT a fresh boot — no second mount
916
+ * stack, no second WS. This is the no-WS live-re-render channel for
917
+ * spec-compliant MCP-Apps hosts (claude.ai / ChatGPT / Claude Desktop)
918
+ * that re-broadcast the tool-result instead of pushing a WS frame.
919
+ *
920
+ * `null` until a renderer is published (e.g. unit tests that drive
921
+ * `bootSequence` with no renderer never set it → the listener no-ops).
679
922
  */
680
- let postMountListenerInstalled = false;
923
+ let activeApplyRender = null;
681
924
  /**
682
925
  * Module-level guard for {@link installAnchorClickInterceptor}. Same
683
926
  * rationale as {@link postMountListenerInstalled}: re-mounts are
@@ -690,7 +933,7 @@ let anchorClickInterceptInstalled = false;
690
933
  * Module-level guard for {@link installFullscreenInterceptors}. The
691
934
  * fullscreen interceptors REPLACE prototype methods on `Element` and
692
935
  * `Document`; re-overriding on every re-mount would chain wrappers
693
- * and (if the captured `args` differ) leak stale `sessionId`/`appId`.
936
+ * and (if the captured `args` differ) leak stale `renderId`/`appId`.
694
937
  * The guard ensures exactly one prototype patch.
695
938
  */
696
939
  let fullscreenInterceptInstalled = false;
@@ -792,9 +1035,22 @@ export function formatWiredActionDataInline(data) {
792
1035
  */
793
1036
  /**
794
1037
  * 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).
1038
+ * window. Internal helper shared across {@link emitAudit} (the
1039
+ * fire-and-forget `tools/call` audit envelope) and any other
1040
+ * non-spec-canonical `ggui:*` outbound envelope that doesn't have an
1041
+ * App method equivalent. Detached parent → silent drop (non-fatal).
1042
+ *
1043
+ * Spec-canonical MCP-Apps notifications whose params are FULLY
1044
+ * described by the spec schema (`ui/update-model-context`,
1045
+ * `ui/open-link`, `ui/request-display-mode`) flow through the App
1046
+ * method helpers below — they round-trip via the bound `Transport`
1047
+ * and follow the spec's request/response shape.
1048
+ *
1049
+ * `ui/message` is the exception: its doorbell carries a content-block
1050
+ * `_meta` extension that the host's closed `McpUiMessageRequestSchema`
1051
+ * parse would strip (and empty the text). It posts its `ui/message`
1052
+ * frame through this raw helper instead — see {@link
1053
+ * emitUserActionDoorbell} for the full rationale.
798
1054
  */
799
1055
  function postToParent(envelope) {
800
1056
  if (typeof window === 'undefined')
@@ -807,77 +1063,117 @@ function postToParent(envelope) {
807
1063
  }
808
1064
  }
809
1065
  /**
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.
1066
+ * Outbound MCP-Apps notification shims — fire the spec-canonical App
1067
+ * method when the module-level App handle is set, otherwise drop
1068
+ * silently. Each method is a fire-and-forget request from the
1069
+ * iframe-runtime's perspective: production never awaits the host's
1070
+ * ack, so we use `void` + `.catch(noop)` to suppress unhandled
1071
+ * rejections.
815
1072
  *
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).
1073
+ * Pre-connect call site safety: every production caller fires after
1074
+ * the boot pipeline has installed an App (anchor clicks, fullscreen
1075
+ * gestures, context-observer ticks, dispatch fan-outs all gate on
1076
+ * post-mount lifecycle). The no-op fallback exists for unit tests
1077
+ * that exercise these helpers without invoking `bootSequence` —
1078
+ * the absence of side effects there is the test's signal that the
1079
+ * caller did its part.
825
1080
  */
826
- let postRpcToParentInited = false;
827
- const postRpcToParentPending = new Map();
828
- let postRpcToParentNextId = 1_000_000;
829
- function ensurePostRpcToParentListener() {
830
- if (postRpcToParentInited)
1081
+ function callAppUpdateModelContext(params) {
1082
+ const app = getCurrentApp();
1083
+ if (app === null)
831
1084
  return;
832
- if (typeof window === 'undefined')
1085
+ void app.updateModelContext(params).catch(() => {
1086
+ // Detached / host-rejected — drop silently per the helper contract.
1087
+ });
1088
+ }
1089
+ function callAppOpenLink(params) {
1090
+ const app = getCurrentApp();
1091
+ if (app === null)
833
1092
  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);
1093
+ void app.openLink(params).catch(() => {
1094
+ // Detached / host-rejected — drop silently per the helper contract.
856
1095
  });
857
1096
  }
858
- function postRpcToParent(method, params) {
859
- if (typeof window === 'undefined') {
860
- return Promise.resolve({
861
- error: { message: 'postRpcToParent: no window' },
1097
+ function callAppRequestDisplayMode(params) {
1098
+ const app = getCurrentApp();
1099
+ if (app === null)
1100
+ return;
1101
+ void app.requestDisplayMode(params).catch(() => {
1102
+ // Detached / host-rejected — drop silently per the helper contract.
1103
+ });
1104
+ }
1105
+ /**
1106
+ * Production {@link ContextSnapshotPoster} — the seam the
1107
+ * context-observer factories consume. Splits the two destinations:
1108
+ *
1109
+ * - `postUpdateModelContext` → spec-canonical
1110
+ * `app.updateModelContext(...)` (via {@link callAppUpdateModelContext}).
1111
+ * - `postContextMirror` → raw `tools/call ggui_runtime_sync_context`
1112
+ * via {@link postToParent} (the host-relay mirror path; not yet
1113
+ * migrated to `app.callServerTool` — see emitAudit for the same
1114
+ * posture).
1115
+ */
1116
+ const productionContextSnapshotPoster = {
1117
+ postUpdateModelContext: (params) => {
1118
+ callAppUpdateModelContext(params);
1119
+ },
1120
+ postContextMirror: (params) => {
1121
+ postToParent({
1122
+ jsonrpc: '2.0',
1123
+ id: Math.floor(Math.random() * 1e9),
1124
+ method: 'tools/call',
1125
+ params: {
1126
+ name: 'ggui_runtime_sync_context',
1127
+ arguments: {
1128
+ renderId: params.renderId,
1129
+ appId: params.appId,
1130
+ snapshot: params.snapshot,
1131
+ },
1132
+ },
862
1133
  });
1134
+ },
1135
+ };
1136
+ /**
1137
+ * Outbound `tools/call` shim. Routes through the spec-canonical
1138
+ * `app.callServerTool` API on the module-level App handle. Production
1139
+ * always has the handle set (bootSequence / bootSelfContained call
1140
+ * `setCurrentApp(app)` after handshake); tests that exercise dispatch
1141
+ * routing install one explicitly via {@link setCurrentApp} bound to
1142
+ * a `MockTransport`.
1143
+ *
1144
+ * Returns a `JsonRpcResponse`-shaped object for source-compatibility
1145
+ * with the previous direct postMessage path: callers parse
1146
+ * `resp.result.structuredContent` to read submit_action's `{ok, code,
1147
+ * consumerPresent}` envelope. The App branch wraps the parsed
1148
+ * `CallToolResult` in `{result: ...}`.
1149
+ *
1150
+ * Drops with an error envelope when no App is bound — the dispatch
1151
+ * pipeline classifies that as a transport error and routes to the
1152
+ * `ui/message` fallback.
1153
+ */
1154
+ async function callServerToolSpec(toolName, args) {
1155
+ const app = getCurrentApp();
1156
+ if (app === null) {
1157
+ return {
1158
+ error: {
1159
+ message: 'callServerToolSpec: no App bound — call setCurrentApp() first',
1160
+ },
1161
+ };
1162
+ }
1163
+ try {
1164
+ const result = await app.callServerTool({
1165
+ name: toolName,
1166
+ arguments: args,
1167
+ });
1168
+ return { jsonrpc: '2.0', result: result };
1169
+ }
1170
+ catch (err) {
1171
+ return {
1172
+ error: {
1173
+ message: err instanceof Error ? err.message : String(err),
1174
+ },
1175
+ };
863
1176
  }
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
1177
  }
882
1178
  /**
883
1179
  * Fire a single canonical action envelope (`tools/call
@@ -904,10 +1200,7 @@ export function emitAudit(args) {
904
1200
  arguments: {
905
1201
  kind: args.kind,
906
1202
  payload: args.payload,
907
- sessionId: args.sessionId,
908
- ...(args.stackItemId !== undefined
909
- ? { stackItemId: args.stackItemId }
910
- : {}),
1203
+ renderId: args.renderId,
911
1204
  appId: args.appId,
912
1205
  actionId: args.actionId,
913
1206
  firedAt: args.firedAt,
@@ -1091,105 +1384,113 @@ function readLocalUiContext() {
1091
1384
  return out;
1092
1385
  }
1093
1386
  /**
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).
1387
+ * Emit the `ai.ggui/userAction` PURE DOORBELL on a `ui/message`
1388
+ * envelope. Fires when `submit_action` succeeded BUT the server
1389
+ * reported `consumerPresent: false` — no `ggui_consume` long-poll is
1390
+ * currently listening on this render's pipe (the agent's persistent
1391
+ * consume loop has ended, e.g. after a page reload). The gesture is
1392
+ * ALREADY on the pipe from the just-completed `submit_action` append;
1393
+ * this doorbell only wakes a fresh agent turn so it calls
1394
+ * `ggui_consume({renderId})` to drain it.
1100
1395
  *
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.
1396
+ * SINGLE SOURCE OF TRUTH: the pipe. This carries ONLY a pointer to the
1397
+ * render — never the action payload or uiContext. Carrying the payload
1398
+ * here would let the agent act on the inline copy AND drain the pipe =
1399
+ * a double-trigger (the action fires twice). Pointer-only ⇒ the agent
1400
+ * retrieves the gesture EXCLUSIVELY via `ggui_consume`, so it's
1401
+ * exactly-once by construction.
1104
1402
  */
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
- },
1403
+ function emitUserActionDoorbell(args) {
1404
+ // THE DIRECTIVE LIVES IN THIS TEXT. Every host (claude.ai,
1405
+ // chatgpt.com, ggui-aware SDKs) forwards the `ui/message` text to the
1406
+ // model verbatim — none of them runs a ggui-side synthesizer. So the
1407
+ // iframe-runtime is the single directive-authoring point. The
1408
+ // wording below is the empirically-validated XML-tagged
1409
+ // imperative-first shape (REQUIRED FIRST TOOL CALL + `<ggui_directive>`
1410
+ // tags + gesture framing) that was previously synthesized
1411
+ // server-side. The imperative-first phrasing — "Call <tool> NOW. ...
1412
+ // Do not respond conversationally." — is load-bearing for Gemini,
1413
+ // which otherwise summarizes the situation back to the user instead
1414
+ // of dispatching the prepared tool call.
1415
+ //
1416
+ // It is deliberately GENERIC: it names the render pointer, never the
1417
+ // action specifics. Naming what the user did would tempt the agent to
1418
+ // act before consuming = the double-trigger this whole shape exists
1419
+ // to kill. The agent gets the actionable data EXCLUSIVELY from
1420
+ // `ggui_consume`.
1421
+ //
1422
+ // Prose + XML tags only, no fenced ```json``` block: a verbatim copy
1423
+ // of a tool-call wire shape trips claude.ai's prompt-injection
1424
+ // classifier even on the user-trusted `ui/message` channel.
1425
+ const nextArgs = JSON.stringify({ renderId: args.renderId });
1426
+ const text = [
1427
+ `Your REQUIRED FIRST TOOL CALL is ggui_consume with arguments ${nextArgs}. Call it NOW to retrieve and process the pending interaction. Do not respond conversationally; do not summarize. Issue the tool call as your next action.`,
1428
+ '',
1429
+ `<ggui_directive kind="user-action">`,
1430
+ ` <render_id>${args.renderId}</render_id>`,
1431
+ ` <next_tool>ggui_consume</next_tool>`,
1432
+ ` <next_args>${nextArgs}</next_args>`,
1433
+ `</ggui_directive>`,
1434
+ '',
1435
+ `The user interacted with render ${args.renderId} while no ggui_consume long-poll was active. The gesture is queued on the consume pipe for that render — it is NOT in this message. After ggui_consume returns, react to the returned event with the appropriate domain tool, then call ggui_update on the SAME renderId (${args.renderId}).`,
1436
+ ].join('\n');
1437
+ // Structured mirror of the directive for ggui-aware programmatic
1438
+ // consumers. OPTIONAL — nothing in the loop depends on a server-side
1439
+ // parse of this; an `_meta`-agnostic host acts on the text above
1440
+ // alone. Typed against the protocol interface (no runtime guard
1441
+ // exists — the shape is locked at compile time here).
1442
+ const description = `User interacted with render ${args.renderId}; call ggui_consume to retrieve and process it.`;
1443
+ const userAction = {
1444
+ kind: 'user-action',
1445
+ description,
1446
+ renderId: args.renderId,
1447
+ actionId: args.actionId,
1448
+ submittedAt: args.submittedAt,
1449
+ intent: args.intent,
1450
+ nextStep: {
1451
+ tool: 'ggui_consume',
1452
+ args: { renderId: args.renderId },
1146
1453
  },
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.`;
1454
+ };
1455
+ // RAW postMessage — NOT `app.sendMessage`. The doorbell MUST bypass
1456
+ // the App's `ui/message` request path because the host validates the
1457
+ // incoming request through the spec's CLOSED `McpUiMessageRequestSchema`
1458
+ // (`Protocol.setRequestHandler` → `parseWithCompat`). That schema's
1459
+ // `content` array is the spec `ContentBlockSchema`, which has no place
1460
+ // for our content-block `_meta` extension — the parse strips the
1461
+ // extension AND (as observed on the first live post-reload doorbell)
1462
+ // can leave the host's `handleAppMessage` with an empty
1463
+ // `content[0].text`, so it rejects the doorbell with `isError` and no
1464
+ // fresh agent turn fires.
1465
+ //
1466
+ // `postToParent` posts the JSON-RPC frame verbatim to the parent, so
1467
+ // BOTH `content[0].text` (the load-bearing directive every host
1468
+ // forwards to the model) AND `content[0]._meta["ai.ggui/userAction"]`
1469
+ // (the optional structured mirror) survive intact. This is the same
1470
+ // deliberate raw-postMessage decision `ui/message` carried in #275 —
1471
+ // the userAction-collapse refactor regressed it onto `app.sendMessage`.
1472
+ //
1473
+ // Spec-canonical shape: `_meta` lives on the CONTENT BLOCK (the spec
1474
+ // closes `params._meta` via `additionalProperties: false`, but each
1475
+ // content block has its own `_meta: { [key: string]: unknown }` open
1476
+ // record — the proper extension point). Namespaced under
1477
+ // `ai.ggui/userAction` to match our other protocol extensions
1478
+ // (`ai.ggui/render`, `ai.ggui/bootstrap`, etc.).
1170
1479
  postToParent({
1171
1480
  jsonrpc: '2.0',
1172
1481
  id: Math.floor(Math.random() * 1e9),
1173
1482
  method: 'ui/message',
1174
1483
  params: {
1175
1484
  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
- },
1485
+ content: [
1486
+ {
1487
+ type: 'text',
1488
+ text,
1489
+ _meta: {
1490
+ 'ai.ggui/userAction': userAction,
1190
1491
  },
1191
1492
  },
1192
- },
1493
+ ],
1193
1494
  },
1194
1495
  });
1195
1496
  }
@@ -1197,7 +1498,7 @@ function emitUserActionQueued(args) {
1197
1498
  export function dispatchWiredAction(args) {
1198
1499
  if (typeof window === 'undefined')
1199
1500
  return;
1200
- const { toolName, intent, data, sessionId, stackItemId, appId, nextStep } = args;
1501
+ const { toolName, intent, data, renderId, appId } = args;
1201
1502
  const firedAt = new Date().toISOString();
1202
1503
  const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
1203
1504
  const inlineData = formatWiredActionDataInline(data);
@@ -1207,25 +1508,20 @@ export function dispatchWiredAction(args) {
1207
1508
  // LLM's widget-context surface regardless of which downstream path
1208
1509
  // catches the event (pipe or ui/message). Fire-and-forget; no
1209
1510
  // 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
- },
1511
+ callAppUpdateModelContext({
1512
+ content: [
1513
+ {
1514
+ type: 'text',
1515
+ text: `[ggui:pending-action] ${JSON.stringify({
1516
+ actionId,
1517
+ intent,
1518
+ data: data ?? null,
1519
+ firedAt,
1520
+ renderId,
1521
+ appId,
1522
+ })}`,
1523
+ },
1524
+ ],
1229
1525
  });
1230
1526
  // (1.5) Toast — pending state. User sees "→ Sending: archive"
1231
1527
  // immediately so they know the click was registered, even when
@@ -1238,32 +1534,32 @@ export function dispatchWiredAction(args) {
1238
1534
  // `consumerPresent`:
1239
1535
  // - true (or undefined): toast stays `pending`; drain_ack will
1240
1536
  // 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.
1537
+ // - false: no `ggui_consume` long-poll is currently listening
1538
+ // (the agent's persistent consume loop has ended — e.g. after a
1539
+ // page reload). Emit the `ai.ggui/userAction` PURE DOORBELL on a
1540
+ // `ui/message` so a fresh agent turn calls `ggui_consume` to
1541
+ // drain the gesture we just enqueued. No timer, no rescue, no
1542
+ // payload — the pipe is the single source of truth.
1244
1543
  //
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).
1544
+ // On any non-success outcome (PIPE_NOT_FOUND, transport error, host
1545
+ // has no relay) the gesture could NOT be enqueued; there is nothing
1546
+ // to point a doorbell at, so we surface a toast and stop. Post-reload
1547
+ // recovery for the abort-aware long-poll is a separate server-side
1548
+ // concern (#292); this client never inlines the action payload.
1249
1549
  void (async () => {
1250
1550
  let resp = null;
1251
1551
  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,
1552
+ resp = await callServerToolSpec(toolName, {
1553
+ kind: 'dispatch',
1554
+ payload: {
1555
+ intent,
1556
+ actionData: data ?? null,
1557
+ uiContext,
1266
1558
  },
1559
+ renderId,
1560
+ appId,
1561
+ actionId,
1562
+ firedAt,
1267
1563
  });
1268
1564
  }
1269
1565
  catch {
@@ -1272,37 +1568,25 @@ export function dispatchWiredAction(args) {
1272
1568
  }
1273
1569
  if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
1274
1570
  const consumerPresent = extractConsumerPresent(resp);
1275
- if (consumerPresent === false && stackItemId !== undefined) {
1571
+ if (consumerPresent === false) {
1276
1572
  showActionToast(`💬 ${intent}${dataPart} — agent not listening, sent to chat`, 'action_required');
1277
- emitUserActionQueued({
1573
+ emitUserActionDoorbell({
1278
1574
  intent,
1279
- stackItemId,
1575
+ renderId,
1280
1576
  actionId,
1281
1577
  submittedAt: firedAt,
1282
1578
  });
1283
1579
  return;
1284
1580
  }
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
1581
+ // consumerPresent is true (or undefined — agnostic host stripped
1582
+ // the field). Toast stays `pending`; drain_ack listener dismisses
1291
1583
  // it when ggui_consume drains the event.
1292
1584
  return;
1293
1585
  }
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
- });
1586
+ // Enqueue failed (pipe gone / transport error). The gesture is not
1587
+ // on any pipe, so a doorbell would point at an empty queue. Surface
1588
+ // the failure as a toast; no `ui/message` is emitted.
1589
+ showActionToast(`⚠ ${intent} — could not reach the agent`, 'error');
1306
1590
  })();
1307
1591
  }
1308
1592
  /**
@@ -1316,7 +1600,7 @@ export function dispatchWiredAction(args) {
1316
1600
  * PIPE-2 design note: Pattern α deliberately does NOT fire
1317
1601
  * `submit_action` — a pipe append would queue the gesture for the
1318
1602
  * agent's `ggui_consume` long-poll, causing double-processing
1319
- * (host's tool relay AND agent's reaction). SessionInspector loses
1603
+ * (host's tool relay AND agent's reaction). RenderInspector loses
1320
1604
  * Pattern α observability for now; re-add via a dedicated audit-only
1321
1605
  * gesture kind if operators need it.
1322
1606
  *
@@ -1360,11 +1644,28 @@ export function fireDirectToolCall(args) {
1360
1644
  *
1361
1645
  * @internal — exported for unit tests + production reuse.
1362
1646
  */
1647
+ export function resolveDispatchToolName() {
1648
+ // Operator escape hatch — `window.__GGUI_DISPATCH_TOOL__` lets a
1649
+ // host override the receiver tool name when running ggui under a
1650
+ // server that publishes the spec-canonical receiver under a
1651
+ // different name (the agent SDK extension surface). Default:
1652
+ // `ggui_runtime_submit_action` per the OSS handler at
1653
+ // `@ggui-ai/mcp-server-handlers::createGguiSubmitActionHandler`.
1654
+ if (typeof window === 'undefined')
1655
+ return 'ggui_runtime_submit_action';
1656
+ const override = window.__GGUI_DISPATCH_TOOL__;
1657
+ return typeof override === 'string' && override.length > 0
1658
+ ? override
1659
+ : 'ggui_runtime_submit_action';
1660
+ }
1661
+ /**
1662
+ * @internal — exported for unit tests + production reuse.
1663
+ */
1363
1664
  export function routeDispatch(args) {
1364
- const { actionName, data, bootstrap, dispatchToolName } = args;
1365
- const tool = bootstrap.actionNextSteps?.[actionName];
1665
+ const { actionName, data, meta, dispatchToolName } = args;
1666
+ const tool = meta.actionNextSteps?.[actionName];
1366
1667
  const appCallable = typeof tool === 'string' &&
1367
- (bootstrap.appCallableTools ?? []).includes(tool);
1668
+ (meta.appCallableTools ?? []).includes(tool);
1368
1669
  if (appCallable && tool !== undefined) {
1369
1670
  fireDirectToolCall({
1370
1671
  targetToolName: tool,
@@ -1372,20 +1673,18 @@ export function routeDispatch(args) {
1372
1673
  });
1373
1674
  }
1374
1675
  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.
1676
+ // Pattern β: enqueue the gesture onto the render's pending-event
1677
+ // pipe via `submit_action`. The agent retrieves it EXCLUSIVELY via
1678
+ // `ggui_consume` — the gesture never travels inline, so there is no
1679
+ // `nextStep` hint to forward (the doorbell carries only a pointer to
1680
+ // the render). This branch is also where
1681
+ // `actionSpec[*].dispatch.kind === 'agent'` lands.
1381
1682
  dispatchWiredAction({
1382
1683
  toolName: dispatchToolName,
1383
1684
  intent: actionName,
1384
1685
  data,
1385
- sessionId: bootstrap.sessionId,
1386
- stackItemId: bootstrap.stackItemId,
1387
- appId: bootstrap.appId,
1388
- ...(tool !== undefined ? { nextStep: tool } : {}),
1686
+ renderId: meta.renderId,
1687
+ appId: meta.appId,
1389
1688
  });
1390
1689
  }
1391
1690
  }
@@ -1398,7 +1697,7 @@ export function routeDispatch(args) {
1398
1697
  export function openLinkInParent(args) {
1399
1698
  if (typeof window === 'undefined')
1400
1699
  return;
1401
- const { toolName, url, sessionId, stackItemId, appId } = args;
1700
+ const { toolName, url, renderId, appId } = args;
1402
1701
  if (typeof url !== 'string' || url.length === 0) {
1403
1702
  throw new RangeError('wire.openLink(url): `url` must be a non-empty string.');
1404
1703
  }
@@ -1408,18 +1707,12 @@ export function openLinkInParent(args) {
1408
1707
  toolName,
1409
1708
  kind: 'openLink',
1410
1709
  payload: { url },
1411
- sessionId,
1412
- stackItemId,
1710
+ renderId,
1413
1711
  appId,
1414
1712
  actionId,
1415
1713
  firedAt,
1416
1714
  });
1417
- postToParent({
1418
- jsonrpc: '2.0',
1419
- id: Math.floor(Math.random() * 1e9),
1420
- method: 'ui/open-link',
1421
- params: { url },
1422
- });
1715
+ callAppOpenLink({ url });
1423
1716
  }
1424
1717
  /**
1425
1718
  * `ui/request-display-mode` direct dispatch helper — used by the
@@ -1431,25 +1724,19 @@ export function openLinkInParent(args) {
1431
1724
  export function requestDisplayModeInParent(args) {
1432
1725
  if (typeof window === 'undefined')
1433
1726
  return;
1434
- const { toolName, mode, sessionId, stackItemId, appId } = args;
1727
+ const { toolName, mode, renderId, appId } = args;
1435
1728
  const firedAt = new Date().toISOString();
1436
1729
  const actionId = fnv1aHex(`requestDisplayMode|${mode}|${firedAt}`);
1437
1730
  emitAudit({
1438
1731
  toolName,
1439
1732
  kind: 'requestDisplayMode',
1440
1733
  payload: { mode },
1441
- sessionId,
1442
- stackItemId,
1734
+ renderId,
1443
1735
  appId,
1444
1736
  actionId,
1445
1737
  firedAt,
1446
1738
  });
1447
- postToParent({
1448
- jsonrpc: '2.0',
1449
- id: Math.floor(Math.random() * 1e9),
1450
- method: 'ui/request-display-mode',
1451
- params: { mode },
1452
- });
1739
+ callAppRequestDisplayMode({ mode });
1453
1740
  }
1454
1741
  /**
1455
1742
  * Native-idiom interceptor for anchor clicks.
@@ -1494,7 +1781,7 @@ export function installAnchorClickInterceptor(args) {
1494
1781
  if (typeof document === 'undefined')
1495
1782
  return;
1496
1783
  anchorClickInterceptInstalled = true;
1497
- const { dispatchToolName, sessionId, stackItemId, appId } = args;
1784
+ const { dispatchToolName, renderId, appId } = args;
1498
1785
  const onClick = (event) => {
1499
1786
  if (event.defaultPrevented)
1500
1787
  return;
@@ -1539,8 +1826,7 @@ export function installAnchorClickInterceptor(args) {
1539
1826
  openLinkInParent({
1540
1827
  toolName: dispatchToolName,
1541
1828
  url: absoluteHref,
1542
- sessionId,
1543
- stackItemId,
1829
+ renderId,
1544
1830
  appId,
1545
1831
  });
1546
1832
  };
@@ -1580,7 +1866,7 @@ export function installAnchorClickInterceptor(args) {
1580
1866
  *
1581
1867
  * Idempotent: the {@link fullscreenInterceptInstalled} guard prevents
1582
1868
  * re-mounts from chaining wrappers (which would also leak the prior
1583
- * mount's `sessionId`/`appId` if they ever differed).
1869
+ * mount's `renderId`/`appId` if they ever differed).
1584
1870
  */
1585
1871
  /** @internal — exported for unit tests. */
1586
1872
  export function installFullscreenInterceptors(args) {
@@ -1592,13 +1878,12 @@ export function installFullscreenInterceptors(args) {
1592
1878
  return;
1593
1879
  }
1594
1880
  fullscreenInterceptInstalled = true;
1595
- const { dispatchToolName, sessionId, stackItemId, appId } = args;
1881
+ const { dispatchToolName, renderId, appId } = args;
1596
1882
  Element.prototype.requestFullscreen = function (_options) {
1597
1883
  requestDisplayModeInParent({
1598
1884
  toolName: dispatchToolName,
1599
1885
  mode: 'fullscreen',
1600
- sessionId,
1601
- stackItemId,
1886
+ renderId,
1602
1887
  appId,
1603
1888
  });
1604
1889
  return Promise.resolve();
@@ -1607,8 +1892,7 @@ export function installFullscreenInterceptors(args) {
1607
1892
  requestDisplayModeInParent({
1608
1893
  toolName: dispatchToolName,
1609
1894
  mode: 'inline',
1610
- sessionId,
1611
- stackItemId,
1895
+ renderId,
1612
1896
  appId,
1613
1897
  });
1614
1898
  return Promise.resolve();
@@ -1625,533 +1909,155 @@ export function __resetInterceptorsForTest() {
1625
1909
  fullscreenInterceptInstalled = false;
1626
1910
  }
1627
1911
  /**
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-
1912
+ * Install a persistent `app.addEventListener('toolresult', …)` listener
1913
+ * that catches `ui/notifications/tool-result` notifications arriving
1914
+ * AFTER the initial mount. Each new tool-result that carries a
1915
+ * different bootstrap (different renderId / codeUrl / kind) triggers
1916
+ * a re-mount via {@link bootSelfContained}. This closes the boot-only-
1633
1917
  * listener gap that prevented live re-render when an agent issued
1634
- * a second `ggui_push` to the same session-resource.
1918
+ * a second `ggui_render` to the same render-resource.
1635
1919
  *
1636
1920
  * Idempotent: subsequent calls no-op via {@link postMountListenerInstalled}.
1637
1921
  *
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
1922
+ * Why module-level vs scoped to a single mount: bootSelfContained may
1923
+ * itself trigger a re-mount, and the listener should outlive any
1924
+ * single mount cycle. The guard ensures we don't stack listeners
1641
1925
  * across re-mounts.
1926
+ *
1927
+ * Spec-canonical (post-Phase-1.19b.3): the previous hand-rolled
1928
+ * `window.addEventListener('message', …)` is gone; App handles every
1929
+ * inbound `ui/notifications/tool-result` envelope and dispatches via
1930
+ * its event system. Per-iframe single-tenancy means the App handle
1931
+ * comes from `getCurrentApp()` — set by bootSelfContained's own
1932
+ * connect path before this helper runs.
1642
1933
  */
1643
1934
  function installPostMountListener() {
1644
1935
  if (postMountListenerInstalled)
1645
1936
  return;
1646
- if (typeof window === 'undefined')
1647
- return;
1937
+ const app = getCurrentApp();
1938
+ if (app === null)
1939
+ return; // pre-connect; caller bug, swallow safely
1648
1940
  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)
1941
+ let lastMetaKey = null;
1942
+ app.addEventListener('toolresult', (params) => {
1943
+ const meta = extractMetaFromToolResult(params);
1944
+ if (meta === null)
1660
1945
  return;
1661
1946
  // Cheap dedupe — the host may emit the same tool-result more
1662
1947
  // than once (claude.ai re-broadcasts on iframe re-attach).
1663
- // Re-mounting the same bootstrap would flicker without changing
1948
+ // Re-mounting the same slice meta would flicker without changing
1664
1949
  // anything visible.
1665
1950
  //
1666
1951
  // `liveTrio` is load-bearing in the dedupe key: hosts (sample-agent,
1667
- // claude.ai) often emit the initial bootstrap WITHOUT the wsUrl+token
1952
+ // claude.ai) often emit the initial meta WITHOUT the wsUrl+token
1668
1953
  // pair (the Anthropic SDK strips `_meta` from tool results), then
1669
1954
  // refetch + re-emit the FULL envelope. The two envelopes share
1670
- // stackItemId/kind/codeUrl/propsJson but differ on the live trio —
1955
+ // renderId/kind/codeUrl/propsJson but differ on the live trio —
1671
1956
  // without trio in the key, the second arrival deduped silently and
1672
1957
  // bootSelfContained never opened the WS, so `ggui_update` props_update
1673
1958
  // 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
1959
+ const liveTrio = typeof meta.wsUrl === 'string' &&
1960
+ meta.wsUrl.length > 0 &&
1961
+ typeof meta.wsToken === 'string' &&
1962
+ meta.wsToken.length > 0
1679
1963
  ? 'live'
1680
1964
  : '-';
1681
1965
  const key = [
1682
- bootstrap.stackItemId ?? '-',
1683
- bootstrap.kind ?? '-',
1684
- codeUrl ?? '-',
1685
- bootstrap.propsJson ?? '-',
1966
+ meta.renderId,
1967
+ meta.kind ?? '-',
1968
+ meta.codeUrl ?? '-',
1969
+ meta.propsJson ?? '-',
1686
1970
  liveTrio,
1687
1971
  ].join('|');
1688
- if (key === lastBootstrapKey)
1972
+ if (key === lastMetaKey)
1689
1973
  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);
1974
+ lastMetaKey = key;
1975
+ // Re-mount through the EXISTING renderer's `applyRender` (published
1976
+ // module-level by bootProduction's setup) — NOT a fresh boot. One
1977
+ // mount surface, one WS; the prior stale-closure / second-WS race
1978
+ // (the #290 root cause) is structurally impossible. Project the new
1979
+ // tool-result meta into a seed and re-apply it.
1980
+ const apply = activeApplyRender;
1981
+ if (apply === null)
1982
+ return; // no renderer published yet — no-op safely
1983
+ void buildRenderSeedInput(meta).then((seed) => {
1984
+ if (seed === null)
1985
+ return;
1986
+ void apply(seed).then(() => {
1987
+ // Re-emit last-known contextSpec values after a successful
1988
+ // re-mount — keeps the LLM context fresh after a host-driven
1989
+ // reconnect; the new mount's SingleSlotProviders take over via
1990
+ // the regular debounced flow once the component mutates values.
1991
+ // Filter to slot names declared by the FRESHLY mounted contract.
1992
+ const activeSlotNames = new Set((meta.contextSlots ?? []).map((s) => s.name));
1993
+ reemitLastContextValues(productionContextSnapshotPoster, activeSlotNames, { renderId: meta.renderId, appId: meta.appId });
1994
+ });
1716
1995
  });
1717
- };
1718
- window.addEventListener('message', onMessage);
1996
+ });
1719
1997
  }
1720
1998
  /**
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.
1999
+ * Detect a live-channel bootstrap shape inlined onto `__GGUI_META__`.
2000
+ * The first-party render shells (`/r/<shortCode>`,
2001
+ * `ui://ggui/render/<renderId>`, the embedded-ui RenderViewer's thin
2002
+ * shell) populate this synchronously before the runtime loads.
1726
2003
  *
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`.
2004
+ * Post-Phase-B the global carries a slice ENVELOPE (same shape as the
2005
+ * wire `_meta`): `{ "ai.ggui/render": {...} }`. A live-channel shell
2006
+ * omits static content (no codeUrl / kind) and ships wsUrl+token
2007
+ * inside the render slice instead.
2096
2008
  *
2097
2009
  * This predicate exists so the autostart path can distinguish
2098
- * "shell-inlined a WS bootstrap, run `bootProduction` immediately"
2010
+ * "shell-inlined a live bootstrap, run `bootProduction` immediately"
2099
2011
  * from "no bootstrap yet, race the tool-result postMessage with a
2100
2012
  * 30s timeout". Without the distinction, OSS shells that never emit
2101
2013
  * a separate `ui/notifications/tool-result` (because the bootstrap
2102
2014
  * is delivered via the `ui/initialize` Reading-B path) hang at
2103
2015
  * `mounting` for the full 30s before `code-ready`.
2104
2016
  */
2105
- function readGguiBootstrapMetaShape() {
2017
+ function readLiveBootstrapShape() {
2106
2018
  if (typeof window === 'undefined')
2107
2019
  return false;
2108
2020
  const raw = window
2109
- .__GGUI_BOOTSTRAP__;
2021
+ .__GGUI_META__;
2110
2022
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
2111
2023
  return false;
2112
2024
  }
2113
2025
  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);
2026
+ const renderRaw = bag['ai.ggui/render'];
2027
+ if (renderRaw === null || typeof renderRaw !== 'object' || Array.isArray(renderRaw)) {
2028
+ return false;
2029
+ }
2030
+ const renderBag = renderRaw;
2031
+ return (typeof renderBag['wsUrl'] === 'string' &&
2032
+ renderBag['wsUrl'].length > 0 &&
2033
+ typeof renderBag['wsToken'] === 'string' &&
2034
+ renderBag['wsToken'].length > 0 &&
2035
+ typeof renderBag['renderId'] === 'string' &&
2036
+ renderBag['renderId'].length > 0 &&
2037
+ typeof renderBag['appId'] === 'string' &&
2038
+ renderBag['appId'].length > 0);
2122
2039
  }
2123
2040
  /**
2124
2041
  * Hand off to `bootProduction` with the standard wiring (postRendererReady
2125
2042
  * + postBootFailure + observability + lifecycle). Extracted so both the
2126
- * GguiBootstrapMeta-inlined fast path and the post-tool-result-timeout
2043
+ * live-channel-inlined fast path and the post-tool-result-timeout
2127
2044
  * fallback share one call site — keeps the WS-driven boot semantics
2128
2045
  * single-sourced.
2046
+ *
2047
+ * `preResolvedMeta` is threaded in from the autostart resolver when
2048
+ * it has already discovered slice meta — via inline `__GGUI_META__`
2049
+ * global, a buffered `__GGUI_PENDING_TOOL_RESULTS__` entry, or an
2050
+ * early `ui/notifications/tool-result` postMessage. Threading skips
2051
+ * `bootSequence`'s internal resolver chain (which would otherwise
2052
+ * re-await the same postMessage or re-parse the same global), saving
2053
+ * up to the 30s postMessage timeout for spec-strict hosts.
2129
2054
  */
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
- };
2055
+ function runBootProduction(preResolvedMeta) {
2056
+ const { app, transport } = createDefaultApp();
2152
2057
  void bootProduction({
2153
2058
  doc: document,
2154
- callUiInitialize,
2059
+ app,
2060
+ transport,
2155
2061
  notifyParent: (msg) => {
2156
2062
  if (msg.type === 'ggui:renderer-ready') {
2157
2063
  postRendererReady();
@@ -2161,104 +2067,47 @@ function runBootProduction() {
2161
2067
  },
2162
2068
  onObserve: postObservabilityToParent,
2163
2069
  onLifecycle: postLifecycleToParent,
2070
+ ...(preResolvedMeta !== undefined ? { preResolvedMeta } : {}),
2164
2071
  });
2165
2072
  }
2166
2073
  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.
2074
+ // Boot-source resolution — collapsed post-consolidation. The
2075
+ // static-vs-live FORK is gone: every meta flows through the ONE
2076
+ // unified path (`runBootProduction` → `bootProduction` →
2077
+ // `bootSequence`), which decides internally whether to seed-mount
2078
+ // static content (codeUrl/kind), open a WS subscribe (wsUrl+wsToken),
2079
+ // or both. The autostart layer's only job is to RESOLVE the meta from
2080
+ // one of three delivery channels:
2176
2081
  //
2082
+ // 1. Inline `__GGUI_META__` global — first-party shells populate
2083
+ // this synchronously before this bundle evaluates.
2177
2084
  // 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).
2085
+ // pattern (postMessage arrived before the bundle parsed).
2086
+ // 3. Live `ui/notifications/tool-result` postMessage — the
2087
+ // spec-canonical channel for spec-strict hosts (`<AppRenderer>`,
2088
+ // ChatGPT, claude.ai). Caught meta threads through as
2089
+ // `preResolvedMeta` so `bootSequence` doesn't re-await it.
2188
2090
  //
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();
2091
+ // `readLiveBootstrapShape` still short-circuits the 30s tool-result
2092
+ // wait when a live-channel envelope is already inlined — OSS embedded
2093
+ // shells that never emit a separate tool-result would otherwise hang
2094
+ // at `mounting` for the full timeout.
2095
+ const inline = readSelfContainedMeta();
2096
+ if (inline !== null) {
2097
+ runBootProduction(inline);
2216
2098
  }
2217
2099
  else {
2218
2100
  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);
2101
+ if (buffered !== null) {
2102
+ runBootProduction(buffered);
2224
2103
  }
2225
- else if (buffered !== null) {
2104
+ else if (readLiveBootstrapShape()) {
2226
2105
  runBootProduction();
2227
2106
  }
2228
2107
  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
- }
2108
+ void awaitToolResultMeta(POSTMESSAGE_BOOT_TIMEOUT_MS).then((postMessageMeta) => {
2109
+ runBootProduction(postMessageMeta ?? undefined);
2110
+ });
2262
2111
  }
2263
2112
  }
2264
2113
  }
@@ -2283,10 +2132,15 @@ async function bootProduction(opts) {
2283
2132
  import('./globals.js'),
2284
2133
  import('./gadget-loader.js'),
2285
2134
  ]);
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 }) => {
2135
+ // Renderer wiring hook — constructs buses + single-render mount surface
2136
+ // + wire config on demand inside bootSequence.
2137
+ const renderer = {
2138
+ setup: ({ meta, renderInto, statusRefs, onObserve }) => {
2139
+ // Post-Phase-B `meta` is the flat render slice — `renderId` /
2140
+ // `appId` / `runtimeUrl` / `wsUrl` / `wsToken` / `themeId` /
2141
+ // `gadgets` / `publicEnv` / `contextSlots` / `actionNextSteps` /
2142
+ // `appCallableTools` / `streamWebSocketLocalTools` all live
2143
+ // directly on `meta`.
2290
2144
  // Compose the gadget registry: STDLIB seed PLUS any
2291
2145
  // operator-registered wrappers carried on the bootstrap.
2292
2146
  // Awaited inside the synchronous `setup` callback via an IIFE
@@ -2295,13 +2149,13 @@ async function bootProduction(opts) {
2295
2149
  // is fast (already-resolved bundles hit the module cache).
2296
2150
  //
2297
2151
  // 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.
2152
+ // omits `gadgets`. The install happens BEFORE any render mounts
2153
+ // so `rewrite-imports.ts` reads a fully-populated registry on
2154
+ // the first `loadModule` call.
2301
2155
  const composedGadgets = gadgetLoaderMod
2302
2156
  // Empty registrations → resolves to a Promise of the STDLIB
2303
2157
  // seed only; the boot path doesn't wait on wrapper imports.
2304
- .loadGadgetRegistry(gadgetsMod, bootstrap.gadgets ?? []);
2158
+ .loadGadgetRegistry(gadgetsMod, meta.gadgets ?? []);
2305
2159
  // Install with the synchronous STDLIB seed first; the WS-driven
2306
2160
  // boot path doesn't await wrapper loads here (the registry
2307
2161
  // install is TOCTOU-critical and synchronous). The Promise's
@@ -2322,8 +2176,8 @@ async function bootProduction(opts) {
2322
2176
  gadgets: { '@ggui-ai/gadgets': gadgetsMod },
2323
2177
  // Public env values from the bootstrap. The WS-driven path
2324
2178
  // receives bootstrap synchronously via
2325
- // setup({bootstrap, ...}) so installing here is correct.
2326
- publicEnv: bootstrap.publicEnv ?? {},
2179
+ // setup({meta, ...}) so installing here is correct.
2180
+ publicEnv: meta.publicEnv ?? {},
2327
2181
  });
2328
2182
  // Merge 3rd-party package namespaces into the LIVE slot object —
2329
2183
  // not a slot replacement. The per-package data-URL shims read
@@ -2351,24 +2205,19 @@ async function bootProduction(opts) {
2351
2205
  // SingleSlotProvider per declared slot) hoists useState into the
2352
2206
  // runtime so `useGguiContext(slot)` reads the live tuple.
2353
2207
  const registry = globalsMod.getGlobalRegistry();
2354
- const resolvedSlots = registry !== undefined && bootstrap.contextSlots !== undefined
2355
- ? installContextRegistry(registry.contexts, reactMod, bootstrap.contextSlots)
2208
+ const resolvedSlots = registry !== undefined && meta.contextSlots !== undefined
2209
+ ? installContextRegistry(registry.contexts, reactMod, meta.contextSlots)
2356
2210
  : [];
2357
2211
  const ContextStateHost = createContextStateHost({
2358
2212
  react: reactMod,
2359
- postToParent,
2213
+ poster: productionContextSnapshotPoster,
2360
2214
  consoleWarn: typeof console !== 'undefined' && typeof console.warn === 'function'
2361
2215
  ? console.warn.bind(console)
2362
2216
  : undefined,
2363
- ...(bootstrap.stackItemId !== undefined
2364
- ? {
2365
- identity: {
2366
- sessionId: bootstrap.sessionId,
2367
- appId: bootstrap.appId,
2368
- stackItemId: bootstrap.stackItemId,
2369
- },
2370
- }
2371
- : {}),
2217
+ identity: {
2218
+ renderId: meta.renderId,
2219
+ appId: meta.appId,
2220
+ },
2372
2221
  });
2373
2222
  const streamBus = new StreamBus();
2374
2223
  // Buffered send shim — the real WS handle isn't available until
@@ -2396,64 +2245,165 @@ async function bootProduction(opts) {
2396
2245
  }
2397
2246
  },
2398
2247
  };
2399
- const { config: rootConfig, buildScopedConfig } = buildRootWireConfig({
2400
- sessionId: bootstrap.sessionId,
2401
- appId: bootstrap.appId,
2402
- getStack: () => stackModel.snapshot(),
2248
+ // Spec-canonical outbound dispatch. The WS pipe is for streamSpec
2249
+ // subscriptions ONLY (inbound `ggui_emit` fanout + `props_update` +
2250
+ // `render` + `data` + `feedback` + `drain_ack` + `channel_payload`).
2251
+ // Outbound user actions go through the MCP-Apps host relay per
2252
+ // spec §401: postMessage `tools/call:ggui_runtime_submit_action`
2253
+ // to the parent → `AppRenderer.onCallTool` → sample agent's
2254
+ // `/relay/tools-call` → ggui MCP server →
2255
+ // `createGguiSubmitActionHandler.append` → `pendingEventConsumer`
2256
+ // → `ggui_consume` wakes the agent. The server's WS
2257
+ // `handleInboundAction` writes to the session ledger only — no
2258
+ // downstream consumer — so the WS action path silently drops
2259
+ // clicks. `routeDispatch` is the same helper `bootSelfContained`
2260
+ // uses; threading it here aligns LIVE-mode with self-contained.
2261
+ // Single mounted render — populated by `applyRender` on the first
2262
+ // render frame. The wire config + data channel handler read it
2263
+ // through `currentRender`-returning thunks so they always see the
2264
+ // latest snapshot without holding stale refs.
2265
+ let currentRender = null;
2266
+ let renderHandle = null;
2267
+ const dispatchToolName = resolveDispatchToolName();
2268
+ // Native-idiom interceptors — the replacement for the retired
2269
+ // openLink / requestDisplayMode wire primitives. Installed here
2270
+ // (module-guarded, idempotent) so EVERY component render mounted
2271
+ // through this single surface — the WS-ack render, the inline
2272
+ // static seed, and re-mounts — has anchor-click + fullscreen
2273
+ // capture live before the first `applyRender`. Pre-consolidation
2274
+ // these lived only in `bootSelfContained`; the WS-driven path
2275
+ // installed neither, so anchor clicks + fullscreen requests in a
2276
+ // live-rendered component silently no-op'd.
2277
+ installAnchorClickInterceptor({
2278
+ dispatchToolName,
2279
+ renderId: meta.renderId,
2280
+ appId: meta.appId,
2281
+ });
2282
+ installFullscreenInterceptors({
2283
+ dispatchToolName,
2284
+ renderId: meta.renderId,
2285
+ appId: meta.appId,
2286
+ });
2287
+ const rootConfig = buildRootWireConfig({
2288
+ renderId: meta.renderId,
2289
+ appId: meta.appId,
2290
+ getCurrentRender: () => currentRender,
2403
2291
  manager,
2404
2292
  streamBus,
2405
2293
  ...(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,
2294
+ onDispatchEnvelope: (envelope) => {
2295
+ if (envelope.type !== 'data:submit')
2296
+ return;
2297
+ const payload = envelope.payload;
2298
+ if (payload === undefined
2299
+ || typeof payload.action !== 'string'
2300
+ || payload.action.length === 0) {
2301
+ return;
2302
+ }
2303
+ routeDispatch({
2304
+ actionName: payload.action,
2305
+ data: payload.data,
2306
+ meta: {
2307
+ renderId: meta.renderId,
2308
+ appId: meta.appId,
2309
+ ...(meta.appCallableTools !== undefined
2310
+ ? { appCallableTools: meta.appCallableTools }
2311
+ : {}),
2312
+ ...(meta.actionNextSteps !== undefined
2313
+ ? { actionNextSteps: meta.actionNextSteps }
2314
+ : {}),
2315
+ },
2316
+ dispatchToolName,
2451
2317
  });
2452
2318
  },
2453
- streamBus,
2454
- sessionId: bootstrap.sessionId,
2319
+ });
2320
+ // Build the wrap factory used by every mount React tree:
2321
+ // `<ContextStateHost slots={resolvedSlots}>` so contextSpec
2322
+ // values flow through `ui/update-model-context` exactly like the
2323
+ // self-contained path. mcpApps + system renders skip the wrap
2324
+ // (their renderers don't run user component code that reads
2325
+ // contexts). When `resolvedSlots` is empty ContextStateHost
2326
+ // short-circuits to a Fragment, so the wrap is free for renders
2327
+ // with no contextSpec.
2328
+ const buildOuterWrapper = (render) => {
2329
+ if (render.type === 'mcpApps' || render.type === 'system')
2330
+ return undefined;
2331
+ return (mountedTree) => reactMod.createElement(ContextStateHost, {
2332
+ slots: resolvedSlots,
2333
+ children: mountedTree,
2334
+ });
2335
+ };
2336
+ // Build the scoped wire config for the active render. mcpApps +
2337
+ // system renders get NO wire config — their iframe / built-in
2338
+ // host has its own contract (adapter-boundary rule).
2339
+ // Post-render-identity-collapse the WireConfig is bound to the
2340
+ // single render at boot, so there's no per-render scope factory
2341
+ // — every dispatch resolves through `getCurrentRender`.
2342
+ const buildScopedWireFor = (render) => {
2343
+ if (render.type === 'mcpApps' || render.type === 'system')
2344
+ return null;
2345
+ return rootConfig;
2455
2346
  };
2456
- const stackRenderer = new StackRenderer(stackCtx);
2347
+ // Build the renderer options for the active render. Theme is
2348
+ // forwarded from the bootstrap so the mounted React tree injects
2349
+ // the configured theme's CSS vars (indigo, claudic, etc).
2350
+ // Without this, react-renderer.ts falls back to
2351
+ // `getScopedCssTokens` (no preset) and the iframe renders with
2352
+ // the default ggui theme even when `_meta["ai.ggui/render"].themeId`
2353
+ // is `'indigo'`. Sibling `bootSelfContained` path threads the
2354
+ // same fields onto its `mountOpts` for parity.
2355
+ const buildOpts = (render) => {
2356
+ const wrapOuter = buildOuterWrapper(render);
2357
+ return {
2358
+ render,
2359
+ scopedWireConfig: buildScopedWireFor(render),
2360
+ streamBus,
2361
+ renderId: meta.renderId,
2362
+ // Thread the bootstrap's 3rd-party gadget packages so the import
2363
+ // rewriter resolves non-STDLIB gadget imports even for a static
2364
+ // seed mount (which carries no gadgetDescriptors). A full
2365
+ // WS-delivered Render carries gadgetDescriptors and ignores this.
2366
+ ...(meta.gadgets !== undefined
2367
+ ? { gadgetPackages: meta.gadgets.map((g) => g.package) }
2368
+ : {}),
2369
+ ...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
2370
+ ...(meta.themeMode !== undefined
2371
+ ? { themeMode: meta.themeMode }
2372
+ : {}),
2373
+ ...(wrapOuter !== undefined ? { wrapOuter } : {}),
2374
+ };
2375
+ };
2376
+ /**
2377
+ * Mount-or-update the single render slot. First call mounts the
2378
+ * React tree into `renderInto`; subsequent calls re-apply via
2379
+ * `renderHandle.update` (same kind ⇒ in-place props update; kind
2380
+ * transition ⇒ tear-down + remount via `RenderItemHandle`'s own
2381
+ * lifecycle).
2382
+ *
2383
+ * Shared by the render-frame and props_update channel handlers;
2384
+ * closes over `currentRender` + `renderHandle` so the channel
2385
+ * layer just calls `applyRender(render)` without owning
2386
+ * lifecycle state.
2387
+ */
2388
+ const applyRender = async (render) => {
2389
+ currentRender = render;
2390
+ if (renderHandle === null) {
2391
+ renderHandle = await mountRender(renderInto, buildOpts(render));
2392
+ return;
2393
+ }
2394
+ await renderHandle.update(buildOpts(render));
2395
+ };
2396
+ // Publish `applyRender` module-level + install the persistent
2397
+ // post-mount tool-result listener. On a host-re-emitted tool-result
2398
+ // (claude.ai re-broadcast; the Anthropic-SDK-strips-_meta refetch),
2399
+ // the listener re-mounts through THIS `applyRender` — no fresh boot,
2400
+ // no second WS. The no-WS live-re-render channel; for WS hosts the
2401
+ // render-frame handler already covers re-render, so it's a
2402
+ // redundant-safe fallback (guarded by the renderId pin + liveTrio
2403
+ // dedupe). Runs only on the production renderer path (tests that
2404
+ // drive bootSequence without a renderer never reach setup()).
2405
+ activeApplyRender = applyRender;
2406
+ installPostMountListener();
2457
2407
  // Validator context — A2UI default for `_ggui:preview`; no
2458
2408
  // bootstrap-supplied overrides today (the
2459
2409
  // `extraReservedValidators` injection slot is reserved for a
@@ -2461,35 +2411,33 @@ async function bootProduction(opts) {
2461
2411
  const validatorCtx = {
2462
2412
  reservedValidators: mergeReservedValidators(undefined, undefined),
2463
2413
  };
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).
2414
+ // Per-channel transport router. Created here so it shares the
2415
+ // buffered manager shim (and survives the pre-attachManager send
2416
+ // buffering) + the same StreamBus the wire config emits onto.
2417
+ // The router consults `bootstrap.streamWebSocketLocalTools` to
2418
+ // decide WS-subscribe vs iframe-polling per channel; absent ⇒
2419
+ // universal polling fallback. Activated lazily by the render-
2420
+ // frame handler (which calls `channelTransport.applyRender` on
2421
+ // every render fold).
2473
2422
  const channelTransport = createChannelTransportRouter({
2474
- sessionId: bootstrap.sessionId,
2475
- appId: bootstrap.appId,
2476
- ...(bootstrap.streamWebSocketLocalTools !== undefined
2423
+ renderId: meta.renderId,
2424
+ appId: meta.appId,
2425
+ ...(meta.streamWebSocketLocalTools !== undefined
2477
2426
  ? {
2478
- streamWebSocketLocalTools: bootstrap.streamWebSocketLocalTools,
2427
+ streamWebSocketLocalTools: meta.streamWebSocketLocalTools,
2479
2428
  }
2480
2429
  : {}),
2481
2430
  send: (msg) => manager.send(msg),
2482
2431
  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
- });
2432
+ // Iframe-polling transport — `tools/call` against the
2433
+ // parent MCP host via `app.callServerTool` (spec-canonical)
2434
+ // when the App handle is set; falls back to raw postMessage
2435
+ // pre-handshake or in tests. Pattern α direct call (no LLM
2436
+ // consent loop). Returns the tool's structuredContent (or
2437
+ // `content[0]` if that's where the payload landed) as a
2438
+ // JsonValue. On RPC error we throw — the router catches
2439
+ // and silently retries on the next tick.
2440
+ const resp = await callServerToolSpec(toolName, args);
2493
2441
  if (resp.error !== undefined) {
2494
2442
  throw new Error(resp.error.message ?? 'tools/call failed');
2495
2443
  }
@@ -2514,8 +2462,8 @@ async function bootProduction(opts) {
2514
2462
  }
2515
2463
  : {}),
2516
2464
  });
2517
- // B3b — channel-client registry owns dispatch for every routable
2518
- // WS frame type. Each handler closes over the triad state it
2465
+ // B3b — live-channel registry owns dispatch for every routable
2466
+ // WS frame type. Each handler closes over the renderer state it
2519
2467
  // needs. `bootSequence` calls `connectFn` (default
2520
2468
  // `connectViaRegistry`) which registers the `ack` + `error`
2521
2469
  // handshake handlers and binds the WS transport — frames then
@@ -2528,45 +2476,29 @@ async function bootProduction(opts) {
2528
2476
  subscribeFrameBuilder: () => ({
2529
2477
  type: 'subscribe',
2530
2478
  payload: {
2531
- sessionId: bootstrap.sessionId,
2532
- appId: bootstrap.appId,
2533
- ...(bootstrap.token !== undefined
2534
- ? { bootstrap: bootstrap.token }
2479
+ renderId: meta.renderId,
2480
+ appId: meta.appId,
2481
+ ...(meta.wsToken !== undefined
2482
+ ? { wsToken: meta.wsToken }
2535
2483
  : {}),
2536
2484
  },
2537
2485
  }),
2538
2486
  });
2539
- channelRegistry.register(createPushHandler({
2540
- stackModel,
2487
+ channelRegistry.register(createRenderHandler({
2541
2488
  statusRefs,
2542
- getStackRenderer: () => stackRenderer,
2489
+ pinnedRenderId: meta.renderId,
2490
+ applyRender,
2543
2491
  getChannelTransport: () => channelTransport,
2544
2492
  }));
2545
2493
  channelRegistry.register(createDataHandler({
2546
- stackModel,
2494
+ getCurrentRender: () => currentRender,
2547
2495
  streamBus,
2548
2496
  validatorCtx,
2549
2497
  ...(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
2498
  }));
2557
2499
  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
- : {}),
2500
+ getCurrentRender: () => currentRender,
2501
+ applyRender,
2570
2502
  }));
2571
2503
  channelRegistry.register(createDrainAckHandler({ dispatch: dispatchDrainAck }));
2572
2504
  channelRegistry.register(createFeedbackHandler());
@@ -2582,11 +2514,13 @@ async function bootProduction(opts) {
2582
2514
  return {
2583
2515
  rootWireConfig: rootConfig,
2584
2516
  streamBus,
2585
- stackRenderer,
2517
+ applyRender,
2518
+ getCurrentRender: () => currentRender,
2586
2519
  validatorCtx,
2587
2520
  manager,
2588
2521
  channelTransport,
2589
2522
  channelRegistry,
2523
+ composedGadgets,
2590
2524
  };
2591
2525
  },
2592
2526
  attachManager: (handle, realManager) => {
@@ -2597,27 +2531,27 @@ async function bootProduction(opts) {
2597
2531
  }
2598
2532
  },
2599
2533
  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.
2534
+ // The React mount lifecycle is owned by the per-setup
2535
+ // `renderHandle` closure; it stays null until the first render
2536
+ // frame lands. Today bootSequence's failure paths fire before
2537
+ // any render frame (handshake errors arrive before the first
2538
+ // ack), so unmounting the React tree from here is unnecessary.
2539
+ // The only thing the teardown hook still owns is the per-channel
2540
+ // polling timer + transport subscription registry — no-op when
2541
+ // no channel was activated.
2603
2542
  handle.channelTransport.dispose();
2604
2543
  },
2605
2544
  };
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
2545
  await bootSequence({
2615
2546
  doc: opts.doc,
2616
- callUiInitialize: opts.callUiInitialize,
2547
+ app: opts.app,
2548
+ transport: opts.transport,
2617
2549
  notifyParent: opts.notifyParent,
2618
- triadWiring,
2619
- canvasMount,
2550
+ renderer,
2620
2551
  ...(opts.onObserve !== undefined ? { onObserve: opts.onObserve } : {}),
2621
2552
  ...(opts.onLifecycle !== undefined ? { onLifecycle: opts.onLifecycle } : {}),
2553
+ ...(opts.preResolvedMeta !== undefined
2554
+ ? { preResolvedMeta: opts.preResolvedMeta }
2555
+ : {}),
2622
2556
  });
2623
2557
  }