@ggui-ai/iframe-runtime 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/call-tool-unwrap.d.ts +29 -0
  2. package/dist/call-tool-unwrap.js +56 -0
  3. package/dist/channels/props-update.d.ts +19 -0
  4. package/dist/channels/props-update.js +14 -1
  5. package/dist/compiled-validators.d.ts +16 -7
  6. package/dist/compiled-validators.js +41 -8
  7. package/dist/events-polling.d.ts +123 -1
  8. package/dist/events-polling.js +181 -53
  9. package/dist/iframe-runtime.js +65 -64
  10. package/dist/iframe-runtime.meta.json +1 -1
  11. package/dist/mcp-app-iframe-host.d.ts +1 -1
  12. package/dist/meta-parse.d.ts +13 -0
  13. package/dist/meta-parse.js +41 -0
  14. package/dist/observability.d.ts +1 -1
  15. package/dist/provisional-renderer.d.ts +1 -1
  16. package/dist/provisional-renderer.js +1 -1
  17. package/dist/react-renderer.d.ts +13 -1
  18. package/dist/react-renderer.js +87 -17
  19. package/dist/registry-subscribe.d.ts +42 -3
  20. package/dist/registry-subscribe.js +33 -10
  21. package/dist/render-item.d.ts +9 -0
  22. package/dist/render-item.js +7 -1
  23. package/dist/runtime-telemetry.d.ts +53 -0
  24. package/dist/runtime-telemetry.js +74 -0
  25. package/dist/runtime.d.ts +84 -0
  26. package/dist/runtime.js +522 -100
  27. package/dist/server.d.ts +19 -0
  28. package/dist/server.js +19 -0
  29. package/dist/shims/app-components.js +1 -0
  30. package/dist/shims/components.js +1 -0
  31. package/dist/shims/compositions.js +1 -0
  32. package/dist/shims/design.js +1 -0
  33. package/dist/shims/gadgets.js +1 -0
  34. package/dist/shims/interact.js +1 -0
  35. package/dist/shims/jsx-runtime.js +1 -0
  36. package/dist/shims/primitives.js +1 -0
  37. package/dist/shims/react-dom.js +1 -0
  38. package/dist/shims/react.js +1 -0
  39. package/dist/shims/templates.js +1 -0
  40. package/dist/shims/tokens.js +1 -0
  41. package/dist/shims/wire.js +1 -0
  42. package/dist/system-cards/ProtocolProbeCard.js +1 -1
  43. package/dist/system-cards/UiFeedbackCard.d.ts +1 -1
  44. package/dist/system-cards/UiFeedbackCard.js +1 -1
  45. package/dist/ui-feedback-chrome.js +7 -4
  46. package/dist/zod-jitless.d.ts +1 -0
  47. package/dist/zod-jitless.js +18 -0
  48. package/package.json +8 -7
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared 3-tier `CallToolResult` payload unwrap.
3
+ *
4
+ * MCP hosts relay `tools/call` results to the iframe in three observed
5
+ * envelope shapes. The tiers, in the order probed:
6
+ *
7
+ * 1. `structuredContent` — spec-canonical hosts.
8
+ * 2. `content[0].text` parsed as JSON — relay hosts that NORMALIZE
9
+ * the CallToolResult down to its text block (claude.ai's live
10
+ * behavior on the #471 retest: the enqueue succeeded server-side,
11
+ * but the response arrived text-only and classified as a failure,
12
+ * so no doorbell rang).
13
+ * 3. Fields directly on the bare result — looser hosts.
14
+ *
15
+ * Shared by the submit-action response classifier (`runtime.ts`'s
16
+ * `submitActionPayload`) and the bridge-pull rung's `fetchBody`
17
+ * carrier (`events-polling.ts`'s `buildBridgePolling`), so both reads
18
+ * agree on which envelope tier carries the payload for a given host
19
+ * shape.
20
+ */
21
+ /**
22
+ * Unwrap the payload record of a relayed `tools/call` result. `result`
23
+ * is the CallToolResult-level value (i.e. the JSON-RPC envelope's
24
+ * `result` field, already extracted by the caller).
25
+ *
26
+ * Returns `null` when no tier yields an object — the caller treats
27
+ * that as fallback/unknown.
28
+ */
29
+ export declare function unwrapCallToolResult(result: unknown): Record<string, unknown> | null;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared 3-tier `CallToolResult` payload unwrap.
3
+ *
4
+ * MCP hosts relay `tools/call` results to the iframe in three observed
5
+ * envelope shapes. The tiers, in the order probed:
6
+ *
7
+ * 1. `structuredContent` — spec-canonical hosts.
8
+ * 2. `content[0].text` parsed as JSON — relay hosts that NORMALIZE
9
+ * the CallToolResult down to its text block (claude.ai's live
10
+ * behavior on the #471 retest: the enqueue succeeded server-side,
11
+ * but the response arrived text-only and classified as a failure,
12
+ * so no doorbell rang).
13
+ * 3. Fields directly on the bare result — looser hosts.
14
+ *
15
+ * Shared by the submit-action response classifier (`runtime.ts`'s
16
+ * `submitActionPayload`) and the bridge-pull rung's `fetchBody`
17
+ * carrier (`events-polling.ts`'s `buildBridgePolling`), so both reads
18
+ * agree on which envelope tier carries the payload for a given host
19
+ * shape.
20
+ */
21
+ /**
22
+ * Unwrap the payload record of a relayed `tools/call` result. `result`
23
+ * is the CallToolResult-level value (i.e. the JSON-RPC envelope's
24
+ * `result` field, already extracted by the caller).
25
+ *
26
+ * Returns `null` when no tier yields an object — the caller treats
27
+ * that as fallback/unknown.
28
+ */
29
+ export function unwrapCallToolResult(result) {
30
+ if (result === null || typeof result !== 'object')
31
+ return null;
32
+ const r = result;
33
+ if (r.structuredContent && typeof r.structuredContent === 'object') {
34
+ return r.structuredContent;
35
+ }
36
+ // Text-block tier BEFORE the bare-result tier when content exists:
37
+ // a normalized result's own top level carries only `content`, so
38
+ // probing it for payload fields would always miss anyway — but a
39
+ // parseable text block is decisive evidence of the real payload.
40
+ const content = r.content;
41
+ if (Array.isArray(content) && content.length > 0) {
42
+ const first = content[0];
43
+ if (first && first.type === 'text' && typeof first.text === 'string') {
44
+ try {
45
+ const parsed = JSON.parse(first.text);
46
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
47
+ return parsed;
48
+ }
49
+ }
50
+ catch {
51
+ // Not JSON — fall through to the bare-result tier.
52
+ }
53
+ }
54
+ }
55
+ return r;
56
+ }
@@ -45,5 +45,24 @@ export interface PropsUpdateHandlerDeps {
45
45
  * the render-frame handler so React updates flow through one path.
46
46
  */
47
47
  readonly applyRender: (render: GguiSession | GguiSessionSeedInput) => Promise<void>;
48
+ /**
49
+ * This mount's OWN history epoch (#483 freeze latch). A
50
+ * `props_update` frame carrying a HIGHER epoch means an
51
+ * `ggui_update` minted a newer card and this one is now history —
52
+ * the handler hands off to {@link onSuperseded} instead of applying.
53
+ * Absent ⇒ 0 (a fresh render).
54
+ */
55
+ readonly getSelfEpoch?: () => number;
56
+ /**
57
+ * Invoked (once) when a higher-epoch update supersedes this mount.
58
+ * The runtime freezes the frame: stop applying, tear down
59
+ * subscriptions, disable dispatch, show the superseded cue.
60
+ */
61
+ readonly onSuperseded?: () => void;
62
+ /**
63
+ * True after this mount has frozen. When true the handler drops
64
+ * every frame — a superseded mount never repaints again.
65
+ */
66
+ readonly isSuperseded?: () => boolean;
48
67
  }
49
68
  export declare function createPropsUpdateHandler(deps: PropsUpdateHandlerDeps): ChannelHandler<PropsUpdatePayload>;
@@ -33,9 +33,22 @@ export function createPropsUpdateHandler(deps) {
33
33
  return {
34
34
  type: 'props_update',
35
35
  onMessage: async (payload) => {
36
- const { sessionId, props } = payload;
36
+ // Already frozen (#483): a superseded mount never repaints again.
37
+ if (deps.isSuperseded?.() === true)
38
+ return;
39
+ const { sessionId, props, epoch } = payload;
37
40
  if (typeof sessionId !== 'string' || sessionId.length === 0)
38
41
  return;
42
+ // Freeze latch (#483): a frame stamped with a HIGHER epoch than
43
+ // this mount's own means a `ggui_update` minted a newer card —
44
+ // this one is history. Do NOT apply; hand off to the freeze
45
+ // routine. (`ggui_amend` carries the unchanged head epoch, so
46
+ // amends to the live head still apply.)
47
+ const selfEpoch = deps.getSelfEpoch?.() ?? 0;
48
+ if (typeof epoch === 'number' && epoch > selfEpoch) {
49
+ deps.onSuperseded?.();
50
+ return;
51
+ }
39
52
  if (props === null || typeof props !== 'object')
40
53
  return;
41
54
  const current = deps.getCurrentGguiSession();
@@ -39,17 +39,26 @@ export declare const EMPTY_COMPILED_VALIDATOR_SET: CompiledValidatorSet;
39
39
  */
40
40
  export declare function loadCompiledValidators(meta: CompiledContractValidators | undefined, warn?: (message: string, detail: unknown) => void): Promise<CompiledValidatorSet>;
41
41
  /**
42
- * #109 content-addressable variant: fetch the validator-bundle URL,
43
- * dynamic-import the response, then delegate to
44
- * {@link loadCompiledValidators}. Returns {@link EMPTY_COMPILED_VALIDATOR_SET}
42
+ * #109 content-addressable variant: fetch the validator-bundle URL via
43
+ * ONE dynamic import. Returns {@link EMPTY_COMPILED_VALIDATOR_SET}
45
44
  * when `url` is undefined OR the fetch/import fails — sidecar
46
45
  * semantics, the server-side `assertActionContract` gate remains
47
46
  * authoritative on validator absence.
48
47
  *
49
- * Response shape: an ES module whose `default` export is a
50
- * {@link CompiledContractValidators} object (produced server-side by
51
- * `bundleCompiledValidatorsAsModule`). `Cache-Control: immutable` so
52
- * repeat renders with the same contract hit browser cache with no
48
+ * Response shape (v2 executable format, ggui#522 slice 2): an ES
49
+ * module whose `default` export carries the validate FUNCTIONS
50
+ * themselves (produced server-side by
51
+ * `bundleValidatorExprsAsExecutableModule`) — the single https import
52
+ * is the whole load, governed by `script-src` origins alone. No
53
+ * per-validator `blob:` import remains on this path, which is what
54
+ * lets a strict-CSP embedder (no `blob:`, no `data:`, no
55
+ * `'unsafe-eval'`) run client-side outbound validation at all.
56
+ *
57
+ * A default export whose leaves are not functions (the retired v1
58
+ * format carried module SOURCES as strings — reachable only through a
59
+ * stale CDN copy under a pre-salt URL, which no current slice mints)
60
+ * degrades to the empty set with a warning. `Cache-Control: immutable`
61
+ * so repeat renders with the same contract hit browser cache with no
53
62
  * round-trip.
54
63
  */
55
64
  export declare function loadCompiledValidatorsFromUrl(url: string | undefined, warn?: (message: string, detail: unknown) => void): Promise<CompiledValidatorSet>;
@@ -73,17 +73,26 @@ export async function loadCompiledValidators(meta, warn) {
73
73
  };
74
74
  }
75
75
  /**
76
- * #109 content-addressable variant: fetch the validator-bundle URL,
77
- * dynamic-import the response, then delegate to
78
- * {@link loadCompiledValidators}. Returns {@link EMPTY_COMPILED_VALIDATOR_SET}
76
+ * #109 content-addressable variant: fetch the validator-bundle URL via
77
+ * ONE dynamic import. Returns {@link EMPTY_COMPILED_VALIDATOR_SET}
79
78
  * when `url` is undefined OR the fetch/import fails — sidecar
80
79
  * semantics, the server-side `assertActionContract` gate remains
81
80
  * authoritative on validator absence.
82
81
  *
83
- * Response shape: an ES module whose `default` export is a
84
- * {@link CompiledContractValidators} object (produced server-side by
85
- * `bundleCompiledValidatorsAsModule`). `Cache-Control: immutable` so
86
- * repeat renders with the same contract hit browser cache with no
82
+ * Response shape (v2 executable format, ggui#522 slice 2): an ES
83
+ * module whose `default` export carries the validate FUNCTIONS
84
+ * themselves (produced server-side by
85
+ * `bundleValidatorExprsAsExecutableModule`) — the single https import
86
+ * is the whole load, governed by `script-src` origins alone. No
87
+ * per-validator `blob:` import remains on this path, which is what
88
+ * lets a strict-CSP embedder (no `blob:`, no `data:`, no
89
+ * `'unsafe-eval'`) run client-side outbound validation at all.
90
+ *
91
+ * A default export whose leaves are not functions (the retired v1
92
+ * format carried module SOURCES as strings — reachable only through a
93
+ * stale CDN copy under a pre-salt URL, which no current slice mints)
94
+ * degrades to the empty set with a warning. `Cache-Control: immutable`
95
+ * so repeat renders with the same contract hit browser cache with no
87
96
  * round-trip.
88
97
  */
89
98
  export async function loadCompiledValidatorsFromUrl(url, warn) {
@@ -96,7 +105,31 @@ export async function loadCompiledValidatorsFromUrl(url, warn) {
96
105
  warn?.('[ggui:validators] contract bundle: default export is not an object', compiled);
97
106
  return EMPTY_COMPILED_VALIDATOR_SET;
98
107
  }
99
- return loadCompiledValidators(compiled, warn);
108
+ const groups = compiled;
109
+ const pickGroup = (group, kind) => {
110
+ const map = new Map();
111
+ if (group === undefined)
112
+ return map;
113
+ for (const [name, fn] of Object.entries(group)) {
114
+ if (isValidateFunction(fn)) {
115
+ map.set(name, fn);
116
+ }
117
+ else {
118
+ warn?.(`[ggui:validators] ${kind}.${name}: bundle entry is not a function (stale v1-format bundle?)`, typeof fn);
119
+ }
120
+ }
121
+ return map;
122
+ };
123
+ const props = isValidateFunction(groups.props) ? groups.props : undefined;
124
+ if (groups.props !== undefined && props === undefined) {
125
+ warn?.('[ggui:validators] props: bundle entry is not a function (stale v1-format bundle?)', typeof groups.props);
126
+ }
127
+ return {
128
+ ...(props !== undefined ? { props } : {}),
129
+ actions: pickGroup(groups.actions, 'action'),
130
+ streams: pickGroup(groups.streams, 'stream'),
131
+ context: pickGroup(groups.context, 'context'),
132
+ };
100
133
  }
101
134
  catch (err) {
102
135
  warn?.(`[ggui:validators] contract bundle: failed to load ${url}`, err);
@@ -22,6 +22,15 @@
22
22
  * only sees the body, so we emit a synthetic `error` frame for the
23
23
  * registry to surface upward.
24
24
  *
25
+ * # Bridge-pull carrier (terminal rung)
26
+ *
27
+ * `buildBridgePolling` composes the SAME algorithm on the `fetchBody`
28
+ * carrier: a CSP-jailed MCP-Apps iframe with no network path pulls the
29
+ * same ledger by issuing `ggui_runtime_pull` `tools/call`s over the
30
+ * host's postMessage bridge. One parse core
31
+ * (`createEventsSnapshotParser`), two carriers — the tool's output is
32
+ * byte-parity with the `/events` route by contract.
33
+ *
25
34
  * # Anthropic first-mount race fix
26
35
  *
27
36
  * Iframes mounted inside the Anthropic SDK lose `__GGUI_META__` because
@@ -39,6 +48,39 @@
39
48
  * fallback, and live updates.
40
49
  */
41
50
  import type { RegistryPollingOptions } from '@ggui-ai/live-channel';
51
+ import type { GguiRuntimePullInput } from '@ggui-ai/protocol';
52
+ /**
53
+ * Shared, monotonic event-ledger cursor for the failover ladder.
54
+ *
55
+ * The SSE rung advances it via `RegistrySseOptions.onSequence` (the
56
+ * SSE `id:` field IS the ledger sequence); the polling descriptor
57
+ * reads it per tick — so an SSE→polling demotion resumes from the
58
+ * last streamed event instead of re-replaying from the boot snapshot.
59
+ */
60
+ export interface SequenceCursor {
61
+ get(): number;
62
+ /**
63
+ * Monotonic max — no-op when `seq <= current`, guaranteeing a stale
64
+ * rung can never rewind progress another rung already made. Normal
65
+ * delivery path (SSE `id:`, polling `lastSequence`).
66
+ */
67
+ advance(seq: number): void;
68
+ /**
69
+ * Unconditional server-truth override — the REPLAY_HORIZON_PASSED
70
+ * escape hatch, and ONLY that. When the server declares the cursor
71
+ * outside the replayable window it reports its actual high-water
72
+ * mark, which can be BELOW the client cursor (a re-minted or reset
73
+ * session whose ledger restarted). A monotonic `advance` would no-op
74
+ * there and every subsequent tick would re-ask ahead of the horizon —
75
+ * a permanent error loop. `reset` adopts the server's number so the
76
+ * next tick lands inside the window and the ladder self-heals.
77
+ */
78
+ reset(seq: number): void;
79
+ }
80
+ /**
81
+ * Create a {@link SequenceCursor} seeded at `seed` (default `0`).
82
+ */
83
+ export declare function createSequenceCursor(seed?: number): SequenceCursor;
42
84
  export interface BuildEventsPollingOptions {
43
85
  /**
44
86
  * Base URL the polling tick reads from. The composer appends
@@ -53,7 +95,9 @@ export interface BuildEventsPollingOptions {
53
95
  * Optional cursor seed — initial value of `sinceSequence` on the
54
96
  * first tick. Typically threaded from `render.lastSequence` so the
55
97
  * cold-mount-after-WS-fail path picks up where the snapshot left
56
- * off. Defaults to `0` (replay everything still retained).
98
+ * off. Defaults to `0` (replay everything still retained). Ignored
99
+ * when {@link cursor} is supplied — the shared cursor carries its
100
+ * own seed.
57
101
  */
58
102
  readonly initialSinceSequence?: number;
59
103
  /**
@@ -67,6 +111,14 @@ export interface BuildEventsPollingOptions {
67
111
  * single tick to keep latency bounded.
68
112
  */
69
113
  readonly limit?: number;
114
+ /**
115
+ * Shared ladder cursor (WS → SSE → polling). When supplied, the
116
+ * descriptor reads/advances it instead of a private closure cursor,
117
+ * so SSE deliveries observed via `RegistrySseOptions.onSequence`
118
+ * move the polling replay point forward. Absent → private internal
119
+ * cursor seeded from {@link initialSinceSequence} (today's behavior).
120
+ */
121
+ readonly cursor?: SequenceCursor;
70
122
  }
71
123
  /**
72
124
  * Build a {@link RegistryPollingOptions} descriptor that reads
@@ -93,3 +145,73 @@ export interface BuildEventsPollingOptions {
93
145
  * pulls the live cursor and composes the URL anew.
94
146
  */
95
147
  export declare function buildEventsPolling(opts: BuildEventsPollingOptions): RegistryPollingOptions;
148
+ export interface BuildBridgePollingOptions {
149
+ /**
150
+ * Host `tools/call` dispatcher the tick pulls through. The runtime
151
+ * binds this to `app.callServerTool` on the connected MCP-Apps App
152
+ * handle — in a CSP-jailed host the postMessage bridge is the only
153
+ * carrier the iframe has, which is why this rung exists. The
154
+ * resolved value is the raw `CallToolResult`; the tick unwraps it
155
+ * via the shared 3-tier {@link unwrapCallToolResult} (the same
156
+ * unwrap submit-action responses use, so host-normalization
157
+ * quirks — claude.ai's text-only collapse included — are handled
158
+ * identically on both paths).
159
+ */
160
+ readonly callTool: (name: 'ggui_runtime_pull', args: GguiRuntimePullInput) => Promise<unknown>;
161
+ /** Active render id — threaded verbatim as the tool's `sessionId`. */
162
+ readonly sessionId: string;
163
+ /**
164
+ * Shared ladder cursor (WS → SSE → polling → bridge). REQUIRED —
165
+ * unlike {@link BuildEventsPollingOptions.cursor} there is no
166
+ * private-cursor fallback, because the bridge rung never exists
167
+ * alone: the runtime always creates the ladder cursor when it
168
+ * composes this rung, and a demotion into the bridge must resume
169
+ * from whatever the rungs above already delivered.
170
+ */
171
+ readonly cursor: SequenceCursor;
172
+ /**
173
+ * Failure-pacing interval. Defaults to 3000ms. In subscription mode
174
+ * the chain paces via `nextDelayMs`; this value only paces retries
175
+ * after FAILED ticks (transport contract).
176
+ */
177
+ readonly intervalMs?: number;
178
+ /**
179
+ * Server-side hold per subscription-mode pull, in seconds. Defaults
180
+ * to 20 (under ggui_consume's proven 25s host tolerance). `0`
181
+ * disables holding entirely — every pull returns immediately and
182
+ * pacing degrades to {@link idleIntervalMs}.
183
+ */
184
+ readonly holdSeconds?: number;
185
+ /**
186
+ * Consecutive empty holds before demoting subscription → idle
187
+ * polling. Defaults to 3. Any delivered event re-promotes.
188
+ */
189
+ readonly demoteAfterEmpties?: number;
190
+ /**
191
+ * Idle-mode pull cadence. Defaults to 15000ms.
192
+ */
193
+ readonly idleIntervalMs?: number;
194
+ /**
195
+ * Optional per-page event cap, forwarded as the tool's `limit`
196
+ * argument only when set. Absent → the key is omitted and the
197
+ * server default applies.
198
+ */
199
+ readonly limit?: number;
200
+ }
201
+ /**
202
+ * Build the bridge-pull rung of the failover ladder — the SAME
203
+ * polling algorithm as {@link buildEventsPolling} on a different
204
+ * carrier: instead of an HTTP `url`, the descriptor supplies a
205
+ * `fetchBody` that pulls the event ledger through the host's
206
+ * `tools/call` postMessage bridge (`ggui_runtime_pull`). Terminal
207
+ * rung: the registry never arms a failure budget on it.
208
+ *
209
+ * Per tick: `callTool('ggui_runtime_pull', {sessionId, sinceSequence:
210
+ * <cursor>, limit?})` → unwrap the CallToolResult via the shared
211
+ * 3-tier {@link unwrapCallToolResult} → the unwrapped body feeds the
212
+ * SAME parse core as the HTTP rung (`EventsResponse` dispatch,
213
+ * `lastSequence` advance, REPLAY_HORIZON_PASSED reset — the tool's
214
+ * output is byte-parity with `GET /api/sessions/:sessionId/events` by
215
+ * contract).
216
+ */
217
+ export declare function buildBridgePolling(opts: BuildBridgePollingOptions): RegistryPollingOptions;
@@ -1,5 +1,29 @@
1
+ import { unwrapCallToolResult } from './call-tool-unwrap.js';
1
2
  const DEFAULT_EVENTS_POLL_INTERVAL_MS = 2000;
2
3
  const DEFAULT_EVENTS_PAGE_LIMIT = 100;
4
+ const DEFAULT_BRIDGE_PULL_INTERVAL_MS = 3000;
5
+ // Subscription-mode defaults (transport-ladder ruling 20). The hold
6
+ // stays under ggui_consume's proven 25s host tolerance; K=3 empty
7
+ // holds (~1 min of silence) demotes to sparse idle pulls.
8
+ const DEFAULT_BRIDGE_HOLD_SECONDS = 20;
9
+ const DEFAULT_BRIDGE_DEMOTE_AFTER_EMPTIES = 3;
10
+ const DEFAULT_BRIDGE_IDLE_INTERVAL_MS = 15_000;
11
+ /**
12
+ * Create a {@link SequenceCursor} seeded at `seed` (default `0`).
13
+ */
14
+ export function createSequenceCursor(seed = 0) {
15
+ let current = seed;
16
+ return {
17
+ get: () => current,
18
+ advance: (seq) => {
19
+ if (seq > current)
20
+ current = seq;
21
+ },
22
+ reset: (seq) => {
23
+ current = seq;
24
+ },
25
+ };
26
+ }
3
27
  /**
4
28
  * Compose the per-tick URL with `&sinceSequence=N&limit=M` appended.
5
29
  * The base URL may or may not already carry a query string; we pick
@@ -68,7 +92,10 @@ function isGguiSessionEvent(value) {
68
92
  * pulls the live cursor and composes the URL anew.
69
93
  */
70
94
  export function buildEventsPolling(opts) {
71
- let cursor = opts.initialSinceSequence ?? 0;
95
+ // Shared ladder cursor when supplied; else a private cursor seeded
96
+ // from `initialSinceSequence` — behaviorally identical to the old
97
+ // closure `let cursor`, modulo monotonicity (advance() never rewinds).
98
+ const cur = opts.cursor ?? createSequenceCursor(opts.initialSinceSequence ?? 0);
72
99
  const limit = opts.limit ?? DEFAULT_EVENTS_PAGE_LIMIT;
73
100
  const intervalMs = opts.intervalMs ?? DEFAULT_EVENTS_POLL_INTERVAL_MS;
74
101
  // Each `url` access recomputes from the current cursor. The
@@ -76,7 +103,7 @@ export function buildEventsPolling(opts) {
76
103
  const descriptor = Object.create(null);
77
104
  Object.defineProperty(descriptor, 'url', {
78
105
  enumerable: true,
79
- get: () => composeTickUrl(opts.baseUrl, cursor, limit),
106
+ get: () => composeTickUrl(opts.baseUrl, cur.get(), limit),
80
107
  });
81
108
  Object.defineProperty(descriptor, 'intervalMs', {
82
109
  enumerable: true,
@@ -84,60 +111,161 @@ export function buildEventsPolling(opts) {
84
111
  });
85
112
  Object.defineProperty(descriptor, 'parseSnapshot', {
86
113
  enumerable: true,
87
- value: (body) => {
88
- // 410 case manifests as a body that doesn't match the
89
- // EventsResponse shape (`{reason: 'REPLAY_HORIZON_PASSED',
90
- // currentSequence}`). Surface a synthetic `error` frame so the
91
- // registry's error handler can fire the re-mount signal.
114
+ value: createEventsSnapshotParser(cur),
115
+ });
116
+ return descriptor;
117
+ }
118
+ /**
119
+ * The ONE per-tick `EventsResponse` parse core behind BOTH ledger-pull
120
+ * carriers — HTTP polling ({@link buildEventsPolling}) and bridge-pull
121
+ * ({@link buildBridgePolling}). Byte-parity between `GET
122
+ * /api/sessions/:sessionId/events` and `ggui_runtime_pull` output is
123
+ * contractual precisely so this single parser handles both.
124
+ *
125
+ * Reads the `EventsResponse` envelope, dispatches each event by
126
+ * `event.type` to the registered channel handler, advances the shared
127
+ * cursor to `lastSequence`, and folds the REPLAY_HORIZON_PASSED
128
+ * escape hatch through `cursor.reset()` + a synthetic `error` frame.
129
+ */
130
+ function createEventsSnapshotParser(cur) {
131
+ return (body) => {
132
+ // 410 case manifests as a body that doesn't match the
133
+ // EventsResponse shape (`{reason: 'REPLAY_HORIZON_PASSED',
134
+ // currentSequence}`). Surface a synthetic `error` frame so the
135
+ // registry's error handler can fire the re-mount signal.
136
+ if (body !== null &&
137
+ typeof body === 'object' &&
138
+ body.reason === 'REPLAY_HORIZON_PASSED') {
139
+ const cs = body.currentSequence;
140
+ const currentSequence = typeof cs === 'number' ? cs : 0;
141
+ // Advance to the server's high-water mark; next tick starts
142
+ // fresh from there (monotonic — inside the replayable-range
143
+ // contract the high-water mark is >= the cursor, and a shared
144
+ // ladder cursor must never rewind another rung's progress).
145
+ // Consumers handle the re-mount via the error channel.
146
+ // Server-truth override, NOT advance(): the horizon error can
147
+ // report a high-water mark BELOW this cursor (re-minted/reset
148
+ // session ledger) — monotonic advance would no-op and every
149
+ // subsequent tick would re-ask ahead of the horizon forever.
150
+ cur.reset(currentSequence);
151
+ const errorFrame = {
152
+ type: 'error',
153
+ payload: {
154
+ code: 'REPLAY_HORIZON_PASSED',
155
+ message: `events polling cursor outside replayable range; reset to ${currentSequence}`,
156
+ details: { currentSequence },
157
+ },
158
+ };
159
+ return { error: errorFrame };
160
+ }
161
+ if (!isEventsResponse(body))
162
+ return null;
163
+ // Advance cursor even on empty pages — the server's high-water
164
+ // mark moves with /state reads too.
165
+ cur.advance(body.lastSequence);
166
+ if (body.events.length === 0) {
167
+ // Nothing to dispatch; the empty object signals "snapshot
168
+ // parsed but no handlers matched today's keys" (distinct from
169
+ // `null` = no change).
170
+ return {};
171
+ }
172
+ // Group events by type. The registry-level polling transport
173
+ // calls one handler per type; if multiple events share a type
174
+ // we'd lose deliveries. Today's wire frame types
175
+ // (render/props_update) are typically distinct per tick at the
176
+ // expected 2s cadence, but the protocol allows multiple of the
177
+ // same type. Honest workaround: dispatch the LAST event of each
178
+ // type and rely on the consumer's idempotency. Future R8: extend
179
+ // the dispatch shape to a list per type.
180
+ const frames = {};
181
+ for (const event of body.events) {
182
+ if (!isGguiSessionEvent(event))
183
+ continue;
184
+ // Two namespaces meet here, and THIS is the single translation
185
+ // point: the ledger speaks the canonical event taxonomy
186
+ // ('ui.updated', 'user.submitted', …) while the registry's
187
+ // handler map speaks live-channel FRAME types ('props_update',
188
+ // …). `ggui_update` appends 'ui.updated' with the exact
189
+ // `{sessionId, props}` payload the props_update handler
190
+ // expects — map the name so pull rungs repaint through the
191
+ // same handler the WS/SSE push planes use.
192
+ const frameType = event.type === 'ui.updated' ? 'props_update' : event.type;
193
+ frames[frameType] = {
194
+ type: frameType,
195
+ payload: event.data,
196
+ };
197
+ }
198
+ return frames;
199
+ };
200
+ }
201
+ /**
202
+ * Build the bridge-pull rung of the failover ladder — the SAME
203
+ * polling algorithm as {@link buildEventsPolling} on a different
204
+ * carrier: instead of an HTTP `url`, the descriptor supplies a
205
+ * `fetchBody` that pulls the event ledger through the host's
206
+ * `tools/call` postMessage bridge (`ggui_runtime_pull`). Terminal
207
+ * rung: the registry never arms a failure budget on it.
208
+ *
209
+ * Per tick: `callTool('ggui_runtime_pull', {sessionId, sinceSequence:
210
+ * <cursor>, limit?})` → unwrap the CallToolResult via the shared
211
+ * 3-tier {@link unwrapCallToolResult} → the unwrapped body feeds the
212
+ * SAME parse core as the HTTP rung (`EventsResponse` dispatch,
213
+ * `lastSequence` advance, REPLAY_HORIZON_PASSED reset — the tool's
214
+ * output is byte-parity with `GET /api/sessions/:sessionId/events` by
215
+ * contract).
216
+ */
217
+ export function buildBridgePolling(opts) {
218
+ const { callTool, sessionId, cursor } = opts;
219
+ const limit = opts.limit;
220
+ const intervalMs = opts.intervalMs ?? DEFAULT_BRIDGE_PULL_INTERVAL_MS;
221
+ const holdSeconds = opts.holdSeconds ?? DEFAULT_BRIDGE_HOLD_SECONDS;
222
+ const demoteAfterEmpties = opts.demoteAfterEmpties ?? DEFAULT_BRIDGE_DEMOTE_AFTER_EMPTIES;
223
+ const idleIntervalMs = opts.idleIntervalMs ?? DEFAULT_BRIDGE_IDLE_INTERVAL_MS;
224
+ // Subscription-mode state (transport-ladder ruling 20). Two modes,
225
+ // one counter:
226
+ // - SUBSCRIPTION (hot): each pull carries `wait` — the server
227
+ // holds the call until an event lands or the hold times out —
228
+ // and the next pull fires IMMEDIATELY on return. Back-to-back
229
+ // held single-shots emulate push: event→screen ≈ one relay RTT.
230
+ // - POLLING (idle): after `demoteAfterEmpties` consecutive empty
231
+ // holds the card is presumed quiet; drop to sparse un-held
232
+ // pulls every `idleIntervalMs` so a dormant card doesn't pin
233
+ // the host's relay open forever.
234
+ // Any delivered event promotes straight back to subscription mode.
235
+ let consecutiveEmpties = 0;
236
+ const hot = () => consecutiveEmpties < demoteAfterEmpties;
237
+ return {
238
+ intervalMs,
239
+ fetchBody: async () => {
240
+ const result = await callTool('ggui_runtime_pull', {
241
+ sessionId,
242
+ sinceSequence: cursor.get(),
243
+ ...(limit !== undefined ? { limit } : {}),
244
+ ...(hot() ? { wait: holdSeconds } : {}),
245
+ });
246
+ return unwrapCallToolResult(result);
247
+ },
248
+ parseSnapshot: createEventsSnapshotParser(cursor),
249
+ nextDelayMs: (body) => {
250
+ if (isEventsResponse(body)) {
251
+ if (body.events.length > 0) {
252
+ consecutiveEmpties = 0;
253
+ return 0;
254
+ }
255
+ consecutiveEmpties += 1;
256
+ return hot() ? 0 : idleIntervalMs;
257
+ }
92
258
  if (body !== null &&
93
259
  typeof body === 'object' &&
94
260
  body.reason === 'REPLAY_HORIZON_PASSED') {
95
- const cs = body.currentSequence;
96
- const currentSequence = typeof cs === 'number' ? cs : 0;
97
- // Reset cursor to the server's high-water mark; next tick
98
- // starts fresh from there. Consumers handle the re-mount via
99
- // the error channel.
100
- cursor = currentSequence;
101
- const errorFrame = {
102
- type: 'error',
103
- payload: {
104
- code: 'REPLAY_HORIZON_PASSED',
105
- message: `events polling cursor outside replayable range; reset to ${currentSequence}`,
106
- details: { currentSequence },
107
- },
108
- };
109
- return { error: errorFrame };
110
- }
111
- if (!isEventsResponse(body))
112
- return null;
113
- // Advance cursor even on empty pages — the server's high-water
114
- // mark moves with /state reads too.
115
- cursor = body.lastSequence;
116
- if (body.events.length === 0) {
117
- // Nothing to dispatch; the empty object signals "snapshot
118
- // parsed but no handlers matched today's keys" (distinct from
119
- // `null` = no change).
120
- return {};
121
- }
122
- // Group events by type. The registry-level polling transport
123
- // calls one handler per type; if multiple events share a type
124
- // we'd lose deliveries. Today's wire frame types
125
- // (render/props_update) are typically distinct per tick at the
126
- // expected 2s cadence, but the protocol allows multiple of the
127
- // same type. Honest workaround: dispatch the LAST event of each
128
- // type and rely on the consumer's idempotency. Future R8: extend
129
- // the dispatch shape to a list per type.
130
- const frames = {};
131
- for (const event of body.events) {
132
- if (!isGguiSessionEvent(event))
133
- continue;
134
- frames[event.type] = {
135
- type: event.type,
136
- payload: event.data,
137
- };
261
+ // Cursor just self-healed to the server's high-water mark
262
+ // (parseSnapshot reset it) — one immediate re-pull lands
263
+ // inside the window.
264
+ return 0;
138
265
  }
139
- return frames;
266
+ // Unrecognized body — pace like idle rather than hot-looping on
267
+ // garbage.
268
+ return idleIntervalMs;
140
269
  },
141
- });
142
- return descriptor;
270
+ };
143
271
  }