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