@ggui-ai/iframe-runtime 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/call-tool-unwrap.d.ts +29 -0
- package/dist/call-tool-unwrap.js +56 -0
- package/dist/channels/props-update.d.ts +19 -0
- package/dist/channels/props-update.js +14 -1
- package/dist/compiled-validators.d.ts +16 -7
- package/dist/compiled-validators.js +41 -8
- package/dist/events-polling.d.ts +123 -1
- package/dist/events-polling.js +181 -53
- package/dist/iframe-runtime.js +65 -64
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/mcp-app-iframe-host.d.ts +1 -1
- package/dist/meta-parse.d.ts +13 -0
- package/dist/meta-parse.js +41 -0
- package/dist/observability.d.ts +1 -1
- package/dist/provisional-renderer.d.ts +1 -1
- package/dist/provisional-renderer.js +1 -1
- package/dist/react-renderer.d.ts +13 -1
- package/dist/react-renderer.js +87 -17
- package/dist/registry-subscribe.d.ts +42 -3
- package/dist/registry-subscribe.js +33 -10
- package/dist/render-item.d.ts +9 -0
- package/dist/render-item.js +7 -1
- package/dist/runtime-telemetry.d.ts +53 -0
- package/dist/runtime-telemetry.js +74 -0
- package/dist/runtime.d.ts +84 -0
- package/dist/runtime.js +522 -100
- package/dist/server.d.ts +19 -0
- package/dist/server.js +19 -0
- package/dist/shims/app-components.js +1 -0
- package/dist/shims/components.js +1 -0
- package/dist/shims/compositions.js +1 -0
- package/dist/shims/design.js +1 -0
- package/dist/shims/gadgets.js +1 -0
- package/dist/shims/interact.js +1 -0
- package/dist/shims/jsx-runtime.js +1 -0
- package/dist/shims/primitives.js +1 -0
- package/dist/shims/react-dom.js +1 -0
- package/dist/shims/react.js +1 -0
- package/dist/shims/templates.js +1 -0
- package/dist/shims/tokens.js +1 -0
- package/dist/shims/wire.js +1 -0
- package/dist/system-cards/ProtocolProbeCard.js +1 -1
- package/dist/system-cards/UiFeedbackCard.d.ts +1 -1
- package/dist/system-cards/UiFeedbackCard.js +1 -1
- package/dist/ui-feedback-chrome.js +7 -4
- package/dist/zod-jitless.d.ts +1 -0
- package/dist/zod-jitless.js +18 -0
- package/package.json +8 -7
package/dist/runtime.js
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `@ggui-ai/iframe-runtime` iframe runtime entry.
|
|
3
|
+
*
|
|
4
|
+
* This is the file esbuild bundles into `dist/iframe-runtime.js`. The
|
|
5
|
+
* thin-shell HTML loads it via `<script type="module" src=".../iframe-runtime.js">`;
|
|
6
|
+
* on import the side-effects below take over: build a status DOM,
|
|
7
|
+
* postMessage `ui/initialize` to the parent, parse the bootstrap, open
|
|
8
|
+
* the WebSocket, run the version handshake, and mount the render
|
|
9
|
+
* placeholder.
|
|
10
|
+
*
|
|
11
|
+
* Boot sequence:
|
|
12
|
+
* - Boot from the `_meta["ai.ggui/render"]` slice received via
|
|
13
|
+
* `ui/initialize`.
|
|
14
|
+
* - Open the WebSocket, run the subscribe handshake.
|
|
15
|
+
* - Mount the render — either a structural placeholder or, when
|
|
16
|
+
* the renderer hooks are wired, a React mount of `componentCode`.
|
|
17
|
+
*
|
|
18
|
+
* The runtime advertises its build version via a post-`ui/initialize`
|
|
19
|
+
* notification (`ggui:renderer-ready`).
|
|
20
|
+
*
|
|
21
|
+
* Failure surfacing: every parse / handshake failure goes to two
|
|
22
|
+
* places — (1) the in-iframe status line (operator-visible) and
|
|
23
|
+
* (2) a parent-bound `postMessage({type:'ggui:bootstrap-failed', ...})`.
|
|
24
|
+
* The post-message envelope is consumed by the `<McpAppIframe>` host
|
|
25
|
+
* wrapper, which routes it to the host's `onError` callback.
|
|
26
|
+
*/
|
|
27
|
+
// FIRST import on purpose — declares zod jitless before any
|
|
28
|
+
// schema-defining dependency initializes (see zod-jitless.ts).
|
|
29
|
+
import './zod-jitless.js';
|
|
30
|
+
import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
31
|
+
import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, } from './meta-parse.js';
|
|
3
32
|
import { projectHostContext } from '@ggui-ai/protocol';
|
|
4
33
|
import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
|
|
5
34
|
import { applyHostContextStyling, attachListener as attachHostContextListener, seed as seedHostContext, } from './host-context-emitter.js';
|
|
@@ -7,9 +36,11 @@ import { buildRootWireConfig, StreamBus, } from './wire-config.js';
|
|
|
7
36
|
import { createChannelTransportRouter, } from './channel-transport.js';
|
|
8
37
|
import { RelayIncapableError } from './relay-incapability.js';
|
|
9
38
|
import { ChannelRegistry } from '@ggui-ai/live-channel';
|
|
39
|
+
import { createTelemetrySink, } from './runtime-telemetry.js';
|
|
10
40
|
import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createPropsUpdateHandler, createRenderHandler, } from './channels/index.js';
|
|
11
41
|
import { connectViaRegistry, } from './registry-subscribe.js';
|
|
12
|
-
import { buildEventsPolling } from './events-polling.js';
|
|
42
|
+
import { buildBridgePolling, buildEventsPolling, createSequenceCursor, } from './events-polling.js';
|
|
43
|
+
import { unwrapCallToolResult } from './call-tool-unwrap.js';
|
|
13
44
|
import { ensureStatusDom, setStatus, setConnectedStatus, } from './status-dom.js';
|
|
14
45
|
import { mergeReservedValidators, setActiveValidatorSet, } from './validation.js';
|
|
15
46
|
import { loadCompiledValidatorsFromUrl } from './compiled-validators.js';
|
|
@@ -129,6 +160,32 @@ export function setCurrentApp(app) {
|
|
|
129
160
|
function getCurrentApp() {
|
|
130
161
|
return currentApp;
|
|
131
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* The host's announced color mode, read from the connected App's
|
|
165
|
+
* pre-merged hostContext (`ui/initialize` result, kept current by the
|
|
166
|
+
* App across `host-context-changed`). `undefined` when no App is
|
|
167
|
+
* connected, the host sent no `theme`, or it sent something outside
|
|
168
|
+
* the spec's `'light' | 'dark'` literals (dropped, never guessed —
|
|
169
|
+
* the same tolerant posture as `projectHostContext`).
|
|
170
|
+
*
|
|
171
|
+
* This is the INPUT leg of host-theme adaptation
|
|
172
|
+
* (rnd/gen-ui/beauty/experiments/002-host-theme-adaptation.md, ggui#551):
|
|
173
|
+
* `hostContext.theme` alone reaches only `data-theme` +
|
|
174
|
+
* `color-scheme` via `applyDocumentTheme` — nothing in the token
|
|
175
|
+
* pipeline keys off it, so a dark host painted the light ladder
|
|
176
|
+
* (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
|
|
177
|
+
* `buildOpts` consumes this ONLY when the slice stamps no `themeMode`:
|
|
178
|
+
* every operator layer (live provider > per-render override > app
|
|
179
|
+
* default > server default) still wins by being stamped; the host
|
|
180
|
+
* fills the gap where no operator had an opinion.
|
|
181
|
+
*
|
|
182
|
+
* @internal — exported for unit tests (`setCurrentApp` injects the
|
|
183
|
+
* App); production caller is `buildOpts` inside `bootSequence`.
|
|
184
|
+
*/
|
|
185
|
+
export function hostAnnouncedThemeMode() {
|
|
186
|
+
const theme = currentApp?.getHostContext()?.theme;
|
|
187
|
+
return theme === 'light' || theme === 'dark' ? theme : undefined;
|
|
188
|
+
}
|
|
132
189
|
/**
|
|
133
190
|
* @internal — exported for unit tests to reset module state between
|
|
134
191
|
* scenarios.
|
|
@@ -312,7 +369,16 @@ export async function bootSequence(opts) {
|
|
|
312
369
|
parsed = inline;
|
|
313
370
|
}
|
|
314
371
|
else {
|
|
315
|
-
|
|
372
|
+
// Tier 1.5 — the read-plane door (ggui#537). A pre-boot tool
|
|
373
|
+
// result named the view but carried no material; now that the
|
|
374
|
+
// App is connected, resolve the locator through the host's
|
|
375
|
+
// `resources/read` proxy. One round trip, ahead of the Tier 2
|
|
376
|
+
// wait; a miss falls through to Tier 2 unchanged (its listener
|
|
377
|
+
// is already armed and resolves locator-only arrivals too).
|
|
378
|
+
const viaDoor = opts.preResolvedLocator !== undefined
|
|
379
|
+
? await resolveMetaViaReadDoor(app, opts.preResolvedLocator)
|
|
380
|
+
: null;
|
|
381
|
+
const fromToolResult = viaDoor ?? (await toolResultPromise);
|
|
316
382
|
if (fromToolResult !== null) {
|
|
317
383
|
parsed = { ok: true, meta: fromToolResult };
|
|
318
384
|
}
|
|
@@ -386,16 +452,24 @@ export async function bootSequence(opts) {
|
|
|
386
452
|
// In-iframe UI-feedback affordance (ggui#244) — mounted adjacent to
|
|
387
453
|
// the session root, gated inside the helper on `window.parent !==
|
|
388
454
|
// window` (top-level `/r/<shortCode>` tabs have no `ggui:observe`
|
|
389
|
-
// egress, so they get NO affordance)
|
|
390
|
-
// `
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
455
|
+
// egress, so they get NO affordance) AND here on a LIVE SINK: an
|
|
456
|
+
// injected `onObserve` emitter, or a first-party embed host — the
|
|
457
|
+
// only parent that consumes the `ggui:observe` envelope
|
|
458
|
+
// (mcp-app-iframe-host answers `ui/initialize` with exactly this
|
|
459
|
+
// hostInfo name). Third-party MCP-Apps hosts (claude.ai, ChatGPT,
|
|
460
|
+
// Claude Desktop) drop the envelope on the floor, so rendering the
|
|
461
|
+
// affordance there is a dead control — the first #471 claude.ai
|
|
462
|
+
// retest surfaced it as unstyled clutter that could never deliver
|
|
463
|
+
// feedback anywhere. Fire-and-forget: chrome never blocks or fails
|
|
464
|
+
// the boot.
|
|
465
|
+
const feedbackSinkLive = onObserve !== undefined ||
|
|
466
|
+
app.getHostVersion()?.name === 'ggui-iframe-runtime-embed-host';
|
|
467
|
+
if (feedbackSinkLive) {
|
|
468
|
+
void mountUiFeedbackChrome(doc, {
|
|
469
|
+
emit: onObserve ?? postObservabilityToParent,
|
|
470
|
+
sessionId: meta.sessionId,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
399
473
|
setStatus(refs, `Connecting to ${meta.sessionId}…`, 'connecting');
|
|
400
474
|
// Boot-without-renderer path: we still need a ChannelRegistry to
|
|
401
475
|
// receive frames, because the registry is the only dispatch
|
|
@@ -471,6 +545,27 @@ export async function bootSequence(opts) {
|
|
|
471
545
|
meta.wsUrl.length > 0 &&
|
|
472
546
|
typeof meta.wsToken === 'string' &&
|
|
473
547
|
meta.wsToken.length > 0;
|
|
548
|
+
// Transport-telemetry sink — the iframe's self-report channel (see
|
|
549
|
+
// runtime-telemetry.ts). Created for EVERY boot with an App bridge;
|
|
550
|
+
// the first batch carries the boot-path decision so operators can
|
|
551
|
+
// see which ladder (if any) this mount composed, even on hosts
|
|
552
|
+
// whose console and network are unreachable.
|
|
553
|
+
const telemetryApp = getCurrentApp();
|
|
554
|
+
const telemetry = telemetryApp !== null
|
|
555
|
+
? createTelemetrySink({
|
|
556
|
+
sessionId: meta.sessionId,
|
|
557
|
+
callTool: (args) => telemetryApp.callServerTool(args),
|
|
558
|
+
})
|
|
559
|
+
: null;
|
|
560
|
+
currentTelemetrySink = telemetry;
|
|
561
|
+
// Fresh boot ⇒ not superseded (matters only for test re-runs that
|
|
562
|
+
// reuse the module; a real iframe boots once).
|
|
563
|
+
mountSuperseded = false;
|
|
564
|
+
telemetry?.record('boot.path', JSON.stringify({
|
|
565
|
+
hasStaticContent,
|
|
566
|
+
hasLiveTrio,
|
|
567
|
+
bridgeCapable: telemetryApp !== null,
|
|
568
|
+
}));
|
|
474
569
|
// ── Static seed mount — zero-round-trip paint, no WS required. ──────
|
|
475
570
|
// The ONLY mount path for spec-compliant MCP-Apps hosts that expose no
|
|
476
571
|
// ggui live channel (claude.ai / ChatGPT / Claude Desktop), AND the
|
|
@@ -511,17 +606,28 @@ export async function bootSequence(opts) {
|
|
|
511
606
|
emitCodeReadyOnce();
|
|
512
607
|
}
|
|
513
608
|
}
|
|
514
|
-
// ── No live channel → static-only host.
|
|
609
|
+
// ── No live channel → static-only host (unless bridge-capable). ─────
|
|
515
610
|
// The static seed already painted (or there was nothing to paint).
|
|
516
611
|
// A bootstrap with neither static content nor a live trio is a
|
|
517
612
|
// misconfiguration — surface a typed boot failure.
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
613
|
+
//
|
|
614
|
+
// #471 round-6 live finding: when the host exposes an App bridge
|
|
615
|
+
// (tools/call postMessage — claude.ai, every MCP Apps host), a
|
|
616
|
+
// trio-less static mount must NOT stop here — the bridge-pull
|
|
617
|
+
// terminal rung is exactly its subscription. Fall through to the
|
|
618
|
+
// subscribe below, which binds trio-less (the registry starts the
|
|
619
|
+
// ladder at its tail). Gating the terminal rung behind the WS
|
|
620
|
+
// trio's presence disabled it on precisely the host class it was
|
|
621
|
+
// built for.
|
|
622
|
+
const staticOnlyNoBridge = !hasLiveTrio && getCurrentApp() === null;
|
|
623
|
+
if (!hasLiveTrio && mountedRender === null) {
|
|
624
|
+
const message = 'bootstrap carries neither static content (codeUrl/codeB64/kind) nor a live trio (wsUrl/wsToken)';
|
|
625
|
+
setStatus(refs, message, 'error');
|
|
626
|
+
emitBootFailure('MISSING_META_GGUI_BOOTSTRAP', message);
|
|
627
|
+
return { ok: false, mountedRender };
|
|
628
|
+
}
|
|
629
|
+
if (staticOnlyNoBridge) {
|
|
630
|
+
telemetry?.record('boot.static_only_no_bridge');
|
|
525
631
|
// Host-context wiring for the no-WS static mount (claude.ai / ChatGPT
|
|
526
632
|
// / Claude Desktop). The INITIAL host theme/style/fonts were already
|
|
527
633
|
// applied at boot (applyHostContextStyling, above); attach the
|
|
@@ -543,12 +649,42 @@ export async function bootSequence(opts) {
|
|
|
543
649
|
return { ok: true, mountedRender };
|
|
544
650
|
}
|
|
545
651
|
// ── Live-channel subscribe (conditional enhancement). ──────────────
|
|
652
|
+
// Failover-ladder rung URLs (WS → SSE → polling → bridge-pull),
|
|
653
|
+
// hoisted so the spreads below stay exact-optional-clean without
|
|
654
|
+
// non-null assertions. `sseUrl` is the server-stamped wsToken-gated
|
|
655
|
+
// `/api/sessions/<id>/stream` URL; SSE `id:` = ledger sequence = the
|
|
656
|
+
// SAME cursor model as the WS `sinceSequence` replay and polling
|
|
657
|
+
// ticks — switching rungs does not lose events.
|
|
658
|
+
const sseUrl = typeof meta.sseUrl === 'string' && meta.sseUrl.length > 0
|
|
659
|
+
? meta.sseUrl
|
|
660
|
+
: undefined;
|
|
661
|
+
const pollingBaseUrl = typeof meta.pollingUrl === 'string' && meta.pollingUrl.length > 0
|
|
662
|
+
? meta.pollingUrl
|
|
663
|
+
: undefined;
|
|
664
|
+
// One shared cursor for the whole ladder — SSE deliveries advance it
|
|
665
|
+
// (via onSequence), the polling + bridge descriptors read it per
|
|
666
|
+
// tick, so a demotion down the ladder resumes from the last
|
|
667
|
+
// delivered event instead of re-replaying from the boot snapshot.
|
|
668
|
+
// Created when ANY fallback rung exists — including the bridge-pull
|
|
669
|
+
// rung, whose only precondition is a connected App handle (the
|
|
670
|
+
// universal floor: a CSP-jailed host stamps no sseUrl/pollingUrl,
|
|
671
|
+
// yet its `tools/call` postMessage bridge still reaches the ledger).
|
|
672
|
+
const bridgeApp = getCurrentApp();
|
|
673
|
+
const ladderCursor = sseUrl !== undefined || pollingBaseUrl !== undefined || bridgeApp !== null
|
|
674
|
+
? createSequenceCursor(meta.lastSequence ?? 0)
|
|
675
|
+
: undefined;
|
|
546
676
|
let handle;
|
|
547
677
|
try {
|
|
548
678
|
handle = await connectFn({
|
|
549
679
|
meta,
|
|
550
680
|
registry: activeRegistry,
|
|
681
|
+
// Live-channel diagnostics tap — every channel_* event the
|
|
682
|
+
// transports emit (failover swaps, polling budget exhaustion,
|
|
683
|
+
// SSE lifecycle) lands in the telemetry buffer. Without this the
|
|
684
|
+
// ladder's story on sandboxed hosts is unobservable.
|
|
685
|
+
...(telemetry !== null ? { logger: telemetry.channelLogger } : {}),
|
|
551
686
|
onStatusChange: (status) => {
|
|
687
|
+
telemetry?.record(`status.${status}`);
|
|
552
688
|
setStatus(refs, status === 'connected' ? 'Connected.' : `Connection ${status}…`, status);
|
|
553
689
|
// Propagate WS status to the per-channel transport router so
|
|
554
690
|
// it can flip WS-bound channels into polling
|
|
@@ -573,21 +709,48 @@ export async function bootSequence(opts) {
|
|
|
573
709
|
onResubscribeAck: (ack) => {
|
|
574
710
|
void applyAck(ack);
|
|
575
711
|
},
|
|
576
|
-
//
|
|
577
|
-
// bind time from
|
|
578
|
-
// gated
|
|
579
|
-
// (cursor seed). FailoverHandle
|
|
580
|
-
// 'failed';
|
|
712
|
+
// Failover-ladder fallback rungs (WS → SSE → polling →
|
|
713
|
+
// bridge-pull), composed once at bind time from the
|
|
714
|
+
// server-stamped wsToken-gated URLs + `meta.lastSequence`
|
|
715
|
+
// (shared cursor seed). FailoverHandle descends a rung on each
|
|
716
|
+
// 'failed'; the bridge rung is terminal (never emits 'failed').
|
|
581
717
|
//
|
|
582
718
|
// Same cursor model as the WS subscribe `sinceSequence` replay
|
|
583
719
|
// path — switching transports does not lose events.
|
|
584
|
-
...(
|
|
720
|
+
...(sseUrl !== undefined && ladderCursor !== undefined
|
|
721
|
+
? {
|
|
722
|
+
sse: {
|
|
723
|
+
url: sseUrl,
|
|
724
|
+
initialSinceSequence: ladderCursor.get(),
|
|
725
|
+
onSequence: (seq) => {
|
|
726
|
+
ladderCursor.advance(seq);
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
}
|
|
730
|
+
: {}),
|
|
731
|
+
...(pollingBaseUrl !== undefined && ladderCursor !== undefined
|
|
585
732
|
? {
|
|
586
733
|
polling: buildEventsPolling({
|
|
587
|
-
baseUrl:
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
734
|
+
baseUrl: pollingBaseUrl,
|
|
735
|
+
cursor: ladderCursor,
|
|
736
|
+
}),
|
|
737
|
+
}
|
|
738
|
+
: {}),
|
|
739
|
+
// Bridge-pull terminal rung — composed whenever the App handle
|
|
740
|
+
// is bound (it always is by this point in the boot: connectApp
|
|
741
|
+
// succeeded and setCurrentApp ran above), INDEPENDENT of
|
|
742
|
+
// sseUrl/pollingUrl presence. This is the universal floor for
|
|
743
|
+
// CSP-jailed hosts (claude.ai) where the iframe has no network
|
|
744
|
+
// path at all: the ledger is pulled via `ggui_runtime_pull`
|
|
745
|
+
// `tools/call`s over the host's postMessage bridge. Shares the
|
|
746
|
+
// SAME ladder cursor — a demotion into the bridge resumes from
|
|
747
|
+
// whatever the rungs above already delivered.
|
|
748
|
+
...(bridgeApp !== null && ladderCursor !== undefined
|
|
749
|
+
? {
|
|
750
|
+
bridge: buildBridgePolling({
|
|
751
|
+
callTool: (name, args) => bridgeApp.callServerTool({ name, arguments: args }),
|
|
752
|
+
sessionId: meta.sessionId,
|
|
753
|
+
cursor: ladderCursor,
|
|
591
754
|
}),
|
|
592
755
|
}
|
|
593
756
|
: {}),
|
|
@@ -626,12 +789,19 @@ export async function bootSequence(opts) {
|
|
|
626
789
|
emitBootFailure('WS_HANDSHAKE_FAILED', message);
|
|
627
790
|
return { ok: false, mountedRender };
|
|
628
791
|
}
|
|
792
|
+
telemetry?.record('subscribe.resolved', JSON.stringify({ kind: handle.handle.kind, hasAck: handle.ack !== undefined }));
|
|
629
793
|
// Attach the live transport handle to the renderer — flushes any
|
|
630
|
-
// buffered outbound `action` frames that were queued
|
|
631
|
-
//
|
|
632
|
-
//
|
|
794
|
+
// buffered outbound `action` frames that were queued while the
|
|
795
|
+
// subscribe handshake completed. `send` narrows to the ws surface:
|
|
796
|
+
// a trio-less bridge-only bind has NO outbound channel (gestures
|
|
797
|
+
// ride app.callServerTool, not the transport), so its sends drop —
|
|
798
|
+
// the same posture a post-swap FailoverHandle already has.
|
|
799
|
+
const transportSend = (msg) => {
|
|
800
|
+
if (handle.handle.kind === 'ws')
|
|
801
|
+
handle.handle.send(msg);
|
|
802
|
+
};
|
|
633
803
|
if (renderer !== null && rendererHooks?.attachManager !== undefined) {
|
|
634
|
-
rendererHooks.attachManager(renderer, { send:
|
|
804
|
+
rendererHooks.attachManager(renderer, { send: transportSend });
|
|
635
805
|
}
|
|
636
806
|
// seed the host-context emitter with the projection captured from
|
|
637
807
|
// `app.getHostContext()` (the App class's spec-canonical
|
|
@@ -642,7 +812,7 @@ export async function bootSequence(opts) {
|
|
|
642
812
|
if (parsed.hostContext !== undefined) {
|
|
643
813
|
seedHostContext({
|
|
644
814
|
sessionId: meta.sessionId,
|
|
645
|
-
send:
|
|
815
|
+
send: transportSend,
|
|
646
816
|
initial: parsed.hostContext,
|
|
647
817
|
});
|
|
648
818
|
// Bind via App's spec-canonical `hostcontextchanged` event surface.
|
|
@@ -655,8 +825,12 @@ export async function bootSequence(opts) {
|
|
|
655
825
|
// `pinnedSessionId`) and mount it. Reuses the same `applyAck` helper
|
|
656
826
|
// the reconnect-rebootstrap path uses — so a server-restart-driven
|
|
657
827
|
// snapshot replay and the first-boot snapshot apply flow through
|
|
658
|
-
// one implementation.
|
|
659
|
-
|
|
828
|
+
// one implementation. Trio-less bridge-only binds have no handshake
|
|
829
|
+
// and therefore no ack — the static seed already painted, and the
|
|
830
|
+
// bridge delivers deltas from the boot cursor.
|
|
831
|
+
if (handle.ack !== undefined) {
|
|
832
|
+
await applyAck(handle.ack);
|
|
833
|
+
}
|
|
660
834
|
setConnectedStatus(refs);
|
|
661
835
|
// Lifecycle `code-ready` — terminal happy state. Emitted ONCE per boot
|
|
662
836
|
// (a static+live meta already fired it from the seed mount, so this is
|
|
@@ -942,6 +1116,26 @@ export function readPendingToolResults() {
|
|
|
942
1116
|
}
|
|
943
1117
|
return null;
|
|
944
1118
|
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Sibling of {@link readPendingToolResults} for the read-plane-only
|
|
1121
|
+
* posture (ggui#537): the NEWEST buffered tool result that carries a
|
|
1122
|
+
* `ui://ggui/render/…` locator but no bootstrap material. Consulted only
|
|
1123
|
+
* when the buffer yielded no inline slice; the autostart threads it to
|
|
1124
|
+
* `bootSequence` as `preResolvedLocator`, resolved after the handshake.
|
|
1125
|
+
*/
|
|
1126
|
+
export function readPendingToolResultLocator() {
|
|
1127
|
+
if (typeof window === 'undefined')
|
|
1128
|
+
return null;
|
|
1129
|
+
const raw = window.__GGUI_PENDING_TOOL_RESULTS__;
|
|
1130
|
+
if (!Array.isArray(raw) || raw.length === 0)
|
|
1131
|
+
return null;
|
|
1132
|
+
for (let i = raw.length - 1; i >= 0; i--) {
|
|
1133
|
+
const locator = extractLocatorFromToolResult(raw[i]);
|
|
1134
|
+
if (locator !== null)
|
|
1135
|
+
return locator;
|
|
1136
|
+
}
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
945
1139
|
/**
|
|
946
1140
|
* Listen for a `ui/notifications/tool-result` notification via the
|
|
947
1141
|
* App's spec-canonical event surface and resolve to the extracted
|
|
@@ -957,36 +1151,75 @@ export function readPendingToolResults() {
|
|
|
957
1151
|
function awaitToolResultMetaFromApp(app, timeoutMs) {
|
|
958
1152
|
return new Promise((resolve) => {
|
|
959
1153
|
let settled = false;
|
|
960
|
-
const
|
|
1154
|
+
const settle = (value) => {
|
|
961
1155
|
if (settled)
|
|
962
1156
|
return;
|
|
963
|
-
const meta = extractMetaFromToolResult(params);
|
|
964
|
-
if (meta === null)
|
|
965
|
-
return;
|
|
966
1157
|
settled = true;
|
|
967
1158
|
app.removeEventListener('toolresult', handler);
|
|
968
1159
|
clearTimeout(timer);
|
|
969
|
-
resolve(
|
|
1160
|
+
resolve(value);
|
|
970
1161
|
};
|
|
971
|
-
|
|
972
|
-
const timer = setTimeout(() => {
|
|
1162
|
+
const handler = (params) => {
|
|
973
1163
|
if (settled)
|
|
974
1164
|
return;
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
1165
|
+
const meta = extractMetaFromToolResult(params);
|
|
1166
|
+
if (meta !== null) {
|
|
1167
|
+
settle(meta);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
// Read-plane door (ggui#537): identity-only result → resolve the
|
|
1171
|
+
// locator through the host's `resources/read` proxy. Async, and
|
|
1172
|
+
// it never settles `null` — a later result carrying the slice
|
|
1173
|
+
// inline (or the timeout) still decides.
|
|
1174
|
+
const locator = extractLocatorFromToolResult(params);
|
|
1175
|
+
if (locator === null)
|
|
1176
|
+
return;
|
|
1177
|
+
void resolveMetaViaReadDoor(app, locator).then((resolved) => {
|
|
1178
|
+
if (resolved !== null)
|
|
1179
|
+
settle(resolved);
|
|
1180
|
+
});
|
|
1181
|
+
};
|
|
1182
|
+
app.addEventListener('toolresult', handler);
|
|
1183
|
+
const timer = setTimeout(() => settle(null), timeoutMs);
|
|
979
1184
|
});
|
|
980
1185
|
}
|
|
981
1186
|
/**
|
|
982
|
-
*
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
|
|
1187
|
+
* The read-plane door (ggui#537). A server running the read-plane-only
|
|
1188
|
+
* posture publishes only the view's identity on the tool result — the
|
|
1189
|
+
* `ui://ggui/render/…` locator — and no bootstrap material; the
|
|
1190
|
+
* per-render resource that locator names is the self-contained shell,
|
|
1191
|
+
* whose document inlines the very envelope this runtime boots from.
|
|
1192
|
+
* Ask the HOST to read it (`app.readServerResource` → `resources/read`,
|
|
1193
|
+
* proxied by the host's own MCP client — no fetch from this sandbox, so
|
|
1194
|
+
* a host CSP without `connect-src` to the server is not in the way),
|
|
1195
|
+
* recover the envelope with the protocol's writer/reader pair
|
|
1196
|
+
* ({@link readGguiShellEnvelope}), and validate it exactly as an inline
|
|
1197
|
+
* slice would be. `null` on any miss (host cannot proxy reads, resource
|
|
1198
|
+
* gone, no envelope) — the caller's other tiers still decide.
|
|
1199
|
+
*/
|
|
1200
|
+
export async function resolveMetaViaReadDoor(app, locator) {
|
|
1201
|
+
let text;
|
|
1202
|
+
try {
|
|
1203
|
+
const res = await app.readServerResource({ uri: locator });
|
|
1204
|
+
const first = res.contents[0];
|
|
1205
|
+
text = first !== undefined && 'text' in first && typeof first.text === 'string' ? first.text : undefined;
|
|
1206
|
+
}
|
|
1207
|
+
catch (err) {
|
|
1208
|
+
// eslint-disable-next-line no-console -- operator-visible: the door is a boot tier, its miss must be legible
|
|
1209
|
+
console.warn(`[ggui-runtime] read-plane door: host could not read ${locator} — ${err instanceof Error ? err.message : String(err)}`);
|
|
1210
|
+
return null;
|
|
1211
|
+
}
|
|
1212
|
+
if (text === undefined)
|
|
1213
|
+
return null;
|
|
1214
|
+
const envelope = readGguiShellEnvelope(text);
|
|
1215
|
+
if (envelope === undefined)
|
|
1216
|
+
return null;
|
|
1217
|
+
const parsed = parseMcpAppAiGguiRenderMeta(envelope);
|
|
1218
|
+
if (!parsed.ok || parsed.meta === undefined)
|
|
1219
|
+
return null;
|
|
1220
|
+
const validated = validateMeta(parsed.meta);
|
|
1221
|
+
return validated.ok ? validated.meta : null;
|
|
1222
|
+
}
|
|
990
1223
|
function awaitToolResultMeta(timeoutMs) {
|
|
991
1224
|
if (typeof window === 'undefined')
|
|
992
1225
|
return Promise.resolve(null);
|
|
@@ -1009,8 +1242,17 @@ function awaitToolResultMeta(timeoutMs) {
|
|
|
1009
1242
|
return;
|
|
1010
1243
|
}
|
|
1011
1244
|
const meta = extractMetaFromToolResult(m.params);
|
|
1012
|
-
if (meta !== null)
|
|
1013
|
-
settle(meta);
|
|
1245
|
+
if (meta !== null) {
|
|
1246
|
+
settle({ kind: 'meta', meta });
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
// Identity-only result (ggui#537): settle NOW with the locator so
|
|
1250
|
+
// boot starts immediately and the door runs after the handshake —
|
|
1251
|
+
// waiting the full timeout for a slice this posture never sends
|
|
1252
|
+
// would cost every spec-host view 30 s for nothing.
|
|
1253
|
+
const locator = extractLocatorFromToolResult(m.params);
|
|
1254
|
+
if (locator !== null)
|
|
1255
|
+
settle({ kind: 'locator', locator });
|
|
1014
1256
|
};
|
|
1015
1257
|
window.addEventListener('message', onMessage);
|
|
1016
1258
|
const timer = setTimeout(() => settle(null), timeoutMs);
|
|
@@ -1307,6 +1549,23 @@ export function emitAudit(args) {
|
|
|
1307
1549
|
* iframe-runtime then falls through to `ui/message` so the gesture
|
|
1308
1550
|
* still reaches the agent on its next turn.
|
|
1309
1551
|
*/
|
|
1552
|
+
/**
|
|
1553
|
+
* Unwrap the payload record of a relayed submit-action tool result.
|
|
1554
|
+
* Envelope-level shim over the shared 3-tier unwrap
|
|
1555
|
+
* ({@link unwrapCallToolResult}: `structuredContent` →
|
|
1556
|
+
* `content[0].text` parsed as JSON → bare result) — this wrapper only
|
|
1557
|
+
* peels the JSON-RPC `result` field first. The tiers themselves are
|
|
1558
|
+
* shared with the bridge-pull rung's `fetchBody` carrier
|
|
1559
|
+
* (`events-polling.ts`) so both reads agree on which envelope tier
|
|
1560
|
+
* carries the payload for a given host shape.
|
|
1561
|
+
* Returns `null` when no tier yields an object — the caller treats
|
|
1562
|
+
* that as fallback/unknown.
|
|
1563
|
+
*/
|
|
1564
|
+
function submitActionPayload(resp) {
|
|
1565
|
+
if (resp === null || typeof resp !== 'object')
|
|
1566
|
+
return null;
|
|
1567
|
+
return unwrapCallToolResult(resp.result);
|
|
1568
|
+
}
|
|
1310
1569
|
function classifySubmitActionResponse(resp) {
|
|
1311
1570
|
if (resp === null || typeof resp !== 'object')
|
|
1312
1571
|
return 'fallback';
|
|
@@ -1315,15 +1574,9 @@ function classifySubmitActionResponse(resp) {
|
|
|
1315
1574
|
resp.error !== null) {
|
|
1316
1575
|
return 'fallback';
|
|
1317
1576
|
}
|
|
1318
|
-
const
|
|
1319
|
-
if (
|
|
1577
|
+
const inner = submitActionPayload(resp);
|
|
1578
|
+
if (inner === null)
|
|
1320
1579
|
return 'fallback';
|
|
1321
|
-
const r = result;
|
|
1322
|
-
// Spec-canonical hosts wrap tool output under `structuredContent`;
|
|
1323
|
-
// looser hosts may surface fields directly on `result`.
|
|
1324
|
-
const inner = r.structuredContent && typeof r.structuredContent === 'object'
|
|
1325
|
-
? r.structuredContent
|
|
1326
|
-
: r;
|
|
1327
1580
|
return inner.ok === true ? 'success' : 'fallback';
|
|
1328
1581
|
}
|
|
1329
1582
|
/**
|
|
@@ -1355,25 +1608,65 @@ function isRelayShapedFailure(resp) {
|
|
|
1355
1608
|
/**
|
|
1356
1609
|
* Extract `consumerPresent` from a successful submit_action response.
|
|
1357
1610
|
* Returns the boolean if present + well-typed; `undefined` otherwise
|
|
1358
|
-
* (
|
|
1359
|
-
*
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1362
|
-
*
|
|
1611
|
+
* (agnostic host stripped the field, or any non-`ok:true` shape).
|
|
1612
|
+
* `undefined` is treated by the dispatch gate as "no confirmed
|
|
1613
|
+
* consumer" — the doorbell rings. Shares {@link submitActionPayload}
|
|
1614
|
+
* with {@link classifySubmitActionResponse} so both reads agree on
|
|
1615
|
+
* which envelope tier carries the payload.
|
|
1363
1616
|
*/
|
|
1364
1617
|
function extractConsumerPresent(resp) {
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
const result = resp.result;
|
|
1368
|
-
if (result === null || typeof result !== 'object')
|
|
1618
|
+
const inner = submitActionPayload(resp);
|
|
1619
|
+
if (inner === null)
|
|
1369
1620
|
return undefined;
|
|
1370
|
-
const r = result;
|
|
1371
|
-
const inner = r.structuredContent && typeof r.structuredContent === 'object'
|
|
1372
|
-
? r.structuredContent
|
|
1373
|
-
: r;
|
|
1374
1621
|
const flag = inner.consumerPresent;
|
|
1375
1622
|
return typeof flag === 'boolean' ? flag : undefined;
|
|
1376
1623
|
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Freeze-cue overlay (#483). A superseded (history) card gets a
|
|
1626
|
+
* subtle, theme-aware "superseded" veil + a labelled pill, and its
|
|
1627
|
+
* content is made non-interactive so a stale-view click can't even
|
|
1628
|
+
* reach a control. Idempotent — the overlay id guards a second call.
|
|
1629
|
+
* A polite live-region announcement (reusing the #447 announcer)
|
|
1630
|
+
* tells a screen-reader user the card is now history.
|
|
1631
|
+
*/
|
|
1632
|
+
const FREEZE_CUE_ID = '__ggui-superseded-cue__';
|
|
1633
|
+
function applyFreezeCue(root) {
|
|
1634
|
+
if (typeof document === 'undefined')
|
|
1635
|
+
return;
|
|
1636
|
+
if (root.querySelector(`#${FREEZE_CUE_ID}`) !== null)
|
|
1637
|
+
return;
|
|
1638
|
+
// Non-interactive + visually receded — inherits the mount's own
|
|
1639
|
+
// colors, so it reads correctly in any theme.
|
|
1640
|
+
root.style.pointerEvents = 'none';
|
|
1641
|
+
root.style.opacity = '0.55';
|
|
1642
|
+
root.style.filter = 'grayscale(0.4)';
|
|
1643
|
+
root.style.transition = 'opacity 160ms ease, filter 160ms ease';
|
|
1644
|
+
if (getComputedStyle(root).position === 'static') {
|
|
1645
|
+
root.style.position = 'relative';
|
|
1646
|
+
}
|
|
1647
|
+
const pill = document.createElement('div');
|
|
1648
|
+
pill.id = FREEZE_CUE_ID;
|
|
1649
|
+
pill.setAttribute('role', 'note');
|
|
1650
|
+
pill.textContent = 'Superseded — a newer version continues below';
|
|
1651
|
+
Object.assign(pill.style, {
|
|
1652
|
+
position: 'absolute',
|
|
1653
|
+
top: '8px',
|
|
1654
|
+
right: '8px',
|
|
1655
|
+
padding: '4px 10px',
|
|
1656
|
+
borderRadius: '9999px',
|
|
1657
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
1658
|
+
fontSize: '11px',
|
|
1659
|
+
fontWeight: '600',
|
|
1660
|
+
color: 'var(--ggui-color-onSurface, inherit)',
|
|
1661
|
+
background: 'rgba(128,128,128,0.16)',
|
|
1662
|
+
border: '1px solid currentColor',
|
|
1663
|
+
opacity: '0.85',
|
|
1664
|
+
pointerEvents: 'none',
|
|
1665
|
+
zIndex: '2',
|
|
1666
|
+
});
|
|
1667
|
+
root.appendChild(pill);
|
|
1668
|
+
announceToast('This card was superseded by a newer version below', 'fallback');
|
|
1669
|
+
}
|
|
1377
1670
|
/** Container id for the announcer holding both live regions. */
|
|
1378
1671
|
const TOAST_ANNOUNCER_ID = '__ggui-toast-announcer__';
|
|
1379
1672
|
/**
|
|
@@ -1710,7 +2003,7 @@ function emitUserActionDoorbell(args) {
|
|
|
1710
2003
|
` <next_args>${nextArgs}</next_args>`,
|
|
1711
2004
|
`</ggui_directive>`,
|
|
1712
2005
|
'',
|
|
1713
|
-
`The user interacted with render ${args.sessionId} while no ggui_consume long-poll was active. The gesture is queued on the consume pipe for that render — it is NOT in this message. After ggui_consume returns, react to the returned event with the appropriate domain tool, then call
|
|
2006
|
+
`The user interacted with render ${args.sessionId} while no ggui_consume long-poll was active. The gesture is queued on the consume pipe for that render — it is NOT in this message. After ggui_consume returns, react to the returned event with the appropriate domain tool, then call ggui_amend on the SAME sessionId (${args.sessionId}) — the card the user is looking at repaints in place. Use ggui_update instead only if this moment deserves a NEW card in the conversation (it advances the history number).`,
|
|
1714
2007
|
].join('\n');
|
|
1715
2008
|
// Structured mirror of the directive for ggui-aware programmatic
|
|
1716
2009
|
// consumers. OPTIONAL — nothing in the loop depends on a server-side
|
|
@@ -1754,6 +2047,7 @@ function emitUserActionDoorbell(args) {
|
|
|
1754
2047
|
// record — the proper extension point). Namespaced under
|
|
1755
2048
|
// `ai.ggui/userAction` to match our other protocol extensions
|
|
1756
2049
|
// (`ai.ggui/render`, `ai.ggui/bootstrap`, etc.).
|
|
2050
|
+
currentTelemetrySink?.record('doorbell.ring', args.sessionId);
|
|
1757
2051
|
postToParent({
|
|
1758
2052
|
jsonrpc: '2.0',
|
|
1759
2053
|
id: Math.floor(Math.random() * 1e9),
|
|
@@ -1785,6 +2079,23 @@ function emitUserActionDoorbell(args) {
|
|
|
1785
2079
|
* nothing else about the latch is observable to the host.
|
|
1786
2080
|
*/
|
|
1787
2081
|
let relayIncapabilityAnnounced = false;
|
|
2082
|
+
/**
|
|
2083
|
+
* Module-level handle to the CURRENT boot's telemetry sink so
|
|
2084
|
+
* module-scope paths (the doorbell emitter below) can report without
|
|
2085
|
+
* threading the sink through every call chain. Per-iframe
|
|
2086
|
+
* single-tenancy makes a single slot correct — same justification as
|
|
2087
|
+
* `getCurrentApp()`.
|
|
2088
|
+
*/
|
|
2089
|
+
let currentTelemetrySink = null;
|
|
2090
|
+
/**
|
|
2091
|
+
* Freeze latch (#483): set true once a higher-epoch `ggui_update`
|
|
2092
|
+
* supersedes this mount. A frozen mount is HISTORY — its gestures no
|
|
2093
|
+
* longer target the live session, so `dispatchSubmitAction` drops them
|
|
2094
|
+
* (a stale-view submit against the live head is a prevented bug class).
|
|
2095
|
+
* Module-level for the same single-mount-per-iframe reason as
|
|
2096
|
+
* `currentTelemetrySink`.
|
|
2097
|
+
*/
|
|
2098
|
+
let mountSuperseded = false;
|
|
1788
2099
|
/**
|
|
1789
2100
|
* Whether the user has manually dismissed the CURRENTLY-STANDING relay
|
|
1790
2101
|
* notice (ggui#442).
|
|
@@ -2066,7 +2377,20 @@ function showPostDismissalCue(intent) {
|
|
|
2066
2377
|
export function dispatchSubmitAction(args) {
|
|
2067
2378
|
if (typeof window === 'undefined')
|
|
2068
2379
|
return;
|
|
2380
|
+
// Freeze latch (#483): a superseded (history) mount does not drive
|
|
2381
|
+
// the live session — drop the gesture rather than submit a stale
|
|
2382
|
+
// view's action against the current head.
|
|
2383
|
+
if (mountSuperseded) {
|
|
2384
|
+
currentTelemetrySink?.record('gesture.dropped_superseded', args.intent);
|
|
2385
|
+
return;
|
|
2386
|
+
}
|
|
2069
2387
|
const { toolName, intent, data, sessionId, appId } = args;
|
|
2388
|
+
// Gesture-path telemetry — the click's own autopsy trail. #471
|
|
2389
|
+
// round 12 hit a frame whose channels + beacons were fully healthy
|
|
2390
|
+
// while clicks produced NOTHING observable; without a record at the
|
|
2391
|
+
// dispatch entry there is no way to tell "handler never fired" from
|
|
2392
|
+
// "dispatch swallowed it" on a console-less host.
|
|
2393
|
+
currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({ intent, toolName }));
|
|
2070
2394
|
const firedAt = new Date().toISOString();
|
|
2071
2395
|
const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
|
|
2072
2396
|
const inlineData = formatSubmitActionDataInline(data);
|
|
@@ -2151,6 +2475,15 @@ export function dispatchSubmitAction(args) {
|
|
|
2151
2475
|
showActionToast(`⚠ ${intent} — transport error`, 'error');
|
|
2152
2476
|
resp = null;
|
|
2153
2477
|
}
|
|
2478
|
+
// Final hop of the gesture autopsy trail — what the relay
|
|
2479
|
+
// answered (or that it didn't).
|
|
2480
|
+
currentTelemetrySink?.record('gesture.result', JSON.stringify({
|
|
2481
|
+
intent,
|
|
2482
|
+
ok: resp !== null && resp.error === undefined,
|
|
2483
|
+
...(resp?.error?.message !== undefined
|
|
2484
|
+
? { error: resp.error.message.slice(0, 120) }
|
|
2485
|
+
: {}),
|
|
2486
|
+
}));
|
|
2154
2487
|
// Self-healing (ggui#440): a result envelope arriving at all is
|
|
2155
2488
|
// proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
|
|
2156
2489
|
// (an otherwise-healthy relay, expired pipe) proves it exactly as
|
|
@@ -2192,16 +2525,25 @@ export function dispatchSubmitAction(args) {
|
|
|
2192
2525
|
}
|
|
2193
2526
|
if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
|
|
2194
2527
|
const consumerPresent = extractConsumerPresent(resp);
|
|
2195
|
-
if (consumerPresent
|
|
2196
|
-
// No `ggui_consume` long-poll is
|
|
2197
|
-
// so the gesture needs a fresh
|
|
2198
|
-
// ONLY view→host method that
|
|
2199
|
-
//
|
|
2200
|
-
// to send a message
|
|
2528
|
+
if (consumerPresent !== true) {
|
|
2529
|
+
// No `ggui_consume` long-poll is POSITIVELY confirmed to be
|
|
2530
|
+
// draining this render's pipe, so the gesture needs a fresh
|
|
2531
|
+
// agent turn. `ui/message` is the ONLY view→host method that
|
|
2532
|
+
// can start one — a host that does not accept it cannot be
|
|
2533
|
+
// woken by us at all, and the user has to send a message
|
|
2534
|
+
// themselves (ggui#440).
|
|
2201
2535
|
//
|
|
2202
|
-
//
|
|
2203
|
-
//
|
|
2204
|
-
//
|
|
2536
|
+
// `!== true` (not `=== false`) is load-bearing: a relay host
|
|
2537
|
+
// that normalizes the tool result and strips the
|
|
2538
|
+
// `consumerPresent` field (claude.ai's live behavior — the
|
|
2539
|
+
// first #471 retest click died exactly here) must ring the
|
|
2540
|
+
// doorbell, not wait forever for a drain_ack that cannot
|
|
2541
|
+
// arrive without a live channel. The doorbell is pointer-only
|
|
2542
|
+
// and the pipe pop is exactly-once, so a redundant ring on a
|
|
2543
|
+
// field-stripping host with a live consumer costs one empty
|
|
2544
|
+
// `ggui_consume`; servers that CAN answer always send an
|
|
2545
|
+
// explicit `true` (the factory wires the registry
|
|
2546
|
+
// unconditionally), so confirmed-consumer hosts stay quiet.
|
|
2205
2547
|
showActionToast(hostCanReceiveMessages()
|
|
2206
2548
|
? `💬 ${intent}${dataPart} — agent not listening, sent to chat`
|
|
2207
2549
|
: `💬 ${intent}${dataPart} — agent not listening. Send a message to continue.`, 'action_required');
|
|
@@ -2213,9 +2555,9 @@ export function dispatchSubmitAction(args) {
|
|
|
2213
2555
|
});
|
|
2214
2556
|
return;
|
|
2215
2557
|
}
|
|
2216
|
-
// consumerPresent
|
|
2217
|
-
// the
|
|
2218
|
-
// it when ggui_consume drains the event.
|
|
2558
|
+
// consumerPresent === true — a live long-poll is confirmed
|
|
2559
|
+
// draining the pipe. Toast stays `pending`; the drain_ack
|
|
2560
|
+
// listener dismisses it when ggui_consume drains the event.
|
|
2219
2561
|
return;
|
|
2220
2562
|
}
|
|
2221
2563
|
// Enqueue failed (pipe gone / transport error / host has no relay).
|
|
@@ -2759,7 +3101,7 @@ function readLiveBootstrapShape() {
|
|
|
2759
3101
|
* re-await the same postMessage or re-parse the same global), saving
|
|
2760
3102
|
* up to the 30s postMessage timeout for spec-strict hosts.
|
|
2761
3103
|
*/
|
|
2762
|
-
function runBootProduction(preResolvedMeta) {
|
|
3104
|
+
function runBootProduction(preResolvedMeta, preResolvedLocator) {
|
|
2763
3105
|
const { app, transport } = createDefaultApp();
|
|
2764
3106
|
void bootProduction({
|
|
2765
3107
|
doc: document,
|
|
@@ -2775,6 +3117,7 @@ function runBootProduction(preResolvedMeta) {
|
|
|
2775
3117
|
onObserve: postObservabilityToParent,
|
|
2776
3118
|
onLifecycle: postLifecycleToParent,
|
|
2777
3119
|
...(preResolvedMeta !== undefined ? { preResolvedMeta } : {}),
|
|
3120
|
+
...(preResolvedLocator !== undefined ? { preResolvedLocator } : {}),
|
|
2778
3121
|
});
|
|
2779
3122
|
}
|
|
2780
3123
|
if (shouldAutostart() && typeof window !== 'undefined') {
|
|
@@ -2805,15 +3148,26 @@ if (shouldAutostart() && typeof window !== 'undefined') {
|
|
|
2805
3148
|
}
|
|
2806
3149
|
else {
|
|
2807
3150
|
const buffered = readPendingToolResults();
|
|
3151
|
+
const bufferedLocator = buffered === null ? readPendingToolResultLocator() : null;
|
|
2808
3152
|
if (buffered !== null) {
|
|
2809
3153
|
runBootProduction(buffered);
|
|
2810
3154
|
}
|
|
3155
|
+
else if (bufferedLocator !== null) {
|
|
3156
|
+
// Read-plane-only posture (ggui#537): the buffered result named
|
|
3157
|
+
// the view; the door resolves it right after the handshake.
|
|
3158
|
+
runBootProduction(undefined, bufferedLocator);
|
|
3159
|
+
}
|
|
2811
3160
|
else if (readLiveBootstrapShape()) {
|
|
2812
3161
|
runBootProduction();
|
|
2813
3162
|
}
|
|
2814
3163
|
else {
|
|
2815
|
-
void awaitToolResultMeta(POSTMESSAGE_BOOT_TIMEOUT_MS).then((
|
|
2816
|
-
|
|
3164
|
+
void awaitToolResultMeta(POSTMESSAGE_BOOT_TIMEOUT_MS).then((pre) => {
|
|
3165
|
+
if (pre === null)
|
|
3166
|
+
runBootProduction();
|
|
3167
|
+
else if (pre.kind === 'meta')
|
|
3168
|
+
runBootProduction(pre.meta);
|
|
3169
|
+
else
|
|
3170
|
+
runBootProduction(undefined, pre.locator);
|
|
2817
3171
|
});
|
|
2818
3172
|
}
|
|
2819
3173
|
}
|
|
@@ -2982,6 +3336,28 @@ async function bootProduction(opts) {
|
|
|
2982
3336
|
// these lived only in the retired self-contained boot path; the
|
|
2983
3337
|
// WS-driven path installed neither, so anchor clicks + fullscreen
|
|
2984
3338
|
// requests in a live-rendered component silently no-op'd.
|
|
3339
|
+
// Root of the gesture autopsy trail: a capture-phase listener
|
|
3340
|
+
// proving pointer events reach this document AT ALL. #471
|
|
3341
|
+
// round 12: a healthy frame (beacons + pulls flowing) whose
|
|
3342
|
+
// clicks produced nothing observable — without this record,
|
|
3343
|
+
// "host swallows pointer events" and "component handler never
|
|
3344
|
+
// attached" are indistinguishable from outside a console-less
|
|
3345
|
+
// host. Bounded: only the first few clicks record (the sink
|
|
3346
|
+
// throttles + caps flushes regardless).
|
|
3347
|
+
if (typeof document !== 'undefined') {
|
|
3348
|
+
let recordedClicks = 0;
|
|
3349
|
+
document.addEventListener('click', (ev) => {
|
|
3350
|
+
if (recordedClicks >= 5)
|
|
3351
|
+
return;
|
|
3352
|
+
recordedClicks += 1;
|
|
3353
|
+
const target = ev.target instanceof Element ? ev.target : null;
|
|
3354
|
+
currentTelemetrySink?.record('gesture.dom_click', JSON.stringify({
|
|
3355
|
+
tag: target?.tagName ?? 'unknown',
|
|
3356
|
+
role: target?.getAttribute('role') ?? undefined,
|
|
3357
|
+
trusted: ev.isTrusted,
|
|
3358
|
+
}));
|
|
3359
|
+
}, { capture: true });
|
|
3360
|
+
}
|
|
2985
3361
|
installAnchorClickInterceptor({
|
|
2986
3362
|
dispatchToolName,
|
|
2987
3363
|
sessionId: meta.sessionId,
|
|
@@ -2999,6 +3375,10 @@ async function bootProduction(opts) {
|
|
|
2999
3375
|
manager,
|
|
3000
3376
|
streamBus,
|
|
3001
3377
|
onDispatchEnvelope: (envelope) => {
|
|
3378
|
+
// First hop of the gesture autopsy trail (see
|
|
3379
|
+
// `dispatchSubmitAction`'s sibling record): proves the wire
|
|
3380
|
+
// layer received the component's action envelope at all.
|
|
3381
|
+
currentTelemetrySink?.record('gesture.envelope', JSON.stringify({ type: envelope.type }));
|
|
3002
3382
|
if (envelope.type !== 'data:submit')
|
|
3003
3383
|
return;
|
|
3004
3384
|
const payload = envelope.payload;
|
|
@@ -3066,10 +3446,30 @@ async function bootProduction(opts) {
|
|
|
3066
3446
|
...(meta.gadgets !== undefined
|
|
3067
3447
|
? { gadgetPackages: meta.gadgets.map((g) => g.package) }
|
|
3068
3448
|
: {}),
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3449
|
+
// Strict-CSP module variant (ggui#522 slice 2) — attached to
|
|
3450
|
+
// the INLINE SEED mount only, detected by the absence of the
|
|
3451
|
+
// server-assigned ledger fields (`buildGguiSessionSeedInput`
|
|
3452
|
+
// omits them; every wire-delivered GguiSession carries them).
|
|
3453
|
+
// The variant URL names the SEEDED bytes: a WS-delivered
|
|
3454
|
+
// body may differ, and importing a stale variant would paint
|
|
3455
|
+
// the WRONG component rather than fail. A WS redelivery of
|
|
3456
|
+
// the SAME bytes loses nothing — same code ⇒ the renderer
|
|
3457
|
+
// skips re-evaluation entirely.
|
|
3458
|
+
...(meta.codeModuleUrl !== undefined &&
|
|
3459
|
+
render.type !== 'mcpApps' &&
|
|
3460
|
+
render.type !== 'system' &&
|
|
3461
|
+
!('createdAt' in render)
|
|
3462
|
+
? { codeModuleUrl: meta.codeModuleUrl }
|
|
3072
3463
|
: {}),
|
|
3464
|
+
...(meta.themeId !== undefined ? { themeId: meta.themeId } : {}),
|
|
3465
|
+
// Mode: the slice's stamped `themeMode` (every operator layer
|
|
3466
|
+
// resolves into it) wins; when absent, the HOST's announced
|
|
3467
|
+
// theme fills the gap (ggui#551 — see `hostAnnouncedThemeMode`).
|
|
3468
|
+
// Absent-in-slice means "no operator opinion", not "light".
|
|
3469
|
+
...(() => {
|
|
3470
|
+
const themeMode = meta.themeMode ?? hostAnnouncedThemeMode();
|
|
3471
|
+
return themeMode !== undefined ? { themeMode } : {};
|
|
3472
|
+
})(),
|
|
3073
3473
|
// Per-app theme overlay (St3 M2.2). Threaded straight from the
|
|
3074
3474
|
// bootstrap's `_meta["ai.ggui/render"].theme` (typed `AppTheme`,
|
|
3075
3475
|
// already injection-validated by the wire parser) onto the mount
|
|
@@ -3185,9 +3585,28 @@ async function bootProduction(opts) {
|
|
|
3185
3585
|
streamBus,
|
|
3186
3586
|
validatorCtx,
|
|
3187
3587
|
}));
|
|
3588
|
+
// Freeze latch (#483). This mount's own history epoch, read once
|
|
3589
|
+
// from its boot meta: render → 0, an update-minted card → its N,
|
|
3590
|
+
// a pinned `#N` read → N. When a `props_update` frame carries a
|
|
3591
|
+
// higher epoch, a newer card superseded this one.
|
|
3592
|
+
const selfEpoch = typeof meta.epoch === 'number' ? meta.epoch : 0;
|
|
3593
|
+
let superseded = false;
|
|
3594
|
+
const supersede = () => {
|
|
3595
|
+
if (superseded)
|
|
3596
|
+
return;
|
|
3597
|
+
superseded = true;
|
|
3598
|
+
// Module-level dispatch guard: a history card's gestures no
|
|
3599
|
+
// longer target the live session.
|
|
3600
|
+
mountSuperseded = true;
|
|
3601
|
+
currentTelemetrySink?.record('epoch.frozen', String(selfEpoch));
|
|
3602
|
+
applyFreezeCue(renderInto);
|
|
3603
|
+
};
|
|
3188
3604
|
channelRegistry.register(createPropsUpdateHandler({
|
|
3189
3605
|
getCurrentGguiSession: () => currentRender,
|
|
3190
3606
|
applyRender,
|
|
3607
|
+
getSelfEpoch: () => selfEpoch,
|
|
3608
|
+
onSuperseded: supersede,
|
|
3609
|
+
isSuperseded: () => superseded,
|
|
3191
3610
|
}));
|
|
3192
3611
|
channelRegistry.register(createDrainAckHandler({ dispatch: dispatchDrainAck }));
|
|
3193
3612
|
channelRegistry.register(createChannelPayloadHandler({
|
|
@@ -3238,5 +3657,8 @@ async function bootProduction(opts) {
|
|
|
3238
3657
|
...(opts.preResolvedMeta !== undefined
|
|
3239
3658
|
? { preResolvedMeta: opts.preResolvedMeta }
|
|
3240
3659
|
: {}),
|
|
3660
|
+
...(opts.preResolvedLocator !== undefined
|
|
3661
|
+
? { preResolvedLocator: opts.preResolvedLocator }
|
|
3662
|
+
: {}),
|
|
3241
3663
|
});
|
|
3242
3664
|
}
|