@ggui-ai/iframe-runtime 0.24.0 → 0.26.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/README.md +2 -2
- package/dist/channels/data.d.ts +8 -0
- package/dist/channels/data.js +2 -0
- package/dist/credential-controller.d.ts +71 -0
- package/dist/credential-controller.js +135 -0
- package/dist/iframe-runtime.js +97 -93
- package/dist/iframe-runtime.meta.json +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/ladders.d.ts +77 -0
- package/dist/ladders.js +383 -0
- package/dist/meta-parse.js +38 -8
- package/dist/observability.d.ts +38 -11
- package/dist/react-renderer.d.ts +22 -0
- package/dist/react-renderer.js +118 -3
- package/dist/registry-subscribe.d.ts +53 -2
- package/dist/registry-subscribe.js +63 -8
- package/dist/render-failure-report.d.ts +15 -0
- package/dist/render-failure-report.js +61 -0
- package/dist/render-item.d.ts +6 -1
- package/dist/render-item.js +7 -2
- package/dist/runtime-telemetry.d.ts +28 -18
- package/dist/runtime-telemetry.js +68 -29
- package/dist/runtime.d.ts +157 -39
- package/dist/runtime.js +724 -368
- package/dist/shims/wire.js +1 -1
- package/dist/stream-seq.d.ts +56 -0
- package/dist/stream-seq.js +46 -0
- package/dist/system-cards/ProtocolProbeCard.d.ts +16 -0
- package/dist/system-cards/ProtocolProbeCard.js +17 -14
- package/dist/tool-result-error.d.ts +6 -0
- package/dist/tool-result-error.js +32 -0
- package/dist/types.d.ts +49 -1
- package/dist/view-origin.d.ts +29 -0
- package/dist/view-origin.js +33 -0
- package/dist/view-proof-signer.d.ts +31 -0
- package/dist/view-proof-signer.js +99 -0
- package/dist/view-root.d.ts +51 -0
- package/dist/view-root.js +56 -0
- package/dist/wire-config.d.ts +15 -1
- package/dist/wire-config.js +42 -1
- package/package.json +8 -7
package/dist/runtime.js
CHANGED
|
@@ -28,9 +28,13 @@
|
|
|
28
28
|
// schema-defining dependency initializes (see zod-jitless.ts).
|
|
29
29
|
import './zod-jitless.js';
|
|
30
30
|
import { runtimeConnectionWriter } from './connection.js';
|
|
31
|
-
import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
31
|
+
import { MCP_APP_BOOTSTRAP_FAILED_TYPE, MCP_APP_RENDERER_READY_TYPE, parseMcpAppAiGguiRenderMeta, readGguiShellEnvelope, GGUI_SHELL_META_MARKER, } from '@ggui-ai/protocol/integrations/mcp-apps';
|
|
32
32
|
import { effectiveThemeId, effectiveThemeMode, } from '@ggui-ai/protocol/integrations/theme-binding';
|
|
33
33
|
import { extractLocatorFromToolResult, parseMetaFromGlobal, parseMetaFromToolResult, validateMeta, READ_DOOR_REPORTERS, } from './meta-parse.js';
|
|
34
|
+
import { documentViewRoots, userActivationIsActive, withViewProof } from './view-origin.js';
|
|
35
|
+
import { createStreamSeqTracker } from './stream-seq.js';
|
|
36
|
+
import { createLadderSet } from './ladders.js';
|
|
37
|
+
import { BOOT_REFRESH_RETRIES, createCredentialController, } from './credential-controller.js';
|
|
34
38
|
import { projectHostContext } from '@ggui-ai/protocol/wire';
|
|
35
39
|
import { installDismissIntentListener } from './dismiss-intent.js';
|
|
36
40
|
import { App, PostMessageTransport } from '@modelcontextprotocol/ext-apps';
|
|
@@ -39,17 +43,17 @@ import { mapHostPaletteToGguiVars } from './host-palette-bridge.js';
|
|
|
39
43
|
import { buildRootWireConfig, StreamBus, } from './wire-config.js';
|
|
40
44
|
import { createChannelTransportRouter, } from './channel-transport.js';
|
|
41
45
|
import { RelayIncapableError } from './relay-incapability.js';
|
|
42
|
-
import { ChannelRegistry } from '@ggui-ai/live-channel';
|
|
43
46
|
import { createTelemetrySink, } from './runtime-telemetry.js';
|
|
44
47
|
import { createChannelErrorHandler, createChannelPayloadHandler, createDataHandler, createDrainAckHandler, createPropsUpdateHandler, createRenderHandler, } from './channels/index.js';
|
|
45
48
|
import { connectViaRegistry, } from './registry-subscribe.js';
|
|
46
|
-
import {
|
|
49
|
+
import { createSequenceCursor } from './events-polling.js';
|
|
47
50
|
import { unwrapCallToolResult } from './call-tool-unwrap.js';
|
|
48
51
|
import { ensureStatusDom, setStatus, setConnectedStatus, retireShellLoadingIndicator, } from './status-dom.js';
|
|
49
52
|
import { mergeReservedValidators, setActiveValidatorSet, } from './validation.js';
|
|
50
53
|
import { loadCompiledValidatorsFromUrl } from './compiled-validators.js';
|
|
51
54
|
import { mountRender, } from './render-item.js';
|
|
52
|
-
import {
|
|
55
|
+
import { createRenderFailureReporter } from './render-failure-report.js';
|
|
56
|
+
import { fromBootstrapFailure, fromTransportFailure, } from './protocol-error.js';
|
|
53
57
|
import { postObservabilityToParent, } from './observability.js';
|
|
54
58
|
import { ACTION_TOAST_Z_INDEX, mountUiFeedbackChrome, shouldMountUiFeedbackChrome, } from './ui-feedback-chrome.js';
|
|
55
59
|
import { hostCanReceiveMessages, hostCanRelayToolCalls, hostCapabilitiesCaptured, setHostCapabilities, } from './host-capabilities.js';
|
|
@@ -185,12 +189,10 @@ let activeThemeUnsubscribe = null;
|
|
|
185
189
|
* the spec's `'light' | 'dark'` literals (dropped, never guessed —
|
|
186
190
|
* the same tolerant posture as `projectHostContext`).
|
|
187
191
|
*
|
|
188
|
-
* This is the INPUT leg of host-theme adaptation
|
|
189
|
-
* (rnd/gen-ui/beauty/experiments/002-host-theme-adaptation.md, ggui#551):
|
|
192
|
+
* This is the INPUT leg of host-theme adaptation (ggui#551):
|
|
190
193
|
* `hostContext.theme` alone reaches only `data-theme` +
|
|
191
194
|
* `color-scheme` via `applyDocumentTheme` — nothing in the token
|
|
192
|
-
* pipeline keys off it, so a dark host painted the light ladder
|
|
193
|
-
* (host-fit 46.0 vs 87.7 with the dark ladder, probe `cc728a8eb`).
|
|
195
|
+
* pipeline keys off it, so a dark host painted the light ladder.
|
|
194
196
|
* Under ggui#987 §4 the embedding host OWNS runtime mode: when it
|
|
195
197
|
* announces one, `buildOpts` paints it over every stamped opinion; the
|
|
196
198
|
* stamped layers decide only when the host is silent. Read live from
|
|
@@ -416,10 +418,10 @@ export async function bootSequence(opts) {
|
|
|
416
418
|
// App handshake settles — whichever yields first wins; the listener
|
|
417
419
|
// is removed on resolve.
|
|
418
420
|
//
|
|
419
|
-
// Skipped entirely when `
|
|
421
|
+
// Skipped entirely when `preResolved` is set (autostart caught
|
|
420
422
|
// the toolresult or read the global early) — no listener installed,
|
|
421
423
|
// no race to run.
|
|
422
|
-
const toolResultPromise = opts.
|
|
424
|
+
const toolResultPromise = opts.preResolved !== undefined
|
|
423
425
|
? Promise.resolve(null)
|
|
424
426
|
: awaitToolResultMetaFromApp(app, toolResultTimeoutMs);
|
|
425
427
|
// Persistent re-mount listener — ALSO pre-connect (ggui#586). On a
|
|
@@ -429,7 +431,8 @@ export async function bootSequence(opts) {
|
|
|
429
431
|
// notice and leaning on SDK replay for correctness. Registering here
|
|
430
432
|
// covers every boot path; arrivals before a renderer publishes
|
|
431
433
|
// `applyRender` no-op inside the listener.
|
|
432
|
-
|
|
434
|
+
const viewRoots = opts.viewRoots ?? documentViewRoots;
|
|
435
|
+
installPersistentToolResultListener(app, viewRoots);
|
|
433
436
|
// Fresh boot ⇒ the relay latch and the connection store start
|
|
434
437
|
// aligned (ggui#670): a standing latch from a prior mount in a
|
|
435
438
|
// re-booted document must not outlive the store it was written to.
|
|
@@ -476,7 +479,7 @@ export async function bootSequence(opts) {
|
|
|
476
479
|
// Reading-B (retired Phase 1.19b.3 with the App-class swap; the
|
|
477
480
|
// McpUiInitializeResult schema doesn't define `toolOutput`).
|
|
478
481
|
//
|
|
479
|
-
// Tier 0
|
|
482
|
+
// Tier 0 preResolved — autostart-layer pre-resolution. When
|
|
480
483
|
// the autostart's own toolresult race or inline `__GGUI_META__`
|
|
481
484
|
// parse caught the meta before bootSequence ran, it threads
|
|
482
485
|
// the parsed slice meta here so we don't re-await the same
|
|
@@ -500,14 +503,18 @@ export async function bootSequence(opts) {
|
|
|
500
503
|
// When both tiers fail, we surface the Tier 2 timeout/failure reason
|
|
501
504
|
// (`MISSING_META_GGUI_BOOTSTRAP`) — it's the spec-canonical channel
|
|
502
505
|
// and its absence is the diagnostic worth showing.
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
+
//
|
|
507
|
+
// Every tier resolves a {@link BootMeta}: a slice whose live credential
|
|
508
|
+
// expired still boots, holding that credential for the one boot refresh
|
|
509
|
+
// below (ggui#1496 R1), instead of reading as no slice at all.
|
|
510
|
+
let resolved;
|
|
511
|
+
if (opts.preResolved !== undefined) {
|
|
512
|
+
resolved = opts.preResolved;
|
|
506
513
|
}
|
|
507
514
|
else {
|
|
508
|
-
const inline =
|
|
509
|
-
if (inline
|
|
510
|
-
|
|
515
|
+
const inline = readSelfContainedMeta();
|
|
516
|
+
if (inline !== null) {
|
|
517
|
+
resolved = inline;
|
|
511
518
|
}
|
|
512
519
|
else {
|
|
513
520
|
// Tier 1.5 — the read-plane door (ggui#537). A pre-boot tool
|
|
@@ -519,18 +526,17 @@ export async function bootSequence(opts) {
|
|
|
519
526
|
const viaDoor = opts.preResolvedLocator !== undefined
|
|
520
527
|
? await resolveMetaViaReadDoor(app, opts.preResolvedLocator)
|
|
521
528
|
: null;
|
|
522
|
-
|
|
523
|
-
if (fromToolResult !== null) {
|
|
524
|
-
parsed = { ok: true, meta: fromToolResult };
|
|
525
|
-
}
|
|
526
|
-
else {
|
|
527
|
-
parsed = {
|
|
528
|
-
ok: false,
|
|
529
|
-
reason: 'MISSING_META_GGUI_BOOTSTRAP',
|
|
530
|
-
};
|
|
531
|
-
}
|
|
529
|
+
resolved = viaDoor ?? (await toolResultPromise);
|
|
532
530
|
}
|
|
533
531
|
}
|
|
532
|
+
// Every reader of the inline envelope copies has run by now, and no card
|
|
533
|
+
// code has loaded yet (ggui#1415).
|
|
534
|
+
forgetInlineEnvelopes(doc);
|
|
535
|
+
let parsed = resolved !== null
|
|
536
|
+
? { ok: true, meta: resolved.meta }
|
|
537
|
+
: { ok: false, reason: 'MISSING_META_GGUI_BOOTSTRAP' };
|
|
538
|
+
const held = resolved?.held;
|
|
539
|
+
const viewRoot = resolved?.viewRoot;
|
|
534
540
|
// hostContext is captured opportunistically from
|
|
535
541
|
// `app.getHostContext()` — populated by App's `ui/initialize`
|
|
536
542
|
// response capture + kept fresh by the `hostcontextchanged`
|
|
@@ -563,6 +569,9 @@ export async function bootSequence(opts) {
|
|
|
563
569
|
// `ai.ggui/render` slice. The parser surfaces it directly on
|
|
564
570
|
// `parsed.meta`.
|
|
565
571
|
const meta = parsed.meta;
|
|
572
|
+
// The boot slice's view key root (ggui#1415), adopted only when it decodes
|
|
573
|
+
// to this slice's session.
|
|
574
|
+
viewRoots.offer(meta.sessionId, viewRoot);
|
|
566
575
|
// Install the precompiled, eval-free contract validators shipped on
|
|
567
576
|
// the bootstrap BEFORE any wire traffic is validated. Self-contained
|
|
568
577
|
// ESM modules — no `__ggui__` dependency, so this need not wait for
|
|
@@ -576,6 +585,22 @@ export async function bootSequence(opts) {
|
|
|
576
585
|
// is the pin; the render handler drops any frame addressed
|
|
577
586
|
// elsewhere.
|
|
578
587
|
const pinnedSessionId = meta.sessionId;
|
|
588
|
+
// The view's live credential (ggui#1496). One controller owns it and its
|
|
589
|
+
// one-refresh budget, over the same host `tools/call` relay the bridge
|
|
590
|
+
// rung uses; it exists when the view holds a credential and has that
|
|
591
|
+
// relay. Every subscribe frame reads the CURRENT token when it is built,
|
|
592
|
+
// so an adopted credential reaches the next subscribe payload (F5).
|
|
593
|
+
const credentialApp = getCurrentApp();
|
|
594
|
+
const initialCredential = held ?? heldCredentialOf(meta);
|
|
595
|
+
const credentialController = credentialApp !== null && initialCredential !== undefined
|
|
596
|
+
? createCredentialController({
|
|
597
|
+
initial: initialCredential,
|
|
598
|
+
callTool: (name, args) => credentialApp.callServerTool({ name, arguments: args }),
|
|
599
|
+
...(opts.credentialTiming?.sleep !== undefined ? { sleep: opts.credentialTiming.sleep } : {}),
|
|
600
|
+
...(opts.credentialTiming?.random !== undefined ? { random: opts.credentialTiming.random } : {}),
|
|
601
|
+
})
|
|
602
|
+
: null;
|
|
603
|
+
let liveMeta = meta;
|
|
579
604
|
// Renderer wiring — when supplied, the handler routes frames through
|
|
580
605
|
// the single-render mount surface + WireConfig + StreamBus. When
|
|
581
606
|
// absent, the placeholder path runs (boot.test.ts relies on the
|
|
@@ -618,20 +643,13 @@ export async function bootSequence(opts) {
|
|
|
618
643
|
});
|
|
619
644
|
}
|
|
620
645
|
setStatus(refs, `Connecting to ${meta.sessionId}…`, 'connecting');
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
? createPlaceholderRegistry({
|
|
629
|
-
meta,
|
|
630
|
-
statusRefs: refs,
|
|
631
|
-
pinnedSessionId,
|
|
632
|
-
})
|
|
633
|
-
: null;
|
|
634
|
-
const activeRegistry = renderer?.channelRegistry ?? placeholderRegistry;
|
|
646
|
+
// The handlers every ladder delivers through. Boot-without-renderer
|
|
647
|
+
// (boot.test.ts) gets just the `render` placeholder handler, so
|
|
648
|
+
// non-renderer consumers observe bootstrap-orchestration outcomes
|
|
649
|
+
// without paying React import cost; it logs status but does not mount
|
|
650
|
+
// React.
|
|
651
|
+
const channelHandlers = renderer?.channelHandlers ??
|
|
652
|
+
createPlaceholderHandlers({ statusRefs: refs, pinnedSessionId });
|
|
635
653
|
/**
|
|
636
654
|
* Apply an ack's render snapshot to the runtime — when the ack
|
|
637
655
|
* carries a render matching `pinnedSessionId`, mount it through the
|
|
@@ -723,10 +741,9 @@ export async function bootSequence(opts) {
|
|
|
723
741
|
// A bootstrap with NEITHER has nothing to show and nowhere to
|
|
724
742
|
// subscribe.
|
|
725
743
|
const hasStaticContent = hasStaticContentMeta(meta);
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
meta.wsToken.length > 0;
|
|
744
|
+
// A held credential counts as a pending live trio on a bridge-capable
|
|
745
|
+
// boot: the boot refresh below may make it one (ggui#1496 F7).
|
|
746
|
+
const liveTrioPending = hasLiveTrio(meta) || (held !== undefined && credentialController !== null);
|
|
730
747
|
// Transport-telemetry sink — the iframe's self-report channel (see
|
|
731
748
|
// runtime-telemetry.ts). Created for EVERY boot with an App bridge;
|
|
732
749
|
// the first batch carries the boot-path decision so operators can
|
|
@@ -745,9 +762,14 @@ export async function bootSequence(opts) {
|
|
|
745
762
|
mountSuperseded = false;
|
|
746
763
|
telemetry?.record('boot.path', JSON.stringify({
|
|
747
764
|
hasStaticContent,
|
|
748
|
-
hasLiveTrio,
|
|
765
|
+
hasLiveTrio: hasLiveTrio(meta),
|
|
766
|
+
heldCredential: held !== undefined,
|
|
749
767
|
bridgeCapable: telemetryApp !== null,
|
|
750
768
|
}));
|
|
769
|
+
// Set when the static fetch failed while a live trio was pending: if no
|
|
770
|
+
// live channel comes of it, the boot ends on this failure as it would
|
|
771
|
+
// have with no trio at all.
|
|
772
|
+
let staticMountFailure;
|
|
751
773
|
// ── Static seed mount — zero-round-trip paint, no WS required. ──────
|
|
752
774
|
// The ONLY mount path for spec-compliant MCP-Apps hosts that expose no
|
|
753
775
|
// ggui live channel (claude.ai / ChatGPT / Claude Desktop), AND the
|
|
@@ -769,12 +791,13 @@ export async function bootSequence(opts) {
|
|
|
769
791
|
}
|
|
770
792
|
catch (err) {
|
|
771
793
|
const message = err instanceof Error ? err.message : String(err);
|
|
772
|
-
if (!
|
|
794
|
+
if (!liveTrioPending) {
|
|
773
795
|
// No WS fallback — a failed static fetch is terminal.
|
|
774
796
|
setStatus(refs, `static mount failed: ${message}`, 'error');
|
|
775
797
|
emitBootFailure('UI_INITIALIZE_FAILED', message);
|
|
776
798
|
return { ok: false, mountedRender };
|
|
777
799
|
}
|
|
800
|
+
staticMountFailure = message;
|
|
778
801
|
// A live trio is present — the WS ack will deliver the render.
|
|
779
802
|
// The seed was best-effort; fall through to subscribe. Warn so a
|
|
780
803
|
// degraded boot (slower first paint, ack-only delivery) is visible
|
|
@@ -802,8 +825,33 @@ export async function bootSequence(opts) {
|
|
|
802
825
|
// ladder at its tail). Gating the terminal rung behind the WS
|
|
803
826
|
// trio's presence disabled it on precisely the host class it was
|
|
804
827
|
// built for.
|
|
805
|
-
|
|
806
|
-
|
|
828
|
+
// ── The boot refresh (ggui#1496 R1). ─────────────────────────────────
|
|
829
|
+
// A slice whose live credential expired, on either shape, refreshes it
|
|
830
|
+
// once, now, through the host, before any ladder binds; an adopted
|
|
831
|
+
// credential makes this a live boot. A live-only slice has no other way
|
|
832
|
+
// to a live channel, so a relay error there is re-sent (F6); a slice with
|
|
833
|
+
// static content goes on with whatever the refresh answered.
|
|
834
|
+
if (held !== undefined && credentialController !== null) {
|
|
835
|
+
const outcome = await credentialController.onExpired(held, 'boot', hasStaticContent ? undefined : { retries: BOOT_REFRESH_RETRIES });
|
|
836
|
+
telemetry?.record('boot.credential_refresh', outcome.kind === 'refused' ? `refused:${outcome.code}` : outcome.kind);
|
|
837
|
+
if (outcome.kind === 'adopted')
|
|
838
|
+
liveMeta = withLiveCredential(meta, outcome.credential);
|
|
839
|
+
}
|
|
840
|
+
const live = hasLiveTrio(liveMeta);
|
|
841
|
+
const staticOnlyNoBridge = !live && getCurrentApp() === null;
|
|
842
|
+
if (!live && mountedRender === null) {
|
|
843
|
+
if (staticMountFailure !== undefined) {
|
|
844
|
+
setStatus(refs, `static mount failed: ${staticMountFailure}`, 'error');
|
|
845
|
+
emitBootFailure('UI_INITIALIZE_FAILED', staticMountFailure);
|
|
846
|
+
return { ok: false, mountedRender };
|
|
847
|
+
}
|
|
848
|
+
if (held !== undefined) {
|
|
849
|
+
const message = 'the live credential expired and was not refreshed';
|
|
850
|
+
setStatus(refs, message, 'error');
|
|
851
|
+
retireShellLoadingIndicator(doc);
|
|
852
|
+
emitBootFailure('EXPIRED_BOOTSTRAP', message);
|
|
853
|
+
return { ok: false, mountedRender };
|
|
854
|
+
}
|
|
807
855
|
const message = 'bootstrap carries neither static content (codeUrl/codeB64/kind) nor a live trio (wsUrl/wsToken)';
|
|
808
856
|
setStatus(refs, message, 'error');
|
|
809
857
|
retireShellLoadingIndicator(doc);
|
|
@@ -839,11 +887,11 @@ export async function bootSequence(opts) {
|
|
|
839
887
|
// `/api/sessions/<id>/stream` URL; SSE `id:` = ledger sequence = the
|
|
840
888
|
// SAME cursor model as the WS `sinceSequence` replay and polling
|
|
841
889
|
// ticks — switching rungs does not lose events.
|
|
842
|
-
const sseUrl = typeof
|
|
843
|
-
?
|
|
890
|
+
const sseUrl = typeof liveMeta.sseUrl === 'string' && liveMeta.sseUrl.length > 0
|
|
891
|
+
? liveMeta.sseUrl
|
|
844
892
|
: undefined;
|
|
845
|
-
const pollingBaseUrl = typeof
|
|
846
|
-
?
|
|
893
|
+
const pollingBaseUrl = typeof liveMeta.pollingUrl === 'string' && liveMeta.pollingUrl.length > 0
|
|
894
|
+
? liveMeta.pollingUrl
|
|
847
895
|
: undefined;
|
|
848
896
|
// One shared cursor for the whole ladder — SSE deliveries advance it
|
|
849
897
|
// (via onSequence), the polling + bridge descriptors read it per
|
|
@@ -857,87 +905,111 @@ export async function bootSequence(opts) {
|
|
|
857
905
|
const ladderCursor = sseUrl !== undefined || pollingBaseUrl !== undefined || bridgeApp !== null
|
|
858
906
|
? createSequenceCursor(meta.lastSequence ?? 0)
|
|
859
907
|
: undefined;
|
|
908
|
+
// How a boot ends when its subscribe fails short of UPGRADE_REQUIRED:
|
|
909
|
+
// a transport or auth failure.
|
|
910
|
+
function endOnSubscribeFailure(message) {
|
|
911
|
+
if (mountedRender !== null) {
|
|
912
|
+
// DEGRADE — a transport/auth failure AFTER a static seed already
|
|
913
|
+
// painted. Keep the mounted content, skip live updates (props_update
|
|
914
|
+
// simply stops arriving), and do NOT tear down or surface a
|
|
915
|
+
// bootstrap failure. connectFn already emitted the typed
|
|
916
|
+
// `subscribe-failed` observability event on its own onObserve path.
|
|
917
|
+
setStatus(refs, `live updates unavailable: ${message}`, 'connecting');
|
|
918
|
+
emitCodeReadyOnce();
|
|
919
|
+
return { ok: true, mountedRender };
|
|
920
|
+
}
|
|
921
|
+
if (renderer !== null)
|
|
922
|
+
rendererHooks?.teardown?.(renderer);
|
|
923
|
+
setStatus(refs, `WS handshake failed: ${message}`, 'error');
|
|
924
|
+
emitBootFailure('WS_HANDSHAKE_FAILED', message);
|
|
925
|
+
return { ok: false, mountedRender };
|
|
926
|
+
}
|
|
927
|
+
// The ladder set (ggui#1496) owns every ladder this view binds: the boot
|
|
928
|
+
// ladder below, and each one an adopted credential builds after it. It
|
|
929
|
+
// composes a ladder's fallback rungs (WS → SSE → polling → bridge-pull)
|
|
930
|
+
// from the credential's server-stamped wsToken-gated URLs and the shared
|
|
931
|
+
// cursor seeded from `meta.lastSequence` — same cursor model as the WS
|
|
932
|
+
// `sinceSequence` replay, so switching transports loses no event. The
|
|
933
|
+
// bridge rung, the universal floor for CSP-jailed hosts (claude.ai),
|
|
934
|
+
// exists whenever the App handle is bound.
|
|
935
|
+
const ladders = createLadderSet({
|
|
936
|
+
meta,
|
|
937
|
+
handlers: channelHandlers,
|
|
938
|
+
cursor: ladderCursor,
|
|
939
|
+
streamSeq: renderer?.streamSeq,
|
|
940
|
+
controller: credentialController,
|
|
941
|
+
connectFn,
|
|
942
|
+
bridgeCallTool: bridgeApp !== null ? bridgeCallToolVia(bridgeApp) : undefined,
|
|
943
|
+
onStatus: (status) => {
|
|
944
|
+
telemetry?.record(`status.${status}`);
|
|
945
|
+
setStatus(refs, status === 'connected' ? 'Connected.' : `Connection ${status}…`, status);
|
|
946
|
+
// Propagate WS status to the per-channel transport router so
|
|
947
|
+
// it can flip WS-bound channels into polling
|
|
948
|
+
// fallback (on disconnect) and re-send `channel_subscribe`
|
|
949
|
+
// on reconnect. No-op when renderer wiring is absent.
|
|
950
|
+
if (renderer !== null) {
|
|
951
|
+
renderer.channelTransport.onWsStatusChange(status);
|
|
952
|
+
}
|
|
953
|
+
},
|
|
954
|
+
// Reconnect-with-rebootstrap — every ack after the boot's first one,
|
|
955
|
+
// from the ladder holding the data (a WS reconnect's, or a rebuilt
|
|
956
|
+
// ladder's), reapplies the server's authoritative `render` snapshot:
|
|
957
|
+
// a render or update that landed while the live channel was down
|
|
958
|
+
// restores without an agent re-prompt. The first render a refreshing
|
|
959
|
+
// boot receives this way is its code-ready.
|
|
960
|
+
onAck: (ack) => {
|
|
961
|
+
void applyAck(ack).then(() => {
|
|
962
|
+
if (mountedRender !== null)
|
|
963
|
+
emitCodeReadyOnce();
|
|
964
|
+
});
|
|
965
|
+
},
|
|
966
|
+
// ggui#1424 — the channel door's refusal of a spent one-shot reads as done.
|
|
967
|
+
onPostAckError: (payload) => handlePostAckErrorFrame(meta.sessionId, payload),
|
|
968
|
+
onRefresh: (source, outcome) => {
|
|
969
|
+
telemetry?.record('credential.refresh', `${source}:${outcome.kind === 'refused' ? `refused:${outcome.code}` : outcome.kind}`);
|
|
970
|
+
},
|
|
971
|
+
// The server's stream counter restarted (a new epoch, or a count below
|
|
972
|
+
// this view's cursor); the cursor was forgotten so new envelopes are
|
|
973
|
+
// not dropped as repeats.
|
|
974
|
+
onStreamRestart: (streamSeq, streamEpoch) => {
|
|
975
|
+
telemetry?.record('stream.counter_restart', `${streamSeq ?? '-'}:${streamEpoch ?? '-'}`);
|
|
976
|
+
},
|
|
977
|
+
// An honest end (R4, ggui#1496): a bridge pull confirmed the live
|
|
978
|
+
// channel cannot come back — the host refuses to relay it, or the
|
|
979
|
+
// session is gone. Every ladder is already disposed; a painted card
|
|
980
|
+
// stays, under a status that says so, with one typed error and one
|
|
981
|
+
// observability event naming the confirmation.
|
|
982
|
+
isConfirmedRelayRefusalCode: (code) => CONFIRMED_RELAY_REFUSAL_CODES.has(code),
|
|
983
|
+
onEnded: (end) => {
|
|
984
|
+
const why = end.confirmation === 'relay-refusal'
|
|
985
|
+
? `the host refused to relay the live channel (${end.code})`
|
|
986
|
+
: `the session is gone (session_not_found, ${end.after === 'a-successful-pull' ? 'after a successful pull' : 'twice in a row'})`;
|
|
987
|
+
telemetry?.record('live.ended', JSON.stringify(end));
|
|
988
|
+
setStatus(refs, `Live updates ended: ${why}.`, 'error');
|
|
989
|
+
onProtocolError?.(fromTransportFailure('DISCONNECTED', false, why));
|
|
990
|
+
onObserve?.({ kind: 'subscribe-failed', reason: 'live-channel-ended', message: why });
|
|
991
|
+
},
|
|
992
|
+
// Live-channel diagnostics tap — every channel_* event the
|
|
993
|
+
// transports emit (failover swaps, polling budget exhaustion,
|
|
994
|
+
// SSE lifecycle) lands in the telemetry buffer. Without this the
|
|
995
|
+
// ladder's story on sandboxed hosts is unobservable.
|
|
996
|
+
...(telemetry !== null ? { logger: telemetry.channelLogger } : {}),
|
|
997
|
+
// Typed errors and observability from each subscribe, forwarded
|
|
998
|
+
// through the runtime's own emitters.
|
|
999
|
+
...(onProtocolError !== undefined ? { onProtocolError } : {}),
|
|
1000
|
+
...(onObserve !== undefined ? { onObserve } : {}),
|
|
1001
|
+
});
|
|
1002
|
+
// The boot ladder carries the controller's credential object itself, so
|
|
1003
|
+
// an expiry it reports is recognised as the current credential's.
|
|
1004
|
+
const bootCredential = hasLiveTrio(liveMeta)
|
|
1005
|
+
? (credentialController?.current() ?? heldCredentialOf(liveMeta))
|
|
1006
|
+
: undefined;
|
|
860
1007
|
let handle;
|
|
861
1008
|
try {
|
|
862
|
-
handle = await
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
// transports emit (failover swaps, polling budget exhaustion,
|
|
867
|
-
// SSE lifecycle) lands in the telemetry buffer. Without this the
|
|
868
|
-
// ladder's story on sandboxed hosts is unobservable.
|
|
869
|
-
...(telemetry !== null ? { logger: telemetry.channelLogger } : {}),
|
|
870
|
-
onStatusChange: (status) => {
|
|
871
|
-
telemetry?.record(`status.${status}`);
|
|
872
|
-
setStatus(refs, status === 'connected' ? 'Connected.' : `Connection ${status}…`, status);
|
|
873
|
-
// Propagate WS status to the per-channel transport router so
|
|
874
|
-
// it can flip WS-bound channels into polling
|
|
875
|
-
// fallback (on disconnect) and re-send `channel_subscribe`
|
|
876
|
-
// on reconnect. No-op when renderer wiring is absent.
|
|
877
|
-
if (renderer !== null) {
|
|
878
|
-
renderer.channelTransport.onWsStatusChange(status);
|
|
879
|
-
}
|
|
880
|
-
},
|
|
881
|
-
// Forward typed errors from subscribe through the runtime's
|
|
882
|
-
// own emitter — connectViaRegistry classifies transport + auth
|
|
883
|
-
// + version + protocol errors on its own, we just plumb them
|
|
884
|
-
// along.
|
|
885
|
-
...(onProtocolError !== undefined ? { onProtocolError } : {}),
|
|
886
|
-
// Forward observability emissions — connectFn owns the
|
|
887
|
-
// schema-version-mismatch + subscribe-failed kinds.
|
|
888
|
-
...(onObserve !== undefined ? { onObserve } : {}),
|
|
889
|
-
// Reconnect-with-rebootstrap — on every ack received AFTER the
|
|
890
|
-
// initial handshake settled, reapply the server's authoritative
|
|
891
|
-
// `render` snapshot. A render or update that landed during a WS
|
|
892
|
-
// dropout window restores here without an agent re-prompt.
|
|
893
|
-
onResubscribeAck: (ack) => {
|
|
894
|
-
void applyAck(ack);
|
|
895
|
-
},
|
|
896
|
-
// Failover-ladder fallback rungs (WS → SSE → polling →
|
|
897
|
-
// bridge-pull), composed once at bind time from the
|
|
898
|
-
// server-stamped wsToken-gated URLs + `meta.lastSequence`
|
|
899
|
-
// (shared cursor seed). FailoverHandle descends a rung on each
|
|
900
|
-
// 'failed'; the bridge rung is terminal (never emits 'failed').
|
|
901
|
-
//
|
|
902
|
-
// Same cursor model as the WS subscribe `sinceSequence` replay
|
|
903
|
-
// path — switching transports does not lose events.
|
|
904
|
-
...(sseUrl !== undefined && ladderCursor !== undefined
|
|
905
|
-
? {
|
|
906
|
-
sse: {
|
|
907
|
-
url: sseUrl,
|
|
908
|
-
initialSinceSequence: ladderCursor.get(),
|
|
909
|
-
onSequence: (seq) => {
|
|
910
|
-
ladderCursor.advance(seq);
|
|
911
|
-
},
|
|
912
|
-
},
|
|
913
|
-
}
|
|
914
|
-
: {}),
|
|
915
|
-
...(pollingBaseUrl !== undefined && ladderCursor !== undefined
|
|
916
|
-
? {
|
|
917
|
-
polling: buildEventsPolling({
|
|
918
|
-
baseUrl: pollingBaseUrl,
|
|
919
|
-
cursor: ladderCursor,
|
|
920
|
-
}),
|
|
921
|
-
}
|
|
922
|
-
: {}),
|
|
923
|
-
// Bridge-pull terminal rung — composed whenever the App handle
|
|
924
|
-
// is bound (it always is by this point in the boot: connectApp
|
|
925
|
-
// succeeded and setCurrentApp ran above), INDEPENDENT of
|
|
926
|
-
// sseUrl/pollingUrl presence. This is the universal floor for
|
|
927
|
-
// CSP-jailed hosts (claude.ai) where the iframe has no network
|
|
928
|
-
// path at all: the ledger is pulled via `ggui_runtime_pull`
|
|
929
|
-
// `tools/call`s over the host's postMessage bridge. Shares the
|
|
930
|
-
// SAME ladder cursor — a demotion into the bridge resumes from
|
|
931
|
-
// whatever the rungs above already delivered.
|
|
932
|
-
...(bridgeApp !== null && ladderCursor !== undefined
|
|
933
|
-
? {
|
|
934
|
-
bridge: buildBridgePolling({
|
|
935
|
-
callTool: (name, args) => bridgeApp.callServerTool({ name, arguments: args }),
|
|
936
|
-
sessionId: meta.sessionId,
|
|
937
|
-
cursor: ladderCursor,
|
|
938
|
-
}),
|
|
939
|
-
}
|
|
940
|
-
: {}),
|
|
1009
|
+
handle = await ladders.connectBoot({
|
|
1010
|
+
credential: bootCredential,
|
|
1011
|
+
sseUrl,
|
|
1012
|
+
pollingUrl: pollingBaseUrl,
|
|
941
1013
|
});
|
|
942
1014
|
}
|
|
943
1015
|
catch (err) {
|
|
@@ -956,24 +1028,21 @@ export async function bootSequence(opts) {
|
|
|
956
1028
|
emitBootFailure('UPGRADE_REQUIRED', message);
|
|
957
1029
|
return { ok: false, mountedRender };
|
|
958
1030
|
}
|
|
959
|
-
|
|
960
|
-
if (mountedRender !== null) {
|
|
961
|
-
// DEGRADE — a transport/auth failure AFTER a static seed already
|
|
962
|
-
// painted. Keep the mounted content, skip live updates (props_update
|
|
963
|
-
// simply stops arriving), and do NOT tear down or surface a
|
|
964
|
-
// bootstrap failure. connectFn already emitted the typed
|
|
965
|
-
// `subscribe-failed` observability event on its own onObserve path.
|
|
966
|
-
setStatus(refs, `live updates unavailable: ${message}`, 'connecting');
|
|
967
|
-
emitCodeReadyOnce();
|
|
968
|
-
return { ok: true, mountedRender };
|
|
969
|
-
}
|
|
970
|
-
if (renderer !== null)
|
|
971
|
-
rendererHooks?.teardown?.(renderer);
|
|
972
|
-
setStatus(refs, `WS handshake failed: ${message}`, 'error');
|
|
973
|
-
emitBootFailure('WS_HANDSHAKE_FAILED', message);
|
|
974
|
-
return { ok: false, mountedRender };
|
|
1031
|
+
return endOnSubscribeFailure(err instanceof Error ? err.message : String(err));
|
|
975
1032
|
}
|
|
976
|
-
|
|
1033
|
+
// A pre-ack auth-class refusal resolves with the ladder instead of
|
|
1034
|
+
// rejecting (ggui#1496 fact 3). For an expired credential the boot
|
|
1035
|
+
// ladder has already asked for a refresh; when it adopts a new one, the
|
|
1036
|
+
// ladder that credential builds takes the view over, so the boot goes on
|
|
1037
|
+
// wiring. A refresh that is skipped or refused, and any other refusal,
|
|
1038
|
+
// end the boot as the rejection did.
|
|
1039
|
+
const bootRefresh = handle.refused?.code === 'BOOTSTRAP_EXPIRED' ? await ladders.bootRefresh() : undefined;
|
|
1040
|
+
const refreshing = bootRefresh?.kind === 'adopted';
|
|
1041
|
+
if (handle.refused !== undefined && !refreshing) {
|
|
1042
|
+
return endOnSubscribeFailure(handle.refused.message ?? handle.refused.code);
|
|
1043
|
+
}
|
|
1044
|
+
// Health (#1383): booleans only; the handle's kind string stays home.
|
|
1045
|
+
telemetry?.record('subscribe.resolved', JSON.stringify({ hasAck: handle.ack !== undefined }));
|
|
977
1046
|
// Attach the live transport handle to the renderer — flushes any
|
|
978
1047
|
// buffered outbound `action` frames that were queued while the
|
|
979
1048
|
// subscribe handshake completed. `send` narrows to the ws surface:
|
|
@@ -981,8 +1050,8 @@ export async function bootSequence(opts) {
|
|
|
981
1050
|
// ride app.callServerTool, not the transport), so its sends drop —
|
|
982
1051
|
// the same posture a post-swap FailoverHandle already has.
|
|
983
1052
|
const transportSend = (msg) => {
|
|
984
|
-
|
|
985
|
-
|
|
1053
|
+
// The active ladder's WS (ggui#1496): after a rebind, the new one's.
|
|
1054
|
+
ladders.send(msg);
|
|
986
1055
|
};
|
|
987
1056
|
if (renderer !== null && rendererHooks?.attachManager !== undefined) {
|
|
988
1057
|
rendererHooks.attachManager(renderer, { send: transportSend });
|
|
@@ -1015,6 +1084,14 @@ export async function bootSequence(opts) {
|
|
|
1015
1084
|
if (handle.ack !== undefined) {
|
|
1016
1085
|
await applyAck(handle.ack);
|
|
1017
1086
|
}
|
|
1087
|
+
if (refreshing) {
|
|
1088
|
+
// A refreshed credential was adopted and its ladder is binding; it takes
|
|
1089
|
+
// the view over at its ack, which applies the render (and code-ready
|
|
1090
|
+
// with it). Until then the active ladder's own status stands.
|
|
1091
|
+
if (mountedRender !== null)
|
|
1092
|
+
emitCodeReadyOnce();
|
|
1093
|
+
return { ok: true, mountedRender };
|
|
1094
|
+
}
|
|
1018
1095
|
setConnectedStatus(refs);
|
|
1019
1096
|
// Lifecycle `code-ready` — terminal happy state. Emitted ONCE per boot
|
|
1020
1097
|
// (a static+live meta already fired it from the seed mount, so this is
|
|
@@ -1025,32 +1102,20 @@ export async function bootSequence(opts) {
|
|
|
1025
1102
|
return { ok: true, mountedRender };
|
|
1026
1103
|
}
|
|
1027
1104
|
/**
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
*
|
|
1033
|
-
*
|
|
1034
|
-
* supplies a fully-populated renderer with the rich handler set.
|
|
1105
|
+
* The handler set for boot paths without renderer wiring (boot.test.ts +
|
|
1106
|
+
* the C7a placeholder-only spec): just the `render` handler — which logs
|
|
1107
|
+
* status but does not mount React — so consumers can observe
|
|
1108
|
+
* bootstrap-orchestration outcomes without paying React import cost.
|
|
1109
|
+
* Every other frame type silently drops. Production boots through
|
|
1110
|
+
* `bootProduction`, whose renderer supplies the rich handler set.
|
|
1035
1111
|
*/
|
|
1036
|
-
function
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
sessionId: params.meta.sessionId,
|
|
1042
|
-
appId: params.meta.appId,
|
|
1043
|
-
...(params.meta.wsToken !== undefined
|
|
1044
|
-
? { wsToken: params.meta.wsToken }
|
|
1045
|
-
: {}),
|
|
1046
|
-
},
|
|
1112
|
+
function createPlaceholderHandlers(params) {
|
|
1113
|
+
return [
|
|
1114
|
+
createRenderHandler({
|
|
1115
|
+
statusRefs: params.statusRefs,
|
|
1116
|
+
pinnedSessionId: params.pinnedSessionId,
|
|
1047
1117
|
}),
|
|
1048
|
-
|
|
1049
|
-
registry.register(createRenderHandler({
|
|
1050
|
-
statusRefs: params.statusRefs,
|
|
1051
|
-
pinnedSessionId: params.pinnedSessionId,
|
|
1052
|
-
}));
|
|
1053
|
-
return registry;
|
|
1118
|
+
];
|
|
1054
1119
|
}
|
|
1055
1120
|
/**
|
|
1056
1121
|
* Frame dispatch lives inside `@ggui-ai/live-channel`'s
|
|
@@ -1083,52 +1148,58 @@ function shouldAutostart() {
|
|
|
1083
1148
|
return true;
|
|
1084
1149
|
return process.env?.GGUI_RENDERER_AUTOSTART !== 'false';
|
|
1085
1150
|
}
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1151
|
+
/**
|
|
1152
|
+
* The bridge rung's `tools/call` over the host relay (ggui#1415): every
|
|
1153
|
+
* `ggui_runtime_pull` it sends goes through {@link withViewProof}.
|
|
1154
|
+
*
|
|
1155
|
+
* @internal — exported for unit tests + production reuse.
|
|
1156
|
+
*/
|
|
1157
|
+
export function bridgeCallToolVia(app) {
|
|
1158
|
+
return (name, args) => app.callServerTool(withViewProof(name, args));
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Remove the inline copies of the boot envelope the shell left behind
|
|
1162
|
+
* (ggui#1415): the `__GGUI_META__` global, the pre-load tool-result buffer,
|
|
1163
|
+
* and the self-contained shell's envelope `<script>`, whose text outlives
|
|
1164
|
+
* the global. Called once the boot slice is resolved, after every reader and
|
|
1165
|
+
* before any card code loads; the key root is already held in this module.
|
|
1166
|
+
*
|
|
1167
|
+
* Hygiene, not a boundary: card code runs in this realm and can still ask
|
|
1168
|
+
* the host to read its own locator. This removes the free copies only.
|
|
1169
|
+
*/
|
|
1170
|
+
function forgetInlineEnvelopes(doc) {
|
|
1171
|
+
Reflect.deleteProperty(globalThis, '__GGUI_META__');
|
|
1172
|
+
Reflect.deleteProperty(globalThis, '__GGUI_PENDING_TOOL_RESULTS__');
|
|
1173
|
+
for (const script of Array.from(doc.querySelectorAll('script:not([src])'))) {
|
|
1174
|
+
if ((script.textContent ?? '').trimStart().startsWith(GGUI_SHELL_META_MARKER))
|
|
1175
|
+
script.remove();
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* A parse result as a boot slice: the ok arm, or an expired live-only
|
|
1180
|
+
* slice with its held credential (the boot refresh may still make it
|
|
1181
|
+
* live); `null` for every other failure.
|
|
1182
|
+
*/
|
|
1183
|
+
function bootMetaOf(result) {
|
|
1184
|
+
if (!result.ok && result.reason !== 'EXPIRED_BOOTSTRAP')
|
|
1185
|
+
return null;
|
|
1186
|
+
return {
|
|
1187
|
+
meta: result.meta,
|
|
1188
|
+
...(result.held !== undefined ? { held: result.held } : {}),
|
|
1189
|
+
...(result.viewRoot !== undefined ? { viewRoot: result.viewRoot } : {}),
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1118
1192
|
/**
|
|
1119
1193
|
* Read `globalThis.__GGUI_META__` synchronously, validate against
|
|
1120
|
-
* the unified slice-meta shape, and return the
|
|
1194
|
+
* the unified slice-meta shape, and return the boot slice (or null
|
|
1121
1195
|
* on absence / malformation).
|
|
1122
1196
|
*
|
|
1123
|
-
* Thin wrapper around {@link parseMetaFromGlobal}
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
* reader contract — the autostart resolver only needs the "valid
|
|
1127
|
-
* meta or fall through" signal.
|
|
1197
|
+
* Thin wrapper around {@link parseMetaFromGlobal}. Returns `null`
|
|
1198
|
+
* instead of {@link McpAppAiGguiMetaParseResult} because the autostart
|
|
1199
|
+
* resolver only needs the "a slice to boot, or fall through" signal.
|
|
1128
1200
|
*/
|
|
1129
1201
|
export function readSelfContainedMeta() {
|
|
1130
|
-
|
|
1131
|
-
return result.ok ? result.meta : null;
|
|
1202
|
+
return bootMetaOf(parseMetaFromGlobal());
|
|
1132
1203
|
}
|
|
1133
1204
|
/**
|
|
1134
1205
|
* Parse the bootstrap meta's `propsJson` string into a {@link JsonObject}.
|
|
@@ -1251,6 +1322,43 @@ export function hasStaticContentMeta(meta) {
|
|
|
1251
1322
|
(typeof meta.codeB64 === 'string' && meta.codeB64.length > 0) ||
|
|
1252
1323
|
(typeof meta.kind === 'string' && meta.kind.length > 0));
|
|
1253
1324
|
}
|
|
1325
|
+
/** Whether the meta carries a live trio a ladder can subscribe with (`wsUrl` + `wsToken`). */
|
|
1326
|
+
function hasLiveTrio(meta) {
|
|
1327
|
+
return (typeof meta.wsUrl === 'string' &&
|
|
1328
|
+
meta.wsUrl.length > 0 &&
|
|
1329
|
+
typeof meta.wsToken === 'string' &&
|
|
1330
|
+
meta.wsToken.length > 0);
|
|
1331
|
+
}
|
|
1332
|
+
/** The live credential a meta's trio carries, as the view holds it (ggui#1496); `undefined` without a trio. */
|
|
1333
|
+
function heldCredentialOf(meta) {
|
|
1334
|
+
if (!hasLiveTrio(meta) || meta.wsUrl === undefined || meta.wsToken === undefined)
|
|
1335
|
+
return undefined;
|
|
1336
|
+
return {
|
|
1337
|
+
wsToken: meta.wsToken,
|
|
1338
|
+
wsUrl: meta.wsUrl,
|
|
1339
|
+
...(meta.expiresAt !== undefined ? { expiresAt: meta.expiresAt } : {}),
|
|
1340
|
+
...(meta.sseUrl !== undefined ? { sseUrl: meta.sseUrl } : {}),
|
|
1341
|
+
...(meta.pollingUrl !== undefined ? { pollingUrl: meta.pollingUrl } : {}),
|
|
1342
|
+
origin: 'root',
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* The meta a ladder binds with once a credential is adopted (ggui#1496):
|
|
1347
|
+
* the boot meta with its live trio replaced by the credential's. A URL the
|
|
1348
|
+
* credential does not carry (its token could not be re-derived) is left
|
|
1349
|
+
* off, so that rung is not armed with a dead token.
|
|
1350
|
+
*/
|
|
1351
|
+
function withLiveCredential(meta, credential) {
|
|
1352
|
+
const { sseUrl: _sse, pollingUrl: _polling, ...rest } = meta;
|
|
1353
|
+
return {
|
|
1354
|
+
...rest,
|
|
1355
|
+
wsUrl: credential.wsUrl,
|
|
1356
|
+
wsToken: credential.wsToken,
|
|
1357
|
+
...(credential.expiresAt !== undefined ? { expiresAt: credential.expiresAt } : {}),
|
|
1358
|
+
...(credential.sseUrl !== undefined ? { sseUrl: credential.sseUrl } : {}),
|
|
1359
|
+
...(credential.pollingUrl !== undefined ? { pollingUrl: credential.pollingUrl } : {}),
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1254
1362
|
/**
|
|
1255
1363
|
* Decode a `codeB64` slice field to UTF-8 component source. `atob`
|
|
1256
1364
|
* yields a byte string; routing through `TextDecoder` restores
|
|
@@ -1286,8 +1394,7 @@ function decodeCodeB64(codeB64) {
|
|
|
1286
1394
|
* impossible because the shell never inspects the slice-meta shape.
|
|
1287
1395
|
*/
|
|
1288
1396
|
export function extractMetaFromToolResult(params) {
|
|
1289
|
-
|
|
1290
|
-
return result.ok ? result.meta : null;
|
|
1397
|
+
return bootMetaOf(parseMetaFromToolResult(params));
|
|
1291
1398
|
}
|
|
1292
1399
|
/**
|
|
1293
1400
|
* Drain `window.__GGUI_PENDING_TOOL_RESULTS__` — the buffer the
|
|
@@ -1314,9 +1421,9 @@ export function readPendingToolResults() {
|
|
|
1314
1421
|
if (!Array.isArray(raw) || raw.length === 0)
|
|
1315
1422
|
return null;
|
|
1316
1423
|
for (let i = raw.length - 1; i >= 0; i--) {
|
|
1317
|
-
const
|
|
1318
|
-
if (
|
|
1319
|
-
return
|
|
1424
|
+
const boot = extractMetaFromToolResult(raw[i]);
|
|
1425
|
+
if (boot !== null)
|
|
1426
|
+
return boot;
|
|
1320
1427
|
}
|
|
1321
1428
|
return null;
|
|
1322
1429
|
}
|
|
@@ -1366,9 +1473,9 @@ function awaitToolResultMetaFromApp(app, timeoutMs) {
|
|
|
1366
1473
|
const handler = (params) => {
|
|
1367
1474
|
if (settled)
|
|
1368
1475
|
return;
|
|
1369
|
-
const
|
|
1370
|
-
if (
|
|
1371
|
-
settle(
|
|
1476
|
+
const boot = extractMetaFromToolResult(params);
|
|
1477
|
+
if (boot !== null) {
|
|
1478
|
+
settle(boot);
|
|
1372
1479
|
return;
|
|
1373
1480
|
}
|
|
1374
1481
|
// Read-plane door (ggui#537): identity-only result → resolve the
|
|
@@ -1421,8 +1528,7 @@ export async function resolveMetaViaReadDoor(app, locator) {
|
|
|
1421
1528
|
const parsed = parseMcpAppAiGguiRenderMeta(envelope, READ_DOOR_REPORTERS);
|
|
1422
1529
|
if (!parsed.ok || parsed.meta === undefined)
|
|
1423
1530
|
return null;
|
|
1424
|
-
|
|
1425
|
-
return validated.ok ? validated.meta : null;
|
|
1531
|
+
return bootMetaOf(validateMeta(parsed.meta));
|
|
1426
1532
|
}
|
|
1427
1533
|
function awaitToolResultMeta(timeoutMs) {
|
|
1428
1534
|
if (typeof window === 'undefined')
|
|
@@ -1445,9 +1551,9 @@ function awaitToolResultMeta(timeoutMs) {
|
|
|
1445
1551
|
m.method !== 'ui/notifications/tool-result') {
|
|
1446
1552
|
return;
|
|
1447
1553
|
}
|
|
1448
|
-
const
|
|
1449
|
-
if (
|
|
1450
|
-
settle({ kind: 'meta',
|
|
1554
|
+
const boot = extractMetaFromToolResult(m.params);
|
|
1555
|
+
if (boot !== null) {
|
|
1556
|
+
settle({ kind: 'meta', boot });
|
|
1451
1557
|
return;
|
|
1452
1558
|
}
|
|
1453
1559
|
// Identity-only result (ggui#537): settle NOW with the locator so
|
|
@@ -1516,56 +1622,6 @@ function fnv1aHex(payload) {
|
|
|
1516
1622
|
}
|
|
1517
1623
|
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
1518
1624
|
}
|
|
1519
|
-
/**
|
|
1520
|
-
* Render a submit-action's `data` payload as a short inline string
|
|
1521
|
-
* for embedding in a `ui/message` consent prompt. Goal: human-
|
|
1522
|
-
* readable, not a JSON dump. Falls back to truncated JSON for
|
|
1523
|
-
* nested values so the prompt doesn't drop information silently.
|
|
1524
|
-
*
|
|
1525
|
-
* Exported for unit-testing — an earlier implementation returned `''`
|
|
1526
|
-
* for primitive payloads (strings/numbers/booleans), which silently
|
|
1527
|
-
* vaporised the chip's actual text from the consent prompt and made
|
|
1528
|
-
* the LLM think every dispatch was a contentless "Please proceed
|
|
1529
|
-
* with **<intent>**" request.
|
|
1530
|
-
*/
|
|
1531
|
-
export function formatSubmitActionDataInline(data) {
|
|
1532
|
-
if (data === null || data === undefined)
|
|
1533
|
-
return '';
|
|
1534
|
-
// Bare primitives: render verbatim. Strings unquoted (most legible
|
|
1535
|
-
// in a "Please proceed with X (foo)" sentence). Numbers / booleans
|
|
1536
|
-
// stringified.
|
|
1537
|
-
if (typeof data === 'string')
|
|
1538
|
-
return data;
|
|
1539
|
-
if (typeof data === 'number' || typeof data === 'boolean')
|
|
1540
|
-
return String(data);
|
|
1541
|
-
// Arrays: short JSON, truncated. Length cap mirrors the per-entry
|
|
1542
|
-
// cap below so the consent line stays a single human-readable phrase.
|
|
1543
|
-
if (Array.isArray(data)) {
|
|
1544
|
-
const json = JSON.stringify(data);
|
|
1545
|
-
return json.length > 60 ? `${json.slice(0, 57)}…` : json;
|
|
1546
|
-
}
|
|
1547
|
-
if (typeof data !== 'object')
|
|
1548
|
-
return '';
|
|
1549
|
-
const entries = Object.entries(data);
|
|
1550
|
-
if (entries.length === 0)
|
|
1551
|
-
return '';
|
|
1552
|
-
const parts = entries.map(([k, v]) => {
|
|
1553
|
-
if (v === null)
|
|
1554
|
-
return `${k}: null`;
|
|
1555
|
-
if (v === undefined)
|
|
1556
|
-
return `${k}: undefined`;
|
|
1557
|
-
if (typeof v === 'string')
|
|
1558
|
-
return `${k}: ${v}`;
|
|
1559
|
-
if (typeof v === 'number' || typeof v === 'boolean')
|
|
1560
|
-
return `${k}: ${v}`;
|
|
1561
|
-
// `JSON.stringify` returns `undefined` for unrepresentable values
|
|
1562
|
-
// (e.g. a `function` field on a form payload object) — guard so
|
|
1563
|
-
// the subsequent `.length` access doesn't crash the dispatch path.
|
|
1564
|
-
const json = JSON.stringify(v) ?? String(v);
|
|
1565
|
-
return `${k}: ${json.length > 40 ? `${json.slice(0, 37)}…` : json}`;
|
|
1566
|
-
});
|
|
1567
|
-
return parts.join(', ');
|
|
1568
|
-
}
|
|
1569
1625
|
/**
|
|
1570
1626
|
* Post an arbitrary JSON-RPC envelope to the iframe's parent
|
|
1571
1627
|
* window. Internal helper shared across {@link emitAudit} (the
|
|
@@ -1646,7 +1702,8 @@ function callAppRequestDisplayMode(params) {
|
|
|
1646
1702
|
* migrated to `app.callServerTool` — see emitAudit for the same
|
|
1647
1703
|
* posture).
|
|
1648
1704
|
*/
|
|
1649
|
-
|
|
1705
|
+
/** @internal — exported for unit tests. */
|
|
1706
|
+
export const productionContextSnapshotPoster = {
|
|
1650
1707
|
postUpdateModelContext: (params) => {
|
|
1651
1708
|
callAppUpdateModelContext(params);
|
|
1652
1709
|
},
|
|
@@ -1655,14 +1712,11 @@ const productionContextSnapshotPoster = {
|
|
|
1655
1712
|
jsonrpc: '2.0',
|
|
1656
1713
|
id: Math.floor(Math.random() * 1e9),
|
|
1657
1714
|
method: 'tools/call',
|
|
1658
|
-
params: {
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
snapshot: params.snapshot,
|
|
1664
|
-
},
|
|
1665
|
-
},
|
|
1715
|
+
params: withViewProof('ggui_runtime_sync_context', {
|
|
1716
|
+
sessionId: params.sessionId,
|
|
1717
|
+
appId: params.appId,
|
|
1718
|
+
snapshot: params.snapshot,
|
|
1719
|
+
}),
|
|
1666
1720
|
});
|
|
1667
1721
|
},
|
|
1668
1722
|
};
|
|
@@ -1681,10 +1735,10 @@ const productionContextSnapshotPoster = {
|
|
|
1681
1735
|
* `CallToolResult` in `{result: ...}`.
|
|
1682
1736
|
*
|
|
1683
1737
|
* Drops with an error envelope when no App is bound — the dispatch
|
|
1684
|
-
* pipeline classifies that as a
|
|
1685
|
-
* `ui/message`
|
|
1738
|
+
* pipeline classifies that as a non-success outcome and drops the
|
|
1739
|
+
* gesture with local feedback (a toast); no `ui/message` fires.
|
|
1686
1740
|
*/
|
|
1687
|
-
async function callServerToolSpec(toolName, args) {
|
|
1741
|
+
async function callServerToolSpec(toolName, args, proof) {
|
|
1688
1742
|
const app = getCurrentApp();
|
|
1689
1743
|
if (app === null) {
|
|
1690
1744
|
return {
|
|
@@ -1694,10 +1748,7 @@ async function callServerToolSpec(toolName, args) {
|
|
|
1694
1748
|
};
|
|
1695
1749
|
}
|
|
1696
1750
|
try {
|
|
1697
|
-
const result = await app.callServerTool(
|
|
1698
|
-
name: toolName,
|
|
1699
|
-
arguments: args,
|
|
1700
|
-
});
|
|
1751
|
+
const result = await app.callServerTool(withViewProof(toolName, args, proof));
|
|
1701
1752
|
return { jsonrpc: '2.0', result: result };
|
|
1702
1753
|
}
|
|
1703
1754
|
catch (err) {
|
|
@@ -1784,17 +1835,14 @@ export function emitAudit(args) {
|
|
|
1784
1835
|
jsonrpc: '2.0',
|
|
1785
1836
|
id: Math.floor(Math.random() * 1e9),
|
|
1786
1837
|
method: 'tools/call',
|
|
1787
|
-
params: {
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
firedAt: args.firedAt,
|
|
1796
|
-
},
|
|
1797
|
-
},
|
|
1838
|
+
params: withViewProof(args.toolName, {
|
|
1839
|
+
kind: args.kind,
|
|
1840
|
+
payload: args.payload,
|
|
1841
|
+
sessionId: args.sessionId,
|
|
1842
|
+
appId: args.appId,
|
|
1843
|
+
actionId: args.actionId,
|
|
1844
|
+
firedAt: args.firedAt,
|
|
1845
|
+
}),
|
|
1798
1846
|
});
|
|
1799
1847
|
}
|
|
1800
1848
|
/**
|
|
@@ -2097,6 +2145,191 @@ function hideToast(el) {
|
|
|
2097
2145
|
}
|
|
2098
2146
|
/** The single action-toast element, reused for every message. */
|
|
2099
2147
|
const ACTION_TOAST_ID = '__ggui-action-toast__';
|
|
2148
|
+
/**
|
|
2149
|
+
* The drawn toast for a dispatched action: a fixed-position element at
|
|
2150
|
+
* the bottom of the iframe, drawn only when the visitor has to know or do
|
|
2151
|
+
* something (the copy is {@link GESTURE_COPY}). Two kinds reach it:
|
|
2152
|
+
* - `'error'` — the gesture did not go through: the relay refused it or
|
|
2153
|
+
* could not be reached, or the card's own contract refused it. Nothing
|
|
2154
|
+
* reached the pipe, so no `ui/message` doorbell fires (SPEC §4.7
|
|
2155
|
+
* "Failure-soft client posture");
|
|
2156
|
+
* - `'action_required'` — the gesture is queued but no consumer is
|
|
2157
|
+
* listening and this host cannot take (or refused) the wake-up, so the
|
|
2158
|
+
* visitor must send a message to continue.
|
|
2159
|
+
* A tap that went through, or a wake-up the host took, is spoken, never
|
|
2160
|
+
* drawn.
|
|
2161
|
+
*
|
|
2162
|
+
* Without it, a failed gesture would be silent: the visitor could not tell
|
|
2163
|
+
* "the agent is busy" from "the tap was lost".
|
|
2164
|
+
*
|
|
2165
|
+
* Direct DOM (not React-managed) because:
|
|
2166
|
+
* - Works during boot before React mounts.
|
|
2167
|
+
* - Doesn't interfere with the component tree the generator owns.
|
|
2168
|
+
* - Survives React mount transitions inside the iframe.
|
|
2169
|
+
*
|
|
2170
|
+
* Single global toast (per iframe). Auto-dismisses after 2.5s on
|
|
2171
|
+
* `success` / `fallback` outcomes. A successful dispatch draws nothing
|
|
2172
|
+
* here (ggui#1444): its "sending" is spoken only (see
|
|
2173
|
+
* {@link GESTURE_COPY}).
|
|
2174
|
+
*
|
|
2175
|
+
* The visible element is only half the surface (ggui#447). Every
|
|
2176
|
+
* message is also spoken through {@link ensureToastAnnouncer}'s live
|
|
2177
|
+
* regions, and the element itself is hidden from the accessibility
|
|
2178
|
+
* tree so the sentence is not read twice. The one exception is
|
|
2179
|
+
* `action_required`, the only toast the user must OPERATE: it re-enters
|
|
2180
|
+
* the tree as a named button with a tab stop, because a dismissal that
|
|
2181
|
+
* needs a mouse is a dismissal some users cannot perform.
|
|
2182
|
+
*
|
|
2183
|
+
* @internal — runtime-layer concern.
|
|
2184
|
+
*/
|
|
2185
|
+
/**
|
|
2186
|
+
* The `label` an action declares on the render's `actionSpec`, trimmed, or
|
|
2187
|
+
* `undefined` when the render declares none (or is a system / MCP-Apps
|
|
2188
|
+
* render, which carries no actionSpec). By contract the label is the
|
|
2189
|
+
* control's own copy, so it is the one name for a tap a visitor may read
|
|
2190
|
+
* (see {@link GESTURE_COPY}).
|
|
2191
|
+
*
|
|
2192
|
+
* @internal — exported for unit tests.
|
|
2193
|
+
*/
|
|
2194
|
+
export function declaredActionLabel(render, actionName) {
|
|
2195
|
+
if (render === null || render.type === 'mcpApps' || render.type === 'system')
|
|
2196
|
+
return undefined;
|
|
2197
|
+
const declared = render.actionSpec?.[actionName]?.label;
|
|
2198
|
+
return typeof declared === 'string' && declared.trim() !== '' ? declared.trim() : undefined;
|
|
2199
|
+
}
|
|
2200
|
+
/**
|
|
2201
|
+
* Everything the runtime says to a VISITOR about a gesture (ggui#1444).
|
|
2202
|
+
*
|
|
2203
|
+
* A card's action NAME and the data it sends are the author's and the
|
|
2204
|
+
* agent's vocabulary, never the visitor's: a staging rehearsal drew
|
|
2205
|
+
* "→ chooseReply (id: pricing)" over a card after a chip tap. So no text
|
|
2206
|
+
* here names the action or carries its data. When the action declares a
|
|
2207
|
+
* `label` (by contract the control's own copy, already on screen) the text
|
|
2208
|
+
* names the tap by it, so two failed taps on different controls read as two
|
|
2209
|
+
* different sentences to a screen reader (ggui#447); without one it says
|
|
2210
|
+
* the same thing unnamed. Diagnostics keep the name and data (the
|
|
2211
|
+
* `gesture.*` telemetry records); the screen and the live regions do not.
|
|
2212
|
+
*
|
|
2213
|
+
* `sending` and `sentToChat` are spoken, never drawn: a tap the relay
|
|
2214
|
+
* accepted, or a wake-up the host took, needs no chrome over the card — the
|
|
2215
|
+
* pending look belongs to the card's own control (`useActionPending`), and a
|
|
2216
|
+
* host that takes a wake-up shows the conversation continuing in its own UI.
|
|
2217
|
+
* The rest are drawn because each one tells the visitor something they have
|
|
2218
|
+
* to know or do.
|
|
2219
|
+
*/
|
|
2220
|
+
const named = (label, rest, unnamed) => label === undefined ? unnamed : `${label} — ${rest}`;
|
|
2221
|
+
const GESTURE_COPY = {
|
|
2222
|
+
sending: (label) => named(label, 'sending…', 'Sending…'),
|
|
2223
|
+
notDelivered: (label) => `⚠ ${named(label, 'not delivered', 'Not delivered')}`,
|
|
2224
|
+
transportError: (label) => `⚠ ${named(label, 'could not send, try again', 'Could not send — try again')}`,
|
|
2225
|
+
unreachable: (label) => `⚠ ${named(label, 'could not reach the agent', 'Could not reach the agent')}`,
|
|
2226
|
+
sentToChat: (label) => named(label, 'sent to the chat', 'Sent to the chat'),
|
|
2227
|
+
sendToContinue: (label) => `💬 ${named(label, 'agent not listening. Send a message to continue.', 'Agent not listening. Send a message to continue.')}`,
|
|
2228
|
+
chatRefused: (label) => `💬 ${named(label, 'the chat did not take the message. Send a message to continue.', 'The chat did not take the message. Send a message to continue.')}`,
|
|
2229
|
+
// ggui#1536 — the card's own action contract refused what the tap would
|
|
2230
|
+
// send, so nothing left the card. The visitor cannot fix that, but a tap
|
|
2231
|
+
// that visibly failed is better than a dead one.
|
|
2232
|
+
refused: (label) => `⚠ ${named(label, 'could not be sent', 'Could not be sent')}`,
|
|
2233
|
+
// ggui#1424 — the server refused the gesture because this one-shot action
|
|
2234
|
+
// already fired on this card: the honest state is "done", not an error.
|
|
2235
|
+
alreadyDone: (label) => `✓ ${named(label, 'already done', 'Already done')}`,
|
|
2236
|
+
};
|
|
2237
|
+
/**
|
|
2238
|
+
* ggui#1424 — the `oneShot` names the server refused as already spent, per
|
|
2239
|
+
* render, so the wire guard (`refusedSpentOneShots`) reads them beside the
|
|
2240
|
+
* card's persisted record and the control reads done from now on. Keyed by
|
|
2241
|
+
* render id because a `ggui_update` mints a new card whose actions are
|
|
2242
|
+
* re-armed, and that card has a new id.
|
|
2243
|
+
*/
|
|
2244
|
+
const refusedSpentOneShotsByRender = new Map();
|
|
2245
|
+
/** The mounted document's "spent inputs changed" notifier, set at mount; `null` before one. */
|
|
2246
|
+
let notifySpentInputsChanged = null;
|
|
2247
|
+
/** @internal — exported for unit tests. */
|
|
2248
|
+
export function isActionSpentByRefusal(renderId, actionName) {
|
|
2249
|
+
return refusedSpentOneShotsByRender.get(renderId)?.has(actionName) === true;
|
|
2250
|
+
}
|
|
2251
|
+
function markSpentByRefusal(renderId, actionName) {
|
|
2252
|
+
let set = refusedSpentOneShotsByRender.get(renderId);
|
|
2253
|
+
if (set === undefined) {
|
|
2254
|
+
set = new Set();
|
|
2255
|
+
refusedSpentOneShotsByRender.set(renderId, set);
|
|
2256
|
+
}
|
|
2257
|
+
set.add(actionName);
|
|
2258
|
+
notifySpentInputsChanged?.();
|
|
2259
|
+
}
|
|
2260
|
+
/** The mounted document's label resolver for an action name, set at mount; `null` before one. */
|
|
2261
|
+
let labelForAction = null;
|
|
2262
|
+
/**
|
|
2263
|
+
* ggui#1424 — the one answer to a server refusal of a spent one-shot, from
|
|
2264
|
+
* either door (the tool answer or the channel's error frame): the guard
|
|
2265
|
+
* reads the action spent from now on, the host is told on the
|
|
2266
|
+
* `action-refused` channel with the field (never the payload), and the
|
|
2267
|
+
* visitor sees the action as already done — never "could not reach the
|
|
2268
|
+
* agent". Nothing is on the pipe, so no doorbell.
|
|
2269
|
+
*/
|
|
2270
|
+
function settleOneShotRefusal(renderId, actionName, label) {
|
|
2271
|
+
markSpentByRefusal(renderId, actionName);
|
|
2272
|
+
postObservabilityToParent({
|
|
2273
|
+
kind: 'action-refused',
|
|
2274
|
+
renderId,
|
|
2275
|
+
actionName,
|
|
2276
|
+
violations: [{ field: `actionSpec.${actionName}.oneShot`, keyword: 'oneShot' }],
|
|
2277
|
+
});
|
|
2278
|
+
showActionToast(GESTURE_COPY.alreadyDone(label), 'success');
|
|
2279
|
+
}
|
|
2280
|
+
/** The action a refusal's violations name at `actionSpec.<name>.oneShot`, or `null`. */
|
|
2281
|
+
function oneShotRefusedAction(violations) {
|
|
2282
|
+
if (!Array.isArray(violations))
|
|
2283
|
+
return null;
|
|
2284
|
+
for (const v of violations) {
|
|
2285
|
+
const field = typeof v === 'object' && v !== null ? v.field : undefined;
|
|
2286
|
+
const m = typeof field === 'string' ? /^actionSpec\.(.+)\.oneShot$/.exec(field) : null;
|
|
2287
|
+
if (m?.[1] !== undefined)
|
|
2288
|
+
return m[1];
|
|
2289
|
+
}
|
|
2290
|
+
return null;
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* ggui#1424 — a post-ack `error` frame from the live channel. The channel
|
|
2294
|
+
* door refuses a second gesture on a spent one-shot with its
|
|
2295
|
+
* CONTRACT_VIOLATION frame and a violation at `actionSpec.<name>.oneShot`;
|
|
2296
|
+
* that one means done, the same as the tool answer. Any other error frame
|
|
2297
|
+
* changes nothing here.
|
|
2298
|
+
*
|
|
2299
|
+
* @internal — exported for unit tests.
|
|
2300
|
+
*/
|
|
2301
|
+
export function handlePostAckErrorFrame(renderId, payload) {
|
|
2302
|
+
if (payload.code !== 'CONTRACT_VIOLATION')
|
|
2303
|
+
return;
|
|
2304
|
+
const details = typeof payload.details === 'object' && payload.details !== null ? payload.details : undefined;
|
|
2305
|
+
const actionName = oneShotRefusedAction(details?.violations);
|
|
2306
|
+
if (actionName === null)
|
|
2307
|
+
return;
|
|
2308
|
+
settleOneShotRefusal(renderId, actionName, labelForAction?.(actionName));
|
|
2309
|
+
}
|
|
2310
|
+
/**
|
|
2311
|
+
* Does a submit_action answer refuse THIS gesture because its one-shot action
|
|
2312
|
+
* already fired on the card (ggui#1424)? The discriminator is the violation's
|
|
2313
|
+
* `field`, `actionSpec.<name>.oneShot` — never the code alone, which a bad
|
|
2314
|
+
* payload shares (`CONTRACT_VIOLATION`, ggui#1358).
|
|
2315
|
+
*/
|
|
2316
|
+
function readsOneShotRefusal(resp, actionName) {
|
|
2317
|
+
if (resp === null)
|
|
2318
|
+
return false;
|
|
2319
|
+
const inner = submitActionPayload(resp);
|
|
2320
|
+
if (inner === null || inner.ok !== false || inner.code !== 'CONTRACT_VIOLATION')
|
|
2321
|
+
return false;
|
|
2322
|
+
return oneShotRefusedAction(inner.violations) === actionName;
|
|
2323
|
+
}
|
|
2324
|
+
/**
|
|
2325
|
+
* The toast for a tap the outbound contract check refused (ggui#1536): the
|
|
2326
|
+
* ordinary error toast, named by the action's declared label.
|
|
2327
|
+
*
|
|
2328
|
+
* @internal — exported for unit tests + production reuse.
|
|
2329
|
+
*/
|
|
2330
|
+
export function showRefusedDispatchToast(label) {
|
|
2331
|
+
showActionToast(GESTURE_COPY.refused(label), 'error');
|
|
2332
|
+
}
|
|
2100
2333
|
function showActionToast(text, kind, onDismiss) {
|
|
2101
2334
|
if (typeof document === 'undefined')
|
|
2102
2335
|
return;
|
|
@@ -2338,9 +2571,14 @@ function emitUserActionDoorbell(args) {
|
|
|
2338
2571
|
// `ai.ggui/userAction` to match our other protocol extensions
|
|
2339
2572
|
// (`ai.ggui/render`, `ai.ggui/bootstrap`, etc.).
|
|
2340
2573
|
currentTelemetrySink?.record('doorbell.ring', args.sessionId);
|
|
2574
|
+
const doorbellId = Math.floor(Math.random() * 1e9);
|
|
2575
|
+
watchDoorbellReply(doorbellId, (code) => {
|
|
2576
|
+
currentTelemetrySink?.record('doorbell.refused', `${args.sessionId} code=${code}`);
|
|
2577
|
+
args.onRefused();
|
|
2578
|
+
});
|
|
2341
2579
|
postToParent({
|
|
2342
2580
|
jsonrpc: '2.0',
|
|
2343
|
-
id:
|
|
2581
|
+
id: doorbellId,
|
|
2344
2582
|
method: 'ui/message',
|
|
2345
2583
|
params: {
|
|
2346
2584
|
role: 'user',
|
|
@@ -2356,6 +2594,54 @@ function emitUserActionDoorbell(args) {
|
|
|
2356
2594
|
},
|
|
2357
2595
|
});
|
|
2358
2596
|
}
|
|
2597
|
+
/**
|
|
2598
|
+
* How long the view listens for the host's answer to a doorbell. A host
|
|
2599
|
+
* may ask the user before sending the message, so the answer can take a
|
|
2600
|
+
* while; past this the listener is removed and a late answer is ignored.
|
|
2601
|
+
*/
|
|
2602
|
+
const DOORBELL_REPLY_WAIT_MS = 5 * 60_000;
|
|
2603
|
+
/**
|
|
2604
|
+
* Listen for the host's JSON-RPC reply to the doorbell `id` (ggui#1314).
|
|
2605
|
+
* The doorbell is posted raw, outside the `App`'s request table, so no
|
|
2606
|
+
* one else correlates it. Only a frame from `window.parent` carrying this
|
|
2607
|
+
* `id` and a `result` or `error` counts; the first such frame ends the
|
|
2608
|
+
* watch. An `error` is the host refusing the doorbell in-band (#1309:
|
|
2609
|
+
* a host delivers a view's `ui/message` or answers it with an error,
|
|
2610
|
+
* never a success), reported with its numeric code (`unknown` when the
|
|
2611
|
+
* host sent none).
|
|
2612
|
+
*/
|
|
2613
|
+
function watchDoorbellReply(id, onRefused) {
|
|
2614
|
+
if (typeof window === 'undefined')
|
|
2615
|
+
return;
|
|
2616
|
+
const onMessage = (ev) => {
|
|
2617
|
+
if (ev.source !== window.parent)
|
|
2618
|
+
return;
|
|
2619
|
+
const frame = ev.data;
|
|
2620
|
+
if (frame === null || typeof frame !== 'object')
|
|
2621
|
+
return;
|
|
2622
|
+
if (!('jsonrpc' in frame) || frame.jsonrpc !== '2.0')
|
|
2623
|
+
return;
|
|
2624
|
+
if (!('id' in frame) || frame.id !== id)
|
|
2625
|
+
return;
|
|
2626
|
+
const refused = 'error' in frame;
|
|
2627
|
+
if (!refused && !('result' in frame))
|
|
2628
|
+
return;
|
|
2629
|
+
stop();
|
|
2630
|
+
if (!refused)
|
|
2631
|
+
return;
|
|
2632
|
+
const error = frame.error;
|
|
2633
|
+
const code = error !== null && typeof error === 'object' && 'code' in error && typeof error.code === 'number'
|
|
2634
|
+
? String(error.code)
|
|
2635
|
+
: 'unknown';
|
|
2636
|
+
onRefused(code);
|
|
2637
|
+
};
|
|
2638
|
+
const stop = () => {
|
|
2639
|
+
window.removeEventListener('message', onMessage);
|
|
2640
|
+
clearTimeout(timer);
|
|
2641
|
+
};
|
|
2642
|
+
window.addEventListener('message', onMessage);
|
|
2643
|
+
const timer = setTimeout(stop, DOORBELL_REPLY_WAIT_MS);
|
|
2644
|
+
}
|
|
2359
2645
|
/**
|
|
2360
2646
|
* Latch for the relay-incapability notice (ggui#440). A host that
|
|
2361
2647
|
* cannot relay fails EVERY gesture, so the explanation is stated once
|
|
@@ -2604,6 +2890,14 @@ function resetRelayCueThrottles() {
|
|
|
2604
2890
|
cancelRelayCueRetraction();
|
|
2605
2891
|
}
|
|
2606
2892
|
/** @internal — exported for unit tests to reset module state. */
|
|
2893
|
+
/**
|
|
2894
|
+
* Test-only: install the telemetry sink the runtime records into (normally
|
|
2895
|
+
* set once at boot), so a test can observe the events a code path posts.
|
|
2896
|
+
* `null` restores the no-sink state.
|
|
2897
|
+
*/
|
|
2898
|
+
export function __setTelemetrySinkForTest(sink) {
|
|
2899
|
+
currentTelemetrySink = sink;
|
|
2900
|
+
}
|
|
2607
2901
|
export function __resetRelayNoticeForTest() {
|
|
2608
2902
|
relayIncapabilityAnnounced = false;
|
|
2609
2903
|
runtimeConnectionWriter().set(true);
|
|
@@ -2687,7 +2981,7 @@ function resolveRelayCueTarget() {
|
|
|
2687
2981
|
* the rest of the session is the same stale-announcement defect the
|
|
2688
2982
|
* toast half avoids by clearing on hide.
|
|
2689
2983
|
*/
|
|
2690
|
-
function announceRelayCue(intent) {
|
|
2984
|
+
function announceRelayCue(intent, label) {
|
|
2691
2985
|
const now = Date.now();
|
|
2692
2986
|
if (intent === lastRelayCueAnnouncedIntent &&
|
|
2693
2987
|
now - lastRelayCueAnnouncedAt < RELAY_CUE_THROTTLE_MS) {
|
|
@@ -2697,7 +2991,7 @@ function announceRelayCue(intent) {
|
|
|
2697
2991
|
lastRelayCueAnnouncedIntent = intent;
|
|
2698
2992
|
// `announceToast` cancels any retraction still pending, so the timer
|
|
2699
2993
|
// armed here is always the only one in flight.
|
|
2700
|
-
announceToast(
|
|
2994
|
+
announceToast(GESTURE_COPY.notDelivered(label), 'error');
|
|
2701
2995
|
relayCueAnnounceTimer = window.setTimeout(() => {
|
|
2702
2996
|
relayCueAnnounceTimer = undefined;
|
|
2703
2997
|
retractRelayCueAnnouncement();
|
|
@@ -2738,7 +3032,7 @@ function retractRelayCueAnnouncement() {
|
|
|
2738
3032
|
* (ggui#447) — the pulse via {@link announceRelayCue}, the fallback via
|
|
2739
3033
|
* the toast primitive's own announcement.
|
|
2740
3034
|
*/
|
|
2741
|
-
function showDeadZoneCue(intent) {
|
|
3035
|
+
function showDeadZoneCue(intent, label) {
|
|
2742
3036
|
if (typeof document === 'undefined')
|
|
2743
3037
|
return;
|
|
2744
3038
|
const target = resolveRelayCueTarget();
|
|
@@ -2751,7 +3045,7 @@ function showDeadZoneCue(intent) {
|
|
|
2751
3045
|
return;
|
|
2752
3046
|
ensureRelayCueStyle();
|
|
2753
3047
|
target.classList.add(RELAY_CUE_CLASS);
|
|
2754
|
-
announceRelayCue(intent);
|
|
3048
|
+
announceRelayCue(intent, label);
|
|
2755
3049
|
// Both cleanup routes cancel the other. Without the `clearTimeout`,
|
|
2756
3050
|
// a pulse ended early by `animationend` leaves its timer armed, and
|
|
2757
3051
|
// that timer later strips whatever class is on the element THEN —
|
|
@@ -2780,7 +3074,7 @@ function showDeadZoneCue(intent) {
|
|
|
2780
3074
|
// a second explanation, so it must clear itself. Throttled because a
|
|
2781
3075
|
// per-click toast on a host that fails every click is the #426
|
|
2782
3076
|
// failure mode the latch exists to prevent.
|
|
2783
|
-
showActionToast(
|
|
3077
|
+
showActionToast(GESTURE_COPY.notDelivered(label), 'error');
|
|
2784
3078
|
}
|
|
2785
3079
|
/**
|
|
2786
3080
|
* Dispatch a submit-action via the empirically-validated bridge
|
|
@@ -2802,20 +3096,21 @@ export function dispatchSubmitAction(args) {
|
|
|
2802
3096
|
// the live session — drop the gesture rather than submit a stale
|
|
2803
3097
|
// view's action against the current head.
|
|
2804
3098
|
if (mountSuperseded) {
|
|
2805
|
-
currentTelemetrySink?.record('gesture.dropped_superseded'
|
|
3099
|
+
currentTelemetrySink?.record('gesture.dropped_superseded');
|
|
2806
3100
|
return;
|
|
2807
3101
|
}
|
|
2808
|
-
const { toolName, intent, data, sessionId, appId } = args;
|
|
3102
|
+
const { toolName, intent, data, sessionId, appId, label } = args;
|
|
3103
|
+
// Read at entry, inside the gesture's own task (ggui#1415): the dispatch
|
|
3104
|
+
// signs it as the proof's user-activation flag, measured, never enforced.
|
|
3105
|
+
const userActivation = userActivationIsActive();
|
|
2809
3106
|
// Gesture-path telemetry — the click's own autopsy trail. #471
|
|
2810
3107
|
// round 12 hit a frame whose channels + beacons were fully healthy
|
|
2811
3108
|
// while clicks produced NOTHING observable; without a record at the
|
|
2812
3109
|
// dispatch entry there is no way to tell "handler never fired" from
|
|
2813
3110
|
// "dispatch swallowed it" on a console-less host.
|
|
2814
|
-
currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({
|
|
3111
|
+
currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({ toolName }));
|
|
2815
3112
|
const firedAt = new Date().toISOString();
|
|
2816
3113
|
const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
|
|
2817
|
-
const inlineData = formatSubmitActionDataInline(data);
|
|
2818
|
-
const dataPart = inlineData === '' ? '' : ` (${inlineData})`;
|
|
2819
3114
|
const uiContext = readLocalUiContext();
|
|
2820
3115
|
// (1) Silent context update — fires FIRST and ALWAYS. Primes the
|
|
2821
3116
|
// LLM's widget-context surface regardless of which downstream path
|
|
@@ -2857,10 +3152,13 @@ export function dispatchSubmitAction(args) {
|
|
|
2857
3152
|
// the user has focused NOW; by the time the relay response settles,
|
|
2858
3153
|
// focus may have moved on.
|
|
2859
3154
|
if (!relayIncapabilityAnnounced) {
|
|
2860
|
-
|
|
3155
|
+
// No pill over the card (ggui#1444): the previous gesture's notice goes,
|
|
3156
|
+
// and "sending" is spoken for assistive tech only.
|
|
3157
|
+
dismissActionToast();
|
|
3158
|
+
announceToast(GESTURE_COPY.sending(label), 'pending');
|
|
2861
3159
|
}
|
|
2862
3160
|
else if (!isRelayNoticeVisible()) {
|
|
2863
|
-
showDeadZoneCue(intent);
|
|
3161
|
+
showDeadZoneCue(intent, label);
|
|
2864
3162
|
}
|
|
2865
3163
|
// (2) Try submit_action via host relay. Spec-compliant hosts
|
|
2866
3164
|
// forward the tools/call to the MCP server's submit_action handler;
|
|
@@ -2894,20 +3192,19 @@ export function dispatchSubmitAction(args) {
|
|
|
2894
3192
|
appId,
|
|
2895
3193
|
actionId,
|
|
2896
3194
|
firedAt,
|
|
2897
|
-
});
|
|
3195
|
+
}, { userActivation });
|
|
2898
3196
|
}
|
|
2899
3197
|
catch {
|
|
2900
|
-
showActionToast(
|
|
3198
|
+
showActionToast(GESTURE_COPY.transportError(label), 'error');
|
|
2901
3199
|
resp = null;
|
|
2902
3200
|
}
|
|
2903
3201
|
// Final hop of the gesture autopsy trail — what the relay
|
|
2904
3202
|
// answered (or that it didn't).
|
|
3203
|
+
// No free text (#1383): the relay's error CODE rides, never its
|
|
3204
|
+
// message (a host's words), and the intent string stays home.
|
|
2905
3205
|
currentTelemetrySink?.record('gesture.result', JSON.stringify({
|
|
2906
|
-
intent,
|
|
2907
3206
|
ok: resp !== null && resp.error === undefined,
|
|
2908
|
-
...(resp?.error?.
|
|
2909
|
-
? { error: resp.error.message.slice(0, 120) }
|
|
2910
|
-
: {}),
|
|
3207
|
+
...(typeof resp?.error?.code === 'number' ? { code: resp.error.code } : {}),
|
|
2911
3208
|
}));
|
|
2912
3209
|
// Self-healing (ggui#440): a result envelope arriving at all is
|
|
2913
3210
|
// proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
|
|
@@ -2940,7 +3237,7 @@ export function dispatchSubmitAction(args) {
|
|
|
2940
3237
|
// through to the terminal "could not reach the agent" toast a
|
|
2941
3238
|
// few lines down, which already replaces the stale notice.
|
|
2942
3239
|
if (classifySubmitActionResponse(resp) === 'success') {
|
|
2943
|
-
|
|
3240
|
+
dismissActionToast();
|
|
2944
3241
|
}
|
|
2945
3242
|
}
|
|
2946
3243
|
if (resp !== null && classifySubmitActionResponse(resp) === 'success') {
|
|
@@ -2964,14 +3261,29 @@ export function dispatchSubmitAction(args) {
|
|
|
2964
3261
|
// `ggui_consume`; servers that CAN answer always send an
|
|
2965
3262
|
// explicit `true` (the factory wires the registry
|
|
2966
3263
|
// unconditionally), so confirmed-consumer hosts stay quiet.
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
3264
|
+
// A host that takes the wake-up carries it in its own UI (the
|
|
3265
|
+
// conversation visibly continues, or a prepared message lands in
|
|
3266
|
+
// its input), so nothing is drawn over the card (ggui#1444): the tap
|
|
3267
|
+
// is spoken to assistive tech only. The card draws only when the
|
|
3268
|
+
// visitor must act — this host cannot receive a message at all, or
|
|
3269
|
+
// it refuses this one (`onRefused` below).
|
|
3270
|
+
if (hostCanReceiveMessages()) {
|
|
3271
|
+
announceToast(GESTURE_COPY.sentToChat(label), 'pending');
|
|
3272
|
+
}
|
|
3273
|
+
else {
|
|
3274
|
+
showActionToast(GESTURE_COPY.sendToContinue(label), 'action_required');
|
|
3275
|
+
}
|
|
2970
3276
|
emitUserActionDoorbell({
|
|
2971
3277
|
intent,
|
|
2972
3278
|
sessionId,
|
|
2973
3279
|
actionId,
|
|
2974
3280
|
submittedAt: firedAt,
|
|
3281
|
+
// The host refused the wake-up in-band (ggui#1314), so the
|
|
3282
|
+
// visitor has to act: say what. The gesture itself is safe on
|
|
3283
|
+
// the pipe; only the wake-up failed.
|
|
3284
|
+
onRefused: () => {
|
|
3285
|
+
showActionToast(GESTURE_COPY.chatRefused(label), 'action_required');
|
|
3286
|
+
},
|
|
2975
3287
|
});
|
|
2976
3288
|
return;
|
|
2977
3289
|
}
|
|
@@ -2980,6 +3292,17 @@ export function dispatchSubmitAction(args) {
|
|
|
2980
3292
|
// listener dismisses it when ggui_consume drains the event.
|
|
2981
3293
|
return;
|
|
2982
3294
|
}
|
|
3295
|
+
// ggui#1424 — the server refused the gesture because this one-shot
|
|
3296
|
+
// action already fired on the card (a tap that got past the guard: a
|
|
3297
|
+
// re-served card before its spent record arrived, a forced click). The
|
|
3298
|
+
// honest state is DONE: the guard reads the action spent from now on,
|
|
3299
|
+
// the host is told on the `action-refused` channel with the field, and
|
|
3300
|
+
// the visitor sees the action as already done — never "could not reach
|
|
3301
|
+
// the agent". Nothing is on the pipe, so no doorbell.
|
|
3302
|
+
if (readsOneShotRefusal(resp, intent)) {
|
|
3303
|
+
settleOneShotRefusal(sessionId, intent, label);
|
|
3304
|
+
return;
|
|
3305
|
+
}
|
|
2983
3306
|
// Enqueue failed (pipe gone / transport error / host has no relay).
|
|
2984
3307
|
// The gesture is not on any pipe, so a doorbell would point at an
|
|
2985
3308
|
// empty queue; no `ui/message` is emitted either way.
|
|
@@ -3053,7 +3376,7 @@ export function dispatchSubmitAction(args) {
|
|
|
3053
3376
|
announceRelayIncapability(confirmedRefusal ? 'confirmed-refusal' : 'advert-silent', { sessionId, appId });
|
|
3054
3377
|
return;
|
|
3055
3378
|
}
|
|
3056
|
-
showActionToast(
|
|
3379
|
+
showActionToast(GESTURE_COPY.unreachable(label), 'error');
|
|
3057
3380
|
})();
|
|
3058
3381
|
}
|
|
3059
3382
|
/**
|
|
@@ -3170,13 +3493,14 @@ export function resolveDispatchToolName() {
|
|
|
3170
3493
|
* @internal — exported for unit tests + production reuse.
|
|
3171
3494
|
*/
|
|
3172
3495
|
export function routeDispatch(args) {
|
|
3173
|
-
const { actionName, data, meta, dispatchToolName } = args;
|
|
3496
|
+
const { actionName, data, meta, dispatchToolName, label } = args;
|
|
3174
3497
|
dispatchSubmitAction({
|
|
3175
3498
|
toolName: dispatchToolName,
|
|
3176
3499
|
intent: actionName,
|
|
3177
3500
|
data,
|
|
3178
3501
|
sessionId: meta.sessionId,
|
|
3179
3502
|
appId: meta.appId,
|
|
3503
|
+
...(label !== undefined ? { label } : {}),
|
|
3180
3504
|
});
|
|
3181
3505
|
}
|
|
3182
3506
|
/**
|
|
@@ -3432,15 +3756,22 @@ export function __resetInterceptorsForTest() {
|
|
|
3432
3756
|
* its event system. The App handle is passed by the boot path
|
|
3433
3757
|
* directly (pre-connect, so `getCurrentApp()` is not yet set).
|
|
3434
3758
|
*/
|
|
3435
|
-
function installPersistentToolResultListener(app) {
|
|
3759
|
+
function installPersistentToolResultListener(app, viewRoots) {
|
|
3436
3760
|
if (persistentToolResultListenerApps.has(app))
|
|
3437
3761
|
return;
|
|
3438
3762
|
persistentToolResultListenerApps.add(app);
|
|
3439
3763
|
let lastMetaKey = null;
|
|
3440
3764
|
app.addEventListener('toolresult', (params) => {
|
|
3441
|
-
|
|
3442
|
-
|
|
3765
|
+
// A re-mount re-applies the static seed only; the one live channel is
|
|
3766
|
+
// the boot's, so a credential held here has nothing to refresh.
|
|
3767
|
+
const resolved = extractMetaFromToolResult(params);
|
|
3768
|
+
if (resolved === null)
|
|
3443
3769
|
return;
|
|
3770
|
+
const meta = resolved.meta;
|
|
3771
|
+
// A later slice's view key root (ggui#1415) is offered whether or not it
|
|
3772
|
+
// re-mounts anything: the holder keeps it only for its own session and
|
|
3773
|
+
// only when it is newer, and ignores a slice that carries none.
|
|
3774
|
+
viewRoots.offer(meta.sessionId, resolved.viewRoot);
|
|
3444
3775
|
// Cheap dedupe — the host may emit the same tool-result more
|
|
3445
3776
|
// than once (claude.ai re-broadcasts on iframe re-attach).
|
|
3446
3777
|
// Re-mounting the same slice meta would flicker without changing
|
|
@@ -3552,7 +3883,7 @@ function readLiveBootstrapShape() {
|
|
|
3552
3883
|
* fallback share one call site — keeps the WS-driven boot semantics
|
|
3553
3884
|
* single-sourced.
|
|
3554
3885
|
*
|
|
3555
|
-
* `
|
|
3886
|
+
* `preResolved` is threaded in from the autostart resolver when
|
|
3556
3887
|
* it has already discovered slice meta — via inline `__GGUI_META__`
|
|
3557
3888
|
* global, a buffered `__GGUI_PENDING_TOOL_RESULTS__` entry, or an
|
|
3558
3889
|
* early `ui/notifications/tool-result` postMessage. Threading skips
|
|
@@ -3560,7 +3891,7 @@ function readLiveBootstrapShape() {
|
|
|
3560
3891
|
* re-await the same postMessage or re-parse the same global), saving
|
|
3561
3892
|
* up to the 30s postMessage timeout for spec-strict hosts.
|
|
3562
3893
|
*/
|
|
3563
|
-
function runBootProduction(
|
|
3894
|
+
function runBootProduction(preResolved, preResolvedLocator) {
|
|
3564
3895
|
const { app, transport } = createDefaultApp();
|
|
3565
3896
|
void bootProduction({
|
|
3566
3897
|
doc: document,
|
|
@@ -3575,7 +3906,7 @@ function runBootProduction(preResolvedMeta, preResolvedLocator) {
|
|
|
3575
3906
|
},
|
|
3576
3907
|
onObserve: postObservabilityToParent,
|
|
3577
3908
|
onLifecycle: postLifecycleToParent,
|
|
3578
|
-
...(
|
|
3909
|
+
...(preResolved !== undefined ? { preResolved } : {}),
|
|
3579
3910
|
...(preResolvedLocator !== undefined ? { preResolvedLocator } : {}),
|
|
3580
3911
|
});
|
|
3581
3912
|
}
|
|
@@ -3595,7 +3926,7 @@ if (shouldAutostart() && typeof window !== 'undefined') {
|
|
|
3595
3926
|
// 3. Live `ui/notifications/tool-result` postMessage — the
|
|
3596
3927
|
// spec-canonical channel for spec-strict hosts (`<AppRenderer>`,
|
|
3597
3928
|
// ChatGPT, claude.ai). Caught meta threads through as
|
|
3598
|
-
// `
|
|
3929
|
+
// `preResolved` so `bootSequence` doesn't re-await it.
|
|
3599
3930
|
//
|
|
3600
3931
|
// `readLiveBootstrapShape` still short-circuits the 30s tool-result
|
|
3601
3932
|
// wait when a live-channel envelope is already inlined — OSS embedded
|
|
@@ -3624,7 +3955,7 @@ if (shouldAutostart() && typeof window !== 'undefined') {
|
|
|
3624
3955
|
if (pre === null)
|
|
3625
3956
|
runBootProduction();
|
|
3626
3957
|
else if (pre.kind === 'meta')
|
|
3627
|
-
runBootProduction(pre.
|
|
3958
|
+
runBootProduction(pre.boot);
|
|
3628
3959
|
else
|
|
3629
3960
|
runBootProduction(undefined, pre.locator);
|
|
3630
3961
|
});
|
|
@@ -3803,7 +4134,30 @@ async function bootProduction(opts) {
|
|
|
3803
4134
|
renderListeners.delete(listener);
|
|
3804
4135
|
};
|
|
3805
4136
|
};
|
|
4137
|
+
// ggui#1424 — a server refusal marks an action spent; the guard re-reads
|
|
4138
|
+
// through the same listeners a replaced render wakes.
|
|
4139
|
+
notifySpentInputsChanged = () => {
|
|
4140
|
+
for (const listener of renderListeners)
|
|
4141
|
+
listener();
|
|
4142
|
+
};
|
|
4143
|
+
labelForAction = (actionName) => declaredActionLabel(currentRender, actionName);
|
|
3806
4144
|
let renderHandle = null;
|
|
4145
|
+
// ggui#1679 — the card's render failure reaches the server once per
|
|
4146
|
+
// session, in the tool's terms, over the same proof-carrying bridge
|
|
4147
|
+
// every runtime tool call rides. The App handle is read when the
|
|
4148
|
+
// failure happens, not now: a boot with no bridge has nowhere to
|
|
4149
|
+
// report, and the reporter then spends its one report on nothing,
|
|
4150
|
+
// which is the "not reported" outcome SPEC §4.9 allows.
|
|
4151
|
+
const renderFailureReporter = createRenderFailureReporter({
|
|
4152
|
+
sessionId: meta.sessionId,
|
|
4153
|
+
appId: meta.appId,
|
|
4154
|
+
callTool: (name, args) => {
|
|
4155
|
+
const bridge = getCurrentApp();
|
|
4156
|
+
if (bridge === null)
|
|
4157
|
+
return Promise.reject(new Error('no App bridge bound: the report has no relay'));
|
|
4158
|
+
return bridgeCallToolVia(bridge)(name, args);
|
|
4159
|
+
},
|
|
4160
|
+
});
|
|
3807
4161
|
const dispatchToolName = resolveDispatchToolName();
|
|
3808
4162
|
// Native-idiom interceptors — the replacement for the retired
|
|
3809
4163
|
// openLink / requestDisplayMode wire primitives. Installed here
|
|
@@ -3850,7 +4204,15 @@ async function bootProduction(opts) {
|
|
|
3850
4204
|
sessionId: meta.sessionId,
|
|
3851
4205
|
appId: meta.appId,
|
|
3852
4206
|
getCurrentGguiSession: () => currentRender,
|
|
4207
|
+
// ggui#1536 — the adapter posted `action-refused` to the host; the
|
|
4208
|
+
// visitor sees the tap failed, named by its declared label.
|
|
4209
|
+
onDispatchRefused: (actionName) => showRefusedDispatchToast(declaredActionLabel(currentRender, actionName)),
|
|
3853
4210
|
renderChanges: onRenderChange,
|
|
4211
|
+
// ggui#1424 — the names the server refused as already spent on this card.
|
|
4212
|
+
refusedSpentOneShots: () => {
|
|
4213
|
+
const set = currentRender === null ? undefined : refusedSpentOneShotsByRender.get(currentRender.id);
|
|
4214
|
+
return set === undefined ? undefined : [...set];
|
|
4215
|
+
},
|
|
3854
4216
|
manager,
|
|
3855
4217
|
streamBus,
|
|
3856
4218
|
onDispatchEnvelope: (envelope) => {
|
|
@@ -3866,6 +4228,8 @@ async function bootProduction(opts) {
|
|
|
3866
4228
|
|| payload.action.length === 0) {
|
|
3867
4229
|
return;
|
|
3868
4230
|
}
|
|
4231
|
+
// The declared label names the tap in visitor copy (ggui#1444).
|
|
4232
|
+
const label = declaredActionLabel(currentRender, payload.action);
|
|
3869
4233
|
routeDispatch({
|
|
3870
4234
|
actionName: payload.action,
|
|
3871
4235
|
data: payload.data,
|
|
@@ -3874,6 +4238,7 @@ async function bootProduction(opts) {
|
|
|
3874
4238
|
appId: meta.appId,
|
|
3875
4239
|
},
|
|
3876
4240
|
dispatchToolName,
|
|
4241
|
+
...(label !== undefined ? { label } : {}),
|
|
3877
4242
|
});
|
|
3878
4243
|
},
|
|
3879
4244
|
});
|
|
@@ -3976,6 +4341,8 @@ async function bootProduction(opts) {
|
|
|
3976
4341
|
return hostPalette !== undefined ? { hostPalette } : {};
|
|
3977
4342
|
})(),
|
|
3978
4343
|
...(wrapOuter !== undefined ? { wrapOuter } : {}),
|
|
4344
|
+
// The boundary's give-up is reported to the server (ggui#1679).
|
|
4345
|
+
onRenderFailure: renderFailureReporter.report,
|
|
3979
4346
|
};
|
|
3980
4347
|
};
|
|
3981
4348
|
/**
|
|
@@ -4090,38 +4457,28 @@ async function bootProduction(opts) {
|
|
|
4090
4457
|
}
|
|
4091
4458
|
: {}),
|
|
4092
4459
|
});
|
|
4093
|
-
// B3b —
|
|
4094
|
-
//
|
|
4095
|
-
//
|
|
4096
|
-
//
|
|
4097
|
-
// handshake handlers and binds the
|
|
4098
|
-
// arrive directly through
|
|
4099
|
-
// intermediate `onMessage` fan-out.
|
|
4460
|
+
// B3b — the handler for every routable frame type, each closing
|
|
4461
|
+
// over the renderer state it needs. The runtime's ladder set
|
|
4462
|
+
// (ggui#1496) registers these same handler objects on every ladder
|
|
4463
|
+
// it binds, one registry per credential; `connectViaRegistry` adds
|
|
4464
|
+
// the `ack` + `error` handshake handlers and binds the transport,
|
|
4465
|
+
// and frames arrive directly through them.
|
|
4100
4466
|
//
|
|
4101
4467
|
// CLIENT_SUPPORTED_VERSIONS handshake is enforced inside
|
|
4102
4468
|
// `connectViaRegistry`'s ack-handler closure (NOT here).
|
|
4103
|
-
const
|
|
4104
|
-
|
|
4105
|
-
type: 'subscribe',
|
|
4106
|
-
payload: {
|
|
4107
|
-
sessionId: meta.sessionId,
|
|
4108
|
-
appId: meta.appId,
|
|
4109
|
-
...(meta.wsToken !== undefined
|
|
4110
|
-
? { wsToken: meta.wsToken }
|
|
4111
|
-
: {}),
|
|
4112
|
-
},
|
|
4113
|
-
}),
|
|
4114
|
-
});
|
|
4115
|
-
channelRegistry.register(createRenderHandler({
|
|
4469
|
+
const channelHandlers = [];
|
|
4470
|
+
channelHandlers.push(createRenderHandler({
|
|
4116
4471
|
statusRefs,
|
|
4117
4472
|
pinnedSessionId: meta.sessionId,
|
|
4118
4473
|
applyRender,
|
|
4119
4474
|
getChannelTransport: () => channelTransport,
|
|
4120
4475
|
}));
|
|
4121
|
-
|
|
4476
|
+
const streamSeq = createStreamSeqTracker();
|
|
4477
|
+
channelHandlers.push(createDataHandler({
|
|
4122
4478
|
getCurrentGguiSession: () => currentRender,
|
|
4123
4479
|
streamBus,
|
|
4124
4480
|
validatorCtx,
|
|
4481
|
+
streamSeq,
|
|
4125
4482
|
}));
|
|
4126
4483
|
// Freeze latch (#483). This mount's own history epoch, read once
|
|
4127
4484
|
// from its boot meta: render → 0, an update-minted card → its N,
|
|
@@ -4139,18 +4496,18 @@ async function bootProduction(opts) {
|
|
|
4139
4496
|
currentTelemetrySink?.record('epoch.frozen', String(selfEpoch));
|
|
4140
4497
|
applyFreezeCue(renderInto);
|
|
4141
4498
|
};
|
|
4142
|
-
|
|
4499
|
+
channelHandlers.push(createPropsUpdateHandler({
|
|
4143
4500
|
getCurrentGguiSession: () => currentRender,
|
|
4144
4501
|
applyRender,
|
|
4145
4502
|
getSelfEpoch: () => selfEpoch,
|
|
4146
4503
|
onSuperseded: supersede,
|
|
4147
4504
|
isSuperseded: () => superseded,
|
|
4148
4505
|
}));
|
|
4149
|
-
|
|
4150
|
-
|
|
4506
|
+
channelHandlers.push(createDrainAckHandler({ dispatch: dispatchDrainAck }));
|
|
4507
|
+
channelHandlers.push(createChannelPayloadHandler({
|
|
4151
4508
|
getChannelTransport: () => channelTransport,
|
|
4152
4509
|
}));
|
|
4153
|
-
|
|
4510
|
+
channelHandlers.push(createChannelErrorHandler({
|
|
4154
4511
|
getChannelTransport: () => channelTransport,
|
|
4155
4512
|
}));
|
|
4156
4513
|
return {
|
|
@@ -4162,7 +4519,8 @@ async function bootProduction(opts) {
|
|
|
4162
4519
|
validatorCtx,
|
|
4163
4520
|
manager,
|
|
4164
4521
|
channelTransport,
|
|
4165
|
-
|
|
4522
|
+
channelHandlers,
|
|
4523
|
+
streamSeq,
|
|
4166
4524
|
composedGadgets,
|
|
4167
4525
|
};
|
|
4168
4526
|
},
|
|
@@ -4193,9 +4551,7 @@ async function bootProduction(opts) {
|
|
|
4193
4551
|
renderer,
|
|
4194
4552
|
...(opts.onObserve !== undefined ? { onObserve: opts.onObserve } : {}),
|
|
4195
4553
|
...(opts.onLifecycle !== undefined ? { onLifecycle: opts.onLifecycle } : {}),
|
|
4196
|
-
...(opts.
|
|
4197
|
-
? { preResolvedMeta: opts.preResolvedMeta }
|
|
4198
|
-
: {}),
|
|
4554
|
+
...(opts.preResolved !== undefined ? { preResolved: opts.preResolved } : {}),
|
|
4199
4555
|
...(opts.preResolvedLocator !== undefined
|
|
4200
4556
|
? { preResolvedLocator: opts.preResolvedLocator }
|
|
4201
4557
|
: {}),
|