@ggui-ai/iframe-runtime 0.25.0 → 0.27.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 -94
- 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 +22 -1
- package/dist/react-renderer.d.ts +20 -0
- package/dist/react-renderer.js +22 -0
- package/dist/registry-subscribe.d.ts +37 -1
- package/dist/registry-subscribe.js +34 -8
- package/dist/render-failure-report.d.ts +15 -0
- package/dist/render-failure-report.js +61 -0
- package/dist/render-item.d.ts +3 -0
- package/dist/render-item.js +2 -0
- package/dist/runtime-telemetry.d.ts +28 -18
- package/dist/runtime-telemetry.js +68 -29
- package/dist/runtime.d.ts +114 -35
- package/dist/runtime.js +556 -309
- 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 +13 -0
- package/dist/wire-config.js +39 -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
|
-
import {
|
|
46
|
-
import {
|
|
48
|
+
import { connectViaRegistry, } from './registry-subscribe.js';
|
|
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';
|
|
@@ -414,10 +418,10 @@ export async function bootSequence(opts) {
|
|
|
414
418
|
// App handshake settles — whichever yields first wins; the listener
|
|
415
419
|
// is removed on resolve.
|
|
416
420
|
//
|
|
417
|
-
// Skipped entirely when `
|
|
421
|
+
// Skipped entirely when `preResolved` is set (autostart caught
|
|
418
422
|
// the toolresult or read the global early) — no listener installed,
|
|
419
423
|
// no race to run.
|
|
420
|
-
const toolResultPromise = opts.
|
|
424
|
+
const toolResultPromise = opts.preResolved !== undefined
|
|
421
425
|
? Promise.resolve(null)
|
|
422
426
|
: awaitToolResultMetaFromApp(app, toolResultTimeoutMs);
|
|
423
427
|
// Persistent re-mount listener — ALSO pre-connect (ggui#586). On a
|
|
@@ -427,7 +431,8 @@ export async function bootSequence(opts) {
|
|
|
427
431
|
// notice and leaning on SDK replay for correctness. Registering here
|
|
428
432
|
// covers every boot path; arrivals before a renderer publishes
|
|
429
433
|
// `applyRender` no-op inside the listener.
|
|
430
|
-
|
|
434
|
+
const viewRoots = opts.viewRoots ?? documentViewRoots;
|
|
435
|
+
installPersistentToolResultListener(app, viewRoots);
|
|
431
436
|
// Fresh boot ⇒ the relay latch and the connection store start
|
|
432
437
|
// aligned (ggui#670): a standing latch from a prior mount in a
|
|
433
438
|
// re-booted document must not outlive the store it was written to.
|
|
@@ -474,7 +479,7 @@ export async function bootSequence(opts) {
|
|
|
474
479
|
// Reading-B (retired Phase 1.19b.3 with the App-class swap; the
|
|
475
480
|
// McpUiInitializeResult schema doesn't define `toolOutput`).
|
|
476
481
|
//
|
|
477
|
-
// Tier 0
|
|
482
|
+
// Tier 0 preResolved — autostart-layer pre-resolution. When
|
|
478
483
|
// the autostart's own toolresult race or inline `__GGUI_META__`
|
|
479
484
|
// parse caught the meta before bootSequence ran, it threads
|
|
480
485
|
// the parsed slice meta here so we don't re-await the same
|
|
@@ -498,14 +503,18 @@ export async function bootSequence(opts) {
|
|
|
498
503
|
// When both tiers fail, we surface the Tier 2 timeout/failure reason
|
|
499
504
|
// (`MISSING_META_GGUI_BOOTSTRAP`) — it's the spec-canonical channel
|
|
500
505
|
// and its absence is the diagnostic worth showing.
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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;
|
|
504
513
|
}
|
|
505
514
|
else {
|
|
506
|
-
const inline =
|
|
507
|
-
if (inline
|
|
508
|
-
|
|
515
|
+
const inline = readSelfContainedMeta();
|
|
516
|
+
if (inline !== null) {
|
|
517
|
+
resolved = inline;
|
|
509
518
|
}
|
|
510
519
|
else {
|
|
511
520
|
// Tier 1.5 — the read-plane door (ggui#537). A pre-boot tool
|
|
@@ -517,18 +526,17 @@ export async function bootSequence(opts) {
|
|
|
517
526
|
const viaDoor = opts.preResolvedLocator !== undefined
|
|
518
527
|
? await resolveMetaViaReadDoor(app, opts.preResolvedLocator)
|
|
519
528
|
: null;
|
|
520
|
-
|
|
521
|
-
if (fromToolResult !== null) {
|
|
522
|
-
parsed = { ok: true, meta: fromToolResult };
|
|
523
|
-
}
|
|
524
|
-
else {
|
|
525
|
-
parsed = {
|
|
526
|
-
ok: false,
|
|
527
|
-
reason: 'MISSING_META_GGUI_BOOTSTRAP',
|
|
528
|
-
};
|
|
529
|
-
}
|
|
529
|
+
resolved = viaDoor ?? (await toolResultPromise);
|
|
530
530
|
}
|
|
531
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;
|
|
532
540
|
// hostContext is captured opportunistically from
|
|
533
541
|
// `app.getHostContext()` — populated by App's `ui/initialize`
|
|
534
542
|
// response capture + kept fresh by the `hostcontextchanged`
|
|
@@ -561,6 +569,9 @@ export async function bootSequence(opts) {
|
|
|
561
569
|
// `ai.ggui/render` slice. The parser surfaces it directly on
|
|
562
570
|
// `parsed.meta`.
|
|
563
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);
|
|
564
575
|
// Install the precompiled, eval-free contract validators shipped on
|
|
565
576
|
// the bootstrap BEFORE any wire traffic is validated. Self-contained
|
|
566
577
|
// ESM modules — no `__ggui__` dependency, so this need not wait for
|
|
@@ -574,6 +585,22 @@ export async function bootSequence(opts) {
|
|
|
574
585
|
// is the pin; the render handler drops any frame addressed
|
|
575
586
|
// elsewhere.
|
|
576
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;
|
|
577
604
|
// Renderer wiring — when supplied, the handler routes frames through
|
|
578
605
|
// the single-render mount surface + WireConfig + StreamBus. When
|
|
579
606
|
// absent, the placeholder path runs (boot.test.ts relies on the
|
|
@@ -616,20 +643,13 @@ export async function bootSequence(opts) {
|
|
|
616
643
|
});
|
|
617
644
|
}
|
|
618
645
|
setStatus(refs, `Connecting to ${meta.sessionId}…`, 'connecting');
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
? createPlaceholderRegistry({
|
|
627
|
-
meta,
|
|
628
|
-
statusRefs: refs,
|
|
629
|
-
pinnedSessionId,
|
|
630
|
-
})
|
|
631
|
-
: null;
|
|
632
|
-
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 });
|
|
633
653
|
/**
|
|
634
654
|
* Apply an ack's render snapshot to the runtime — when the ack
|
|
635
655
|
* carries a render matching `pinnedSessionId`, mount it through the
|
|
@@ -721,10 +741,9 @@ export async function bootSequence(opts) {
|
|
|
721
741
|
// A bootstrap with NEITHER has nothing to show and nowhere to
|
|
722
742
|
// subscribe.
|
|
723
743
|
const hasStaticContent = hasStaticContentMeta(meta);
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
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);
|
|
728
747
|
// Transport-telemetry sink — the iframe's self-report channel (see
|
|
729
748
|
// runtime-telemetry.ts). Created for EVERY boot with an App bridge;
|
|
730
749
|
// the first batch carries the boot-path decision so operators can
|
|
@@ -743,9 +762,14 @@ export async function bootSequence(opts) {
|
|
|
743
762
|
mountSuperseded = false;
|
|
744
763
|
telemetry?.record('boot.path', JSON.stringify({
|
|
745
764
|
hasStaticContent,
|
|
746
|
-
hasLiveTrio,
|
|
765
|
+
hasLiveTrio: hasLiveTrio(meta),
|
|
766
|
+
heldCredential: held !== undefined,
|
|
747
767
|
bridgeCapable: telemetryApp !== null,
|
|
748
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;
|
|
749
773
|
// ── Static seed mount — zero-round-trip paint, no WS required. ──────
|
|
750
774
|
// The ONLY mount path for spec-compliant MCP-Apps hosts that expose no
|
|
751
775
|
// ggui live channel (claude.ai / ChatGPT / Claude Desktop), AND the
|
|
@@ -767,12 +791,13 @@ export async function bootSequence(opts) {
|
|
|
767
791
|
}
|
|
768
792
|
catch (err) {
|
|
769
793
|
const message = err instanceof Error ? err.message : String(err);
|
|
770
|
-
if (!
|
|
794
|
+
if (!liveTrioPending) {
|
|
771
795
|
// No WS fallback — a failed static fetch is terminal.
|
|
772
796
|
setStatus(refs, `static mount failed: ${message}`, 'error');
|
|
773
797
|
emitBootFailure('UI_INITIALIZE_FAILED', message);
|
|
774
798
|
return { ok: false, mountedRender };
|
|
775
799
|
}
|
|
800
|
+
staticMountFailure = message;
|
|
776
801
|
// A live trio is present — the WS ack will deliver the render.
|
|
777
802
|
// The seed was best-effort; fall through to subscribe. Warn so a
|
|
778
803
|
// degraded boot (slower first paint, ack-only delivery) is visible
|
|
@@ -800,8 +825,33 @@ export async function bootSequence(opts) {
|
|
|
800
825
|
// ladder at its tail). Gating the terminal rung behind the WS
|
|
801
826
|
// trio's presence disabled it on precisely the host class it was
|
|
802
827
|
// built for.
|
|
803
|
-
|
|
804
|
-
|
|
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
|
+
}
|
|
805
855
|
const message = 'bootstrap carries neither static content (codeUrl/codeB64/kind) nor a live trio (wsUrl/wsToken)';
|
|
806
856
|
setStatus(refs, message, 'error');
|
|
807
857
|
retireShellLoadingIndicator(doc);
|
|
@@ -837,11 +887,11 @@ export async function bootSequence(opts) {
|
|
|
837
887
|
// `/api/sessions/<id>/stream` URL; SSE `id:` = ledger sequence = the
|
|
838
888
|
// SAME cursor model as the WS `sinceSequence` replay and polling
|
|
839
889
|
// ticks — switching rungs does not lose events.
|
|
840
|
-
const sseUrl = typeof
|
|
841
|
-
?
|
|
890
|
+
const sseUrl = typeof liveMeta.sseUrl === 'string' && liveMeta.sseUrl.length > 0
|
|
891
|
+
? liveMeta.sseUrl
|
|
842
892
|
: undefined;
|
|
843
|
-
const pollingBaseUrl = typeof
|
|
844
|
-
?
|
|
893
|
+
const pollingBaseUrl = typeof liveMeta.pollingUrl === 'string' && liveMeta.pollingUrl.length > 0
|
|
894
|
+
? liveMeta.pollingUrl
|
|
845
895
|
: undefined;
|
|
846
896
|
// One shared cursor for the whole ladder — SSE deliveries advance it
|
|
847
897
|
// (via onSequence), the polling + bridge descriptors read it per
|
|
@@ -855,87 +905,111 @@ export async function bootSequence(opts) {
|
|
|
855
905
|
const ladderCursor = sseUrl !== undefined || pollingBaseUrl !== undefined || bridgeApp !== null
|
|
856
906
|
? createSequenceCursor(meta.lastSequence ?? 0)
|
|
857
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;
|
|
858
1007
|
let handle;
|
|
859
1008
|
try {
|
|
860
|
-
handle = await
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
// transports emit (failover swaps, polling budget exhaustion,
|
|
865
|
-
// SSE lifecycle) lands in the telemetry buffer. Without this the
|
|
866
|
-
// ladder's story on sandboxed hosts is unobservable.
|
|
867
|
-
...(telemetry !== null ? { logger: telemetry.channelLogger } : {}),
|
|
868
|
-
onStatusChange: (status) => {
|
|
869
|
-
telemetry?.record(`status.${status}`);
|
|
870
|
-
setStatus(refs, status === 'connected' ? 'Connected.' : `Connection ${status}…`, status);
|
|
871
|
-
// Propagate WS status to the per-channel transport router so
|
|
872
|
-
// it can flip WS-bound channels into polling
|
|
873
|
-
// fallback (on disconnect) and re-send `channel_subscribe`
|
|
874
|
-
// on reconnect. No-op when renderer wiring is absent.
|
|
875
|
-
if (renderer !== null) {
|
|
876
|
-
renderer.channelTransport.onWsStatusChange(status);
|
|
877
|
-
}
|
|
878
|
-
},
|
|
879
|
-
// Forward typed errors from subscribe through the runtime's
|
|
880
|
-
// own emitter — connectViaRegistry classifies transport + auth
|
|
881
|
-
// + version + protocol errors on its own, we just plumb them
|
|
882
|
-
// along.
|
|
883
|
-
...(onProtocolError !== undefined ? { onProtocolError } : {}),
|
|
884
|
-
// Forward observability emissions — connectFn owns the
|
|
885
|
-
// schema-version-mismatch + subscribe-failed kinds.
|
|
886
|
-
...(onObserve !== undefined ? { onObserve } : {}),
|
|
887
|
-
// Reconnect-with-rebootstrap — on every ack received AFTER the
|
|
888
|
-
// initial handshake settled, reapply the server's authoritative
|
|
889
|
-
// `render` snapshot. A render or update that landed during a WS
|
|
890
|
-
// dropout window restores here without an agent re-prompt.
|
|
891
|
-
onResubscribeAck: (ack) => {
|
|
892
|
-
void applyAck(ack);
|
|
893
|
-
},
|
|
894
|
-
// Failover-ladder fallback rungs (WS → SSE → polling →
|
|
895
|
-
// bridge-pull), composed once at bind time from the
|
|
896
|
-
// server-stamped wsToken-gated URLs + `meta.lastSequence`
|
|
897
|
-
// (shared cursor seed). FailoverHandle descends a rung on each
|
|
898
|
-
// 'failed'; the bridge rung is terminal (never emits 'failed').
|
|
899
|
-
//
|
|
900
|
-
// Same cursor model as the WS subscribe `sinceSequence` replay
|
|
901
|
-
// path — switching transports does not lose events.
|
|
902
|
-
...(sseUrl !== undefined && ladderCursor !== undefined
|
|
903
|
-
? {
|
|
904
|
-
sse: {
|
|
905
|
-
url: sseUrl,
|
|
906
|
-
initialSinceSequence: ladderCursor.get(),
|
|
907
|
-
onSequence: (seq) => {
|
|
908
|
-
ladderCursor.advance(seq);
|
|
909
|
-
},
|
|
910
|
-
},
|
|
911
|
-
}
|
|
912
|
-
: {}),
|
|
913
|
-
...(pollingBaseUrl !== undefined && ladderCursor !== undefined
|
|
914
|
-
? {
|
|
915
|
-
polling: buildEventsPolling({
|
|
916
|
-
baseUrl: pollingBaseUrl,
|
|
917
|
-
cursor: ladderCursor,
|
|
918
|
-
}),
|
|
919
|
-
}
|
|
920
|
-
: {}),
|
|
921
|
-
// Bridge-pull terminal rung — composed whenever the App handle
|
|
922
|
-
// is bound (it always is by this point in the boot: connectApp
|
|
923
|
-
// succeeded and setCurrentApp ran above), INDEPENDENT of
|
|
924
|
-
// sseUrl/pollingUrl presence. This is the universal floor for
|
|
925
|
-
// CSP-jailed hosts (claude.ai) where the iframe has no network
|
|
926
|
-
// path at all: the ledger is pulled via `ggui_runtime_pull`
|
|
927
|
-
// `tools/call`s over the host's postMessage bridge. Shares the
|
|
928
|
-
// SAME ladder cursor — a demotion into the bridge resumes from
|
|
929
|
-
// whatever the rungs above already delivered.
|
|
930
|
-
...(bridgeApp !== null && ladderCursor !== undefined
|
|
931
|
-
? {
|
|
932
|
-
bridge: buildBridgePolling({
|
|
933
|
-
callTool: (name, args) => bridgeApp.callServerTool({ name, arguments: args }),
|
|
934
|
-
sessionId: meta.sessionId,
|
|
935
|
-
cursor: ladderCursor,
|
|
936
|
-
}),
|
|
937
|
-
}
|
|
938
|
-
: {}),
|
|
1009
|
+
handle = await ladders.connectBoot({
|
|
1010
|
+
credential: bootCredential,
|
|
1011
|
+
sseUrl,
|
|
1012
|
+
pollingUrl: pollingBaseUrl,
|
|
939
1013
|
});
|
|
940
1014
|
}
|
|
941
1015
|
catch (err) {
|
|
@@ -954,24 +1028,21 @@ export async function bootSequence(opts) {
|
|
|
954
1028
|
emitBootFailure('UPGRADE_REQUIRED', message);
|
|
955
1029
|
return { ok: false, mountedRender };
|
|
956
1030
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
rendererHooks?.teardown?.(renderer);
|
|
970
|
-
setStatus(refs, `WS handshake failed: ${message}`, 'error');
|
|
971
|
-
emitBootFailure('WS_HANDSHAKE_FAILED', message);
|
|
972
|
-
return { ok: false, mountedRender };
|
|
1031
|
+
return endOnSubscribeFailure(err instanceof Error ? err.message : String(err));
|
|
1032
|
+
}
|
|
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);
|
|
973
1043
|
}
|
|
974
|
-
|
|
1044
|
+
// Health (#1383): booleans only; the handle's kind string stays home.
|
|
1045
|
+
telemetry?.record('subscribe.resolved', JSON.stringify({ hasAck: handle.ack !== undefined }));
|
|
975
1046
|
// Attach the live transport handle to the renderer — flushes any
|
|
976
1047
|
// buffered outbound `action` frames that were queued while the
|
|
977
1048
|
// subscribe handshake completed. `send` narrows to the ws surface:
|
|
@@ -979,8 +1050,8 @@ export async function bootSequence(opts) {
|
|
|
979
1050
|
// ride app.callServerTool, not the transport), so its sends drop —
|
|
980
1051
|
// the same posture a post-swap FailoverHandle already has.
|
|
981
1052
|
const transportSend = (msg) => {
|
|
982
|
-
|
|
983
|
-
|
|
1053
|
+
// The active ladder's WS (ggui#1496): after a rebind, the new one's.
|
|
1054
|
+
ladders.send(msg);
|
|
984
1055
|
};
|
|
985
1056
|
if (renderer !== null && rendererHooks?.attachManager !== undefined) {
|
|
986
1057
|
rendererHooks.attachManager(renderer, { send: transportSend });
|
|
@@ -1013,6 +1084,14 @@ export async function bootSequence(opts) {
|
|
|
1013
1084
|
if (handle.ack !== undefined) {
|
|
1014
1085
|
await applyAck(handle.ack);
|
|
1015
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
|
+
}
|
|
1016
1095
|
setConnectedStatus(refs);
|
|
1017
1096
|
// Lifecycle `code-ready` — terminal happy state. Emitted ONCE per boot
|
|
1018
1097
|
// (a static+live meta already fired it from the seed mount, so this is
|
|
@@ -1023,33 +1102,20 @@ export async function bootSequence(opts) {
|
|
|
1023
1102
|
return { ok: true, mountedRender };
|
|
1024
1103
|
}
|
|
1025
1104
|
/**
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
* 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.
|
|
1033
1111
|
*/
|
|
1034
|
-
function
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
sessionId: params.meta.sessionId,
|
|
1040
|
-
appId: params.meta.appId,
|
|
1041
|
-
...(params.meta.wsToken !== undefined
|
|
1042
|
-
? { wsToken: params.meta.wsToken }
|
|
1043
|
-
: {}),
|
|
1044
|
-
},
|
|
1112
|
+
function createPlaceholderHandlers(params) {
|
|
1113
|
+
return [
|
|
1114
|
+
createRenderHandler({
|
|
1115
|
+
statusRefs: params.statusRefs,
|
|
1116
|
+
pinnedSessionId: params.pinnedSessionId,
|
|
1045
1117
|
}),
|
|
1046
|
-
|
|
1047
|
-
});
|
|
1048
|
-
registry.register(createRenderHandler({
|
|
1049
|
-
statusRefs: params.statusRefs,
|
|
1050
|
-
pinnedSessionId: params.pinnedSessionId,
|
|
1051
|
-
}));
|
|
1052
|
-
return registry;
|
|
1118
|
+
];
|
|
1053
1119
|
}
|
|
1054
1120
|
/**
|
|
1055
1121
|
* Frame dispatch lives inside `@ggui-ai/live-channel`'s
|
|
@@ -1082,52 +1148,58 @@ function shouldAutostart() {
|
|
|
1082
1148
|
return true;
|
|
1083
1149
|
return process.env?.GGUI_RENDERER_AUTOSTART !== 'false';
|
|
1084
1150
|
}
|
|
1085
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
1117
1192
|
/**
|
|
1118
1193
|
* Read `globalThis.__GGUI_META__` synchronously, validate against
|
|
1119
|
-
* the unified slice-meta shape, and return the
|
|
1194
|
+
* the unified slice-meta shape, and return the boot slice (or null
|
|
1120
1195
|
* on absence / malformation).
|
|
1121
1196
|
*
|
|
1122
|
-
* Thin wrapper around {@link parseMetaFromGlobal}
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
* reader contract — the autostart resolver only needs the "valid
|
|
1126
|
-
* 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.
|
|
1127
1200
|
*/
|
|
1128
1201
|
export function readSelfContainedMeta() {
|
|
1129
|
-
|
|
1130
|
-
return result.ok ? result.meta : null;
|
|
1202
|
+
return bootMetaOf(parseMetaFromGlobal());
|
|
1131
1203
|
}
|
|
1132
1204
|
/**
|
|
1133
1205
|
* Parse the bootstrap meta's `propsJson` string into a {@link JsonObject}.
|
|
@@ -1250,6 +1322,43 @@ export function hasStaticContentMeta(meta) {
|
|
|
1250
1322
|
(typeof meta.codeB64 === 'string' && meta.codeB64.length > 0) ||
|
|
1251
1323
|
(typeof meta.kind === 'string' && meta.kind.length > 0));
|
|
1252
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
|
+
}
|
|
1253
1362
|
/**
|
|
1254
1363
|
* Decode a `codeB64` slice field to UTF-8 component source. `atob`
|
|
1255
1364
|
* yields a byte string; routing through `TextDecoder` restores
|
|
@@ -1285,8 +1394,7 @@ function decodeCodeB64(codeB64) {
|
|
|
1285
1394
|
* impossible because the shell never inspects the slice-meta shape.
|
|
1286
1395
|
*/
|
|
1287
1396
|
export function extractMetaFromToolResult(params) {
|
|
1288
|
-
|
|
1289
|
-
return result.ok ? result.meta : null;
|
|
1397
|
+
return bootMetaOf(parseMetaFromToolResult(params));
|
|
1290
1398
|
}
|
|
1291
1399
|
/**
|
|
1292
1400
|
* Drain `window.__GGUI_PENDING_TOOL_RESULTS__` — the buffer the
|
|
@@ -1313,9 +1421,9 @@ export function readPendingToolResults() {
|
|
|
1313
1421
|
if (!Array.isArray(raw) || raw.length === 0)
|
|
1314
1422
|
return null;
|
|
1315
1423
|
for (let i = raw.length - 1; i >= 0; i--) {
|
|
1316
|
-
const
|
|
1317
|
-
if (
|
|
1318
|
-
return
|
|
1424
|
+
const boot = extractMetaFromToolResult(raw[i]);
|
|
1425
|
+
if (boot !== null)
|
|
1426
|
+
return boot;
|
|
1319
1427
|
}
|
|
1320
1428
|
return null;
|
|
1321
1429
|
}
|
|
@@ -1365,9 +1473,9 @@ function awaitToolResultMetaFromApp(app, timeoutMs) {
|
|
|
1365
1473
|
const handler = (params) => {
|
|
1366
1474
|
if (settled)
|
|
1367
1475
|
return;
|
|
1368
|
-
const
|
|
1369
|
-
if (
|
|
1370
|
-
settle(
|
|
1476
|
+
const boot = extractMetaFromToolResult(params);
|
|
1477
|
+
if (boot !== null) {
|
|
1478
|
+
settle(boot);
|
|
1371
1479
|
return;
|
|
1372
1480
|
}
|
|
1373
1481
|
// Read-plane door (ggui#537): identity-only result → resolve the
|
|
@@ -1420,8 +1528,7 @@ export async function resolveMetaViaReadDoor(app, locator) {
|
|
|
1420
1528
|
const parsed = parseMcpAppAiGguiRenderMeta(envelope, READ_DOOR_REPORTERS);
|
|
1421
1529
|
if (!parsed.ok || parsed.meta === undefined)
|
|
1422
1530
|
return null;
|
|
1423
|
-
|
|
1424
|
-
return validated.ok ? validated.meta : null;
|
|
1531
|
+
return bootMetaOf(validateMeta(parsed.meta));
|
|
1425
1532
|
}
|
|
1426
1533
|
function awaitToolResultMeta(timeoutMs) {
|
|
1427
1534
|
if (typeof window === 'undefined')
|
|
@@ -1444,9 +1551,9 @@ function awaitToolResultMeta(timeoutMs) {
|
|
|
1444
1551
|
m.method !== 'ui/notifications/tool-result') {
|
|
1445
1552
|
return;
|
|
1446
1553
|
}
|
|
1447
|
-
const
|
|
1448
|
-
if (
|
|
1449
|
-
settle({ kind: 'meta',
|
|
1554
|
+
const boot = extractMetaFromToolResult(m.params);
|
|
1555
|
+
if (boot !== null) {
|
|
1556
|
+
settle({ kind: 'meta', boot });
|
|
1450
1557
|
return;
|
|
1451
1558
|
}
|
|
1452
1559
|
// Identity-only result (ggui#537): settle NOW with the locator so
|
|
@@ -1595,7 +1702,8 @@ function callAppRequestDisplayMode(params) {
|
|
|
1595
1702
|
* migrated to `app.callServerTool` — see emitAudit for the same
|
|
1596
1703
|
* posture).
|
|
1597
1704
|
*/
|
|
1598
|
-
|
|
1705
|
+
/** @internal — exported for unit tests. */
|
|
1706
|
+
export const productionContextSnapshotPoster = {
|
|
1599
1707
|
postUpdateModelContext: (params) => {
|
|
1600
1708
|
callAppUpdateModelContext(params);
|
|
1601
1709
|
},
|
|
@@ -1604,14 +1712,11 @@ const productionContextSnapshotPoster = {
|
|
|
1604
1712
|
jsonrpc: '2.0',
|
|
1605
1713
|
id: Math.floor(Math.random() * 1e9),
|
|
1606
1714
|
method: 'tools/call',
|
|
1607
|
-
params: {
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
snapshot: params.snapshot,
|
|
1613
|
-
},
|
|
1614
|
-
},
|
|
1715
|
+
params: withViewProof('ggui_runtime_sync_context', {
|
|
1716
|
+
sessionId: params.sessionId,
|
|
1717
|
+
appId: params.appId,
|
|
1718
|
+
snapshot: params.snapshot,
|
|
1719
|
+
}),
|
|
1615
1720
|
});
|
|
1616
1721
|
},
|
|
1617
1722
|
};
|
|
@@ -1630,10 +1735,10 @@ const productionContextSnapshotPoster = {
|
|
|
1630
1735
|
* `CallToolResult` in `{result: ...}`.
|
|
1631
1736
|
*
|
|
1632
1737
|
* Drops with an error envelope when no App is bound — the dispatch
|
|
1633
|
-
* pipeline classifies that as a
|
|
1634
|
-
* `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.
|
|
1635
1740
|
*/
|
|
1636
|
-
async function callServerToolSpec(toolName, args) {
|
|
1741
|
+
async function callServerToolSpec(toolName, args, proof) {
|
|
1637
1742
|
const app = getCurrentApp();
|
|
1638
1743
|
if (app === null) {
|
|
1639
1744
|
return {
|
|
@@ -1643,10 +1748,7 @@ async function callServerToolSpec(toolName, args) {
|
|
|
1643
1748
|
};
|
|
1644
1749
|
}
|
|
1645
1750
|
try {
|
|
1646
|
-
const result = await app.callServerTool(
|
|
1647
|
-
name: toolName,
|
|
1648
|
-
arguments: args,
|
|
1649
|
-
});
|
|
1751
|
+
const result = await app.callServerTool(withViewProof(toolName, args, proof));
|
|
1650
1752
|
return { jsonrpc: '2.0', result: result };
|
|
1651
1753
|
}
|
|
1652
1754
|
catch (err) {
|
|
@@ -1733,17 +1835,14 @@ export function emitAudit(args) {
|
|
|
1733
1835
|
jsonrpc: '2.0',
|
|
1734
1836
|
id: Math.floor(Math.random() * 1e9),
|
|
1735
1837
|
method: 'tools/call',
|
|
1736
|
-
params: {
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
firedAt: args.firedAt,
|
|
1745
|
-
},
|
|
1746
|
-
},
|
|
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
|
+
}),
|
|
1747
1846
|
});
|
|
1748
1847
|
}
|
|
1749
1848
|
/**
|
|
@@ -2047,17 +2146,21 @@ function hideToast(el) {
|
|
|
2047
2146
|
/** The single action-toast element, reused for every message. */
|
|
2048
2147
|
const ACTION_TOAST_ID = '__ggui-action-toast__';
|
|
2049
2148
|
/**
|
|
2050
|
-
*
|
|
2051
|
-
*
|
|
2052
|
-
*
|
|
2053
|
-
* —
|
|
2054
|
-
*
|
|
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.
|
|
2055
2161
|
*
|
|
2056
|
-
* Without
|
|
2057
|
-
*
|
|
2058
|
-
* agent is busy" from "click was lost". Especially load-bearing in
|
|
2059
|
-
* the consume-pipe vs ui/message dual-path era — the toast tells
|
|
2060
|
-
* the user which path the gesture actually took.
|
|
2162
|
+
* Without it, a failed gesture would be silent: the visitor could not tell
|
|
2163
|
+
* "the agent is busy" from "the tap was lost".
|
|
2061
2164
|
*
|
|
2062
2165
|
* Direct DOM (not React-managed) because:
|
|
2063
2166
|
* - Works during boot before React mounts.
|
|
@@ -2123,7 +2226,110 @@ const GESTURE_COPY = {
|
|
|
2123
2226
|
sentToChat: (label) => named(label, 'sent to the chat', 'Sent to the chat'),
|
|
2124
2227
|
sendToContinue: (label) => `💬 ${named(label, 'agent not listening. Send a message to continue.', 'Agent not listening. Send a message to continue.')}`,
|
|
2125
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')}`,
|
|
2126
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
|
+
}
|
|
2127
2333
|
function showActionToast(text, kind, onDismiss) {
|
|
2128
2334
|
if (typeof document === 'undefined')
|
|
2129
2335
|
return;
|
|
@@ -2890,16 +3096,19 @@ export function dispatchSubmitAction(args) {
|
|
|
2890
3096
|
// the live session — drop the gesture rather than submit a stale
|
|
2891
3097
|
// view's action against the current head.
|
|
2892
3098
|
if (mountSuperseded) {
|
|
2893
|
-
currentTelemetrySink?.record('gesture.dropped_superseded'
|
|
3099
|
+
currentTelemetrySink?.record('gesture.dropped_superseded');
|
|
2894
3100
|
return;
|
|
2895
3101
|
}
|
|
2896
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();
|
|
2897
3106
|
// Gesture-path telemetry — the click's own autopsy trail. #471
|
|
2898
3107
|
// round 12 hit a frame whose channels + beacons were fully healthy
|
|
2899
3108
|
// while clicks produced NOTHING observable; without a record at the
|
|
2900
3109
|
// dispatch entry there is no way to tell "handler never fired" from
|
|
2901
3110
|
// "dispatch swallowed it" on a console-less host.
|
|
2902
|
-
currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({
|
|
3111
|
+
currentTelemetrySink?.record('gesture.dispatch', JSON.stringify({ toolName }));
|
|
2903
3112
|
const firedAt = new Date().toISOString();
|
|
2904
3113
|
const actionId = fnv1aHex(`${intent}|${JSON.stringify(data ?? null)}|${firedAt}`);
|
|
2905
3114
|
const uiContext = readLocalUiContext();
|
|
@@ -2983,7 +3192,7 @@ export function dispatchSubmitAction(args) {
|
|
|
2983
3192
|
appId,
|
|
2984
3193
|
actionId,
|
|
2985
3194
|
firedAt,
|
|
2986
|
-
});
|
|
3195
|
+
}, { userActivation });
|
|
2987
3196
|
}
|
|
2988
3197
|
catch {
|
|
2989
3198
|
showActionToast(GESTURE_COPY.transportError(label), 'error');
|
|
@@ -2991,12 +3200,11 @@ export function dispatchSubmitAction(args) {
|
|
|
2991
3200
|
}
|
|
2992
3201
|
// Final hop of the gesture autopsy trail — what the relay
|
|
2993
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.
|
|
2994
3205
|
currentTelemetrySink?.record('gesture.result', JSON.stringify({
|
|
2995
|
-
intent,
|
|
2996
3206
|
ok: resp !== null && resp.error === undefined,
|
|
2997
|
-
...(resp?.error?.
|
|
2998
|
-
? { error: resp.error.message.slice(0, 120) }
|
|
2999
|
-
: {}),
|
|
3207
|
+
...(typeof resp?.error?.code === 'number' ? { code: resp.error.code } : {}),
|
|
3000
3208
|
}));
|
|
3001
3209
|
// Self-healing (ggui#440): a result envelope arriving at all is
|
|
3002
3210
|
// proof this host CAN relay — `{ok:false, code:'PIPE_NOT_FOUND'}`
|
|
@@ -3084,6 +3292,17 @@ export function dispatchSubmitAction(args) {
|
|
|
3084
3292
|
// listener dismisses it when ggui_consume drains the event.
|
|
3085
3293
|
return;
|
|
3086
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
|
+
}
|
|
3087
3306
|
// Enqueue failed (pipe gone / transport error / host has no relay).
|
|
3088
3307
|
// The gesture is not on any pipe, so a doorbell would point at an
|
|
3089
3308
|
// empty queue; no `ui/message` is emitted either way.
|
|
@@ -3537,15 +3756,22 @@ export function __resetInterceptorsForTest() {
|
|
|
3537
3756
|
* its event system. The App handle is passed by the boot path
|
|
3538
3757
|
* directly (pre-connect, so `getCurrentApp()` is not yet set).
|
|
3539
3758
|
*/
|
|
3540
|
-
function installPersistentToolResultListener(app) {
|
|
3759
|
+
function installPersistentToolResultListener(app, viewRoots) {
|
|
3541
3760
|
if (persistentToolResultListenerApps.has(app))
|
|
3542
3761
|
return;
|
|
3543
3762
|
persistentToolResultListenerApps.add(app);
|
|
3544
3763
|
let lastMetaKey = null;
|
|
3545
3764
|
app.addEventListener('toolresult', (params) => {
|
|
3546
|
-
|
|
3547
|
-
|
|
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)
|
|
3548
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);
|
|
3549
3775
|
// Cheap dedupe — the host may emit the same tool-result more
|
|
3550
3776
|
// than once (claude.ai re-broadcasts on iframe re-attach).
|
|
3551
3777
|
// Re-mounting the same slice meta would flicker without changing
|
|
@@ -3657,7 +3883,7 @@ function readLiveBootstrapShape() {
|
|
|
3657
3883
|
* fallback share one call site — keeps the WS-driven boot semantics
|
|
3658
3884
|
* single-sourced.
|
|
3659
3885
|
*
|
|
3660
|
-
* `
|
|
3886
|
+
* `preResolved` is threaded in from the autostart resolver when
|
|
3661
3887
|
* it has already discovered slice meta — via inline `__GGUI_META__`
|
|
3662
3888
|
* global, a buffered `__GGUI_PENDING_TOOL_RESULTS__` entry, or an
|
|
3663
3889
|
* early `ui/notifications/tool-result` postMessage. Threading skips
|
|
@@ -3665,7 +3891,7 @@ function readLiveBootstrapShape() {
|
|
|
3665
3891
|
* re-await the same postMessage or re-parse the same global), saving
|
|
3666
3892
|
* up to the 30s postMessage timeout for spec-strict hosts.
|
|
3667
3893
|
*/
|
|
3668
|
-
function runBootProduction(
|
|
3894
|
+
function runBootProduction(preResolved, preResolvedLocator) {
|
|
3669
3895
|
const { app, transport } = createDefaultApp();
|
|
3670
3896
|
void bootProduction({
|
|
3671
3897
|
doc: document,
|
|
@@ -3680,7 +3906,7 @@ function runBootProduction(preResolvedMeta, preResolvedLocator) {
|
|
|
3680
3906
|
},
|
|
3681
3907
|
onObserve: postObservabilityToParent,
|
|
3682
3908
|
onLifecycle: postLifecycleToParent,
|
|
3683
|
-
...(
|
|
3909
|
+
...(preResolved !== undefined ? { preResolved } : {}),
|
|
3684
3910
|
...(preResolvedLocator !== undefined ? { preResolvedLocator } : {}),
|
|
3685
3911
|
});
|
|
3686
3912
|
}
|
|
@@ -3700,7 +3926,7 @@ if (shouldAutostart() && typeof window !== 'undefined') {
|
|
|
3700
3926
|
// 3. Live `ui/notifications/tool-result` postMessage — the
|
|
3701
3927
|
// spec-canonical channel for spec-strict hosts (`<AppRenderer>`,
|
|
3702
3928
|
// ChatGPT, claude.ai). Caught meta threads through as
|
|
3703
|
-
// `
|
|
3929
|
+
// `preResolved` so `bootSequence` doesn't re-await it.
|
|
3704
3930
|
//
|
|
3705
3931
|
// `readLiveBootstrapShape` still short-circuits the 30s tool-result
|
|
3706
3932
|
// wait when a live-channel envelope is already inlined — OSS embedded
|
|
@@ -3729,7 +3955,7 @@ if (shouldAutostart() && typeof window !== 'undefined') {
|
|
|
3729
3955
|
if (pre === null)
|
|
3730
3956
|
runBootProduction();
|
|
3731
3957
|
else if (pre.kind === 'meta')
|
|
3732
|
-
runBootProduction(pre.
|
|
3958
|
+
runBootProduction(pre.boot);
|
|
3733
3959
|
else
|
|
3734
3960
|
runBootProduction(undefined, pre.locator);
|
|
3735
3961
|
});
|
|
@@ -3908,7 +4134,30 @@ async function bootProduction(opts) {
|
|
|
3908
4134
|
renderListeners.delete(listener);
|
|
3909
4135
|
};
|
|
3910
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);
|
|
3911
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
|
+
});
|
|
3912
4161
|
const dispatchToolName = resolveDispatchToolName();
|
|
3913
4162
|
// Native-idiom interceptors — the replacement for the retired
|
|
3914
4163
|
// openLink / requestDisplayMode wire primitives. Installed here
|
|
@@ -3955,7 +4204,15 @@ async function bootProduction(opts) {
|
|
|
3955
4204
|
sessionId: meta.sessionId,
|
|
3956
4205
|
appId: meta.appId,
|
|
3957
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)),
|
|
3958
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
|
+
},
|
|
3959
4216
|
manager,
|
|
3960
4217
|
streamBus,
|
|
3961
4218
|
onDispatchEnvelope: (envelope) => {
|
|
@@ -4084,6 +4341,8 @@ async function bootProduction(opts) {
|
|
|
4084
4341
|
return hostPalette !== undefined ? { hostPalette } : {};
|
|
4085
4342
|
})(),
|
|
4086
4343
|
...(wrapOuter !== undefined ? { wrapOuter } : {}),
|
|
4344
|
+
// The boundary's give-up is reported to the server (ggui#1679).
|
|
4345
|
+
onRenderFailure: renderFailureReporter.report,
|
|
4087
4346
|
};
|
|
4088
4347
|
};
|
|
4089
4348
|
/**
|
|
@@ -4198,39 +4457,28 @@ async function bootProduction(opts) {
|
|
|
4198
4457
|
}
|
|
4199
4458
|
: {}),
|
|
4200
4459
|
});
|
|
4201
|
-
// B3b —
|
|
4202
|
-
//
|
|
4203
|
-
//
|
|
4204
|
-
//
|
|
4205
|
-
// handshake handlers and binds the
|
|
4206
|
-
// arrive directly through
|
|
4207
|
-
// 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.
|
|
4208
4466
|
//
|
|
4209
4467
|
// CLIENT_SUPPORTED_VERSIONS handshake is enforced inside
|
|
4210
4468
|
// `connectViaRegistry`'s ack-handler closure (NOT here).
|
|
4211
|
-
const
|
|
4212
|
-
|
|
4213
|
-
type: 'subscribe',
|
|
4214
|
-
payload: {
|
|
4215
|
-
sessionId: meta.sessionId,
|
|
4216
|
-
appId: meta.appId,
|
|
4217
|
-
...(meta.wsToken !== undefined
|
|
4218
|
-
? { wsToken: meta.wsToken }
|
|
4219
|
-
: {}),
|
|
4220
|
-
},
|
|
4221
|
-
}),
|
|
4222
|
-
classifyFrame: classifyChannelFrame,
|
|
4223
|
-
});
|
|
4224
|
-
channelRegistry.register(createRenderHandler({
|
|
4469
|
+
const channelHandlers = [];
|
|
4470
|
+
channelHandlers.push(createRenderHandler({
|
|
4225
4471
|
statusRefs,
|
|
4226
4472
|
pinnedSessionId: meta.sessionId,
|
|
4227
4473
|
applyRender,
|
|
4228
4474
|
getChannelTransport: () => channelTransport,
|
|
4229
4475
|
}));
|
|
4230
|
-
|
|
4476
|
+
const streamSeq = createStreamSeqTracker();
|
|
4477
|
+
channelHandlers.push(createDataHandler({
|
|
4231
4478
|
getCurrentGguiSession: () => currentRender,
|
|
4232
4479
|
streamBus,
|
|
4233
4480
|
validatorCtx,
|
|
4481
|
+
streamSeq,
|
|
4234
4482
|
}));
|
|
4235
4483
|
// Freeze latch (#483). This mount's own history epoch, read once
|
|
4236
4484
|
// from its boot meta: render → 0, an update-minted card → its N,
|
|
@@ -4248,18 +4496,18 @@ async function bootProduction(opts) {
|
|
|
4248
4496
|
currentTelemetrySink?.record('epoch.frozen', String(selfEpoch));
|
|
4249
4497
|
applyFreezeCue(renderInto);
|
|
4250
4498
|
};
|
|
4251
|
-
|
|
4499
|
+
channelHandlers.push(createPropsUpdateHandler({
|
|
4252
4500
|
getCurrentGguiSession: () => currentRender,
|
|
4253
4501
|
applyRender,
|
|
4254
4502
|
getSelfEpoch: () => selfEpoch,
|
|
4255
4503
|
onSuperseded: supersede,
|
|
4256
4504
|
isSuperseded: () => superseded,
|
|
4257
4505
|
}));
|
|
4258
|
-
|
|
4259
|
-
|
|
4506
|
+
channelHandlers.push(createDrainAckHandler({ dispatch: dispatchDrainAck }));
|
|
4507
|
+
channelHandlers.push(createChannelPayloadHandler({
|
|
4260
4508
|
getChannelTransport: () => channelTransport,
|
|
4261
4509
|
}));
|
|
4262
|
-
|
|
4510
|
+
channelHandlers.push(createChannelErrorHandler({
|
|
4263
4511
|
getChannelTransport: () => channelTransport,
|
|
4264
4512
|
}));
|
|
4265
4513
|
return {
|
|
@@ -4271,7 +4519,8 @@ async function bootProduction(opts) {
|
|
|
4271
4519
|
validatorCtx,
|
|
4272
4520
|
manager,
|
|
4273
4521
|
channelTransport,
|
|
4274
|
-
|
|
4522
|
+
channelHandlers,
|
|
4523
|
+
streamSeq,
|
|
4275
4524
|
composedGadgets,
|
|
4276
4525
|
};
|
|
4277
4526
|
},
|
|
@@ -4302,9 +4551,7 @@ async function bootProduction(opts) {
|
|
|
4302
4551
|
renderer,
|
|
4303
4552
|
...(opts.onObserve !== undefined ? { onObserve: opts.onObserve } : {}),
|
|
4304
4553
|
...(opts.onLifecycle !== undefined ? { onLifecycle: opts.onLifecycle } : {}),
|
|
4305
|
-
...(opts.
|
|
4306
|
-
? { preResolvedMeta: opts.preResolvedMeta }
|
|
4307
|
-
: {}),
|
|
4554
|
+
...(opts.preResolved !== undefined ? { preResolved: opts.preResolved } : {}),
|
|
4308
4555
|
...(opts.preResolvedLocator !== undefined
|
|
4309
4556
|
? { preResolvedLocator: opts.preResolvedLocator }
|
|
4310
4557
|
: {}),
|