@ggui-ai/iframe-runtime 0.1.0-rc.3 → 0.2.0-alpha.3

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 (83) hide show
  1. package/README.md +7 -6
  2. package/dist/channel-transport.d.ts +15 -15
  3. package/dist/channel-transport.js +32 -35
  4. package/dist/channels/channel-payload.d.ts +2 -2
  5. package/dist/channels/channel-payload.js +1 -1
  6. package/dist/channels/data.d.ts +14 -19
  7. package/dist/channels/data.js +13 -31
  8. package/dist/channels/drain-ack.d.ts +2 -2
  9. package/dist/channels/drain-ack.js +1 -1
  10. package/dist/channels/feedback.d.ts +1 -1
  11. package/dist/channels/index.d.ts +1 -1
  12. package/dist/channels/index.js +1 -1
  13. package/dist/channels/props-update.d.ts +33 -37
  14. package/dist/channels/props-update.js +30 -106
  15. package/dist/channels/render-frame.d.ts +52 -0
  16. package/dist/channels/render-frame.js +53 -0
  17. package/dist/channels/system.d.ts +1 -1
  18. package/dist/compiled-validators.d.ts +15 -0
  19. package/dist/compiled-validators.js +31 -0
  20. package/dist/context-observer.d.ts +68 -8
  21. package/dist/context-observer.js +25 -45
  22. package/dist/events-polling.d.ts +95 -0
  23. package/dist/events-polling.js +143 -0
  24. package/dist/gadget-loader.d.ts +1 -1
  25. package/dist/globals.d.ts +4 -4
  26. package/dist/host-context-emitter.d.ts +84 -20
  27. package/dist/host-context-emitter.js +159 -29
  28. package/dist/iframe-runtime.js +98 -59
  29. package/dist/index.d.ts +18 -13
  30. package/dist/index.js +1 -1
  31. package/dist/lifecycle.d.ts +2 -2
  32. package/dist/lifecycle.js +5 -5
  33. package/dist/mcp-app-iframe-host.d.ts +9 -9
  34. package/dist/mcp-app-iframe-host.js +16 -17
  35. package/dist/meta-parse.d.ts +44 -0
  36. package/dist/meta-parse.js +390 -0
  37. package/dist/observability.d.ts +6 -6
  38. package/dist/protocol-error.d.ts +7 -7
  39. package/dist/provisional-renderer.d.ts +1 -1
  40. package/dist/provisional-renderer.js +1 -1
  41. package/dist/react-renderer.d.ts +10 -11
  42. package/dist/react-renderer.js +8 -8
  43. package/dist/registry-subscribe.d.ts +27 -9
  44. package/dist/registry-subscribe.js +32 -27
  45. package/dist/render-item.d.ts +83 -0
  46. package/dist/{stack-item-renderer.js → render-item.js} +106 -106
  47. package/dist/runtime.d.ts +183 -131
  48. package/dist/runtime.js +1264 -1330
  49. package/dist/status-dom.d.ts +52 -47
  50. package/dist/status-dom.js +51 -34
  51. package/dist/system-cards/NoCredentialsCard.d.ts +1 -1
  52. package/dist/system-cards/NoCredentialsCard.js +1 -1
  53. package/dist/system-cards/ProtocolProbeCard.js +2 -2
  54. package/dist/system-cards/index.d.ts +4 -4
  55. package/dist/system-cards/index.js +4 -4
  56. package/dist/types.d.ts +70 -34
  57. package/dist/types.js +1 -1
  58. package/dist/validation.d.ts +2 -2
  59. package/dist/validation.js +2 -2
  60. package/dist/wire-config.d.ts +54 -82
  61. package/dist/wire-config.js +61 -93
  62. package/package.json +9 -8
  63. package/dist/bootstrap.d.ts +0 -72
  64. package/dist/bootstrap.js +0 -500
  65. package/dist/canvas/animator/animator-host.d.ts +0 -63
  66. package/dist/canvas/animator/animator-host.js +0 -105
  67. package/dist/canvas/animator/animator-pill.d.ts +0 -42
  68. package/dist/canvas/animator/animator-pill.js +0 -119
  69. package/dist/canvas/animator/state-machine.d.ts +0 -106
  70. package/dist/canvas/animator/state-machine.js +0 -150
  71. package/dist/canvas/canvas-shell.d.ts +0 -77
  72. package/dist/canvas/canvas-shell.js +0 -117
  73. package/dist/canvas/display-mode-policy.d.ts +0 -104
  74. package/dist/canvas/display-mode-policy.js +0 -98
  75. package/dist/canvas/mount.d.ts +0 -150
  76. package/dist/canvas/mount.js +0 -202
  77. package/dist/canvas/nav-stack.d.ts +0 -133
  78. package/dist/canvas/nav-stack.js +0 -172
  79. package/dist/channels/push.d.ts +0 -47
  80. package/dist/channels/push.js +0 -54
  81. package/dist/stack-item-renderer.d.ts +0 -103
  82. package/dist/stack.d.ts +0 -90
  83. package/dist/stack.js +0 -118
@@ -1,130 +1,54 @@
1
1
  /**
2
- * `props_update` channel handler — factored out of `runtime.ts` into
3
- * the `@ggui-ai/channel-client` layer.
2
+ * `props_update` channel handler — patches the currently-mounted
3
+ * render's props in place via the renderer's `applyRender`
4
+ * callback.
4
5
  *
5
- * Receives a `{stackItemId, props}` payload from the live channel, validates
6
- * the new props against the stack item's cached `propsSpec`, patches
7
- * the in-model entry, and re-applies the renderer so React picks up
8
- * the change.
6
+ * Post-stack-removal (2026-05-27): each iframe holds exactly one
7
+ * mounted render. The handler reads the current render via
8
+ * `getCurrentRender()`, validates the inbound props against its
9
+ * cached `propsSpec`, and re-applies the patched render through
10
+ * `applyRender` — the same callback the render handler uses, so the
11
+ * React update surface is unified.
9
12
  *
10
13
  * Skips when:
11
- * - `stackItemId` is empty or not a string.
14
+ * - `renderId` is empty or not a string.
12
15
  * - `props` is null / not an object (defensive — server can't emit
13
16
  * this shape, but the dispatcher routes the frame on type alone).
14
- * - No matching stack item exists (server raced ahead of our pop).
15
- * - The matched item is `mcpApps` / `system` (no `propsSpec`; server
16
- * should never emit `props_update` for these).
17
+ * - No render is currently mounted (`getCurrentRender` returns null).
18
+ * - The current render's id doesn't match `payload.renderId` —
19
+ * the server may have raced ahead of an in-flight render swap.
20
+ * - The current render is `mcpApps` / `system` (no `propsSpec`;
21
+ * server should never emit `props_update` for these).
17
22
  * - The new props fail validation against the cached spec.
18
23
  *
19
- * An OPTIONAL polling descriptor provides a fallback transport —
20
- * when `polling` is supplied at factory time, `PollingTransport`
21
- * polls `/api/bootstrap/<shortCode>` and synthesizes a frame
22
- * whenever `propsJson` diffs from the last seen hash. WS hosts are
23
- * unaffected (the `polling` descriptor is inert under `WSTransport`).
24
+ * R6 (2026-05-26) retired the per-handler polling descriptor. Polling
25
+ * is now registry-level — the iframe-runtime composes the
26
+ * `/api/renders/:id/state?wsToken=<token>` URL and a snapshot-parsing
27
+ * function once at bind time (see `runtime.ts`); a single fetch per
28
+ * tick projects the slice envelope into per-handler frames the
29
+ * `PollingTransport` dispatches.
24
30
  */
25
31
  import { validateInboundPropsPayload } from '../validation.js';
26
- const DEFAULT_POLLING_INTERVAL_MS = 2000;
27
- /**
28
- * Tiny FNV-1a 32-bit hash — stable, no protocol dep, ~5 LOC. We only
29
- * need diff detection (not collision resistance), so the trade-off is
30
- * fine. Returns a hex string for human-readable logs.
31
- */
32
- function fnv1a(input) {
33
- let hash = 0x811c9dc5;
34
- for (let i = 0; i < input.length; i += 1) {
35
- hash ^= input.charCodeAt(i);
36
- // 32-bit FNV prime multiplication via additive bit-shifts.
37
- hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
38
- }
39
- return hash.toString(16);
40
- }
41
32
  export function createPropsUpdateHandler(deps) {
42
- const polling = buildPollingDescriptor(deps);
43
33
  return {
44
34
  type: 'props_update',
45
35
  onMessage: async (payload) => {
46
- const { stackItemId, props } = payload;
47
- if (typeof stackItemId !== 'string' || stackItemId.length === 0)
36
+ const { renderId, props } = payload;
37
+ if (typeof renderId !== 'string' || renderId.length === 0)
48
38
  return;
49
39
  if (props === null || typeof props !== 'object')
50
40
  return;
51
- const target = deps.stackModel
52
- .snapshot()
53
- .find((item) => item.id === stackItemId);
54
- if (target === undefined)
41
+ const current = deps.getCurrentRender();
42
+ if (current === null)
43
+ return;
44
+ if (current.id !== renderId)
55
45
  return;
56
- if (target.type === 'mcpApps' || target.type === 'system')
46
+ if (current.type === 'mcpApps' || current.type === 'system')
57
47
  return;
58
- const result = validateInboundPropsPayload(target.propsSpec, props);
48
+ const result = validateInboundPropsPayload(current.propsSpec, props);
59
49
  if (!result.valid)
60
50
  return;
61
- const nextSnapshot = deps.stackModel
62
- .snapshot()
63
- .map((item) => {
64
- if (item.id !== stackItemId)
65
- return item;
66
- if (item.type === 'mcpApps' || item.type === 'system')
67
- return item;
68
- return { ...item, props };
69
- });
70
- deps.stackModel.setAll(nextSnapshot);
71
- await deps.getStackRenderer().applyStack(deps.stackModel.snapshot());
72
- },
73
- ...(polling !== undefined ? { polling } : {}),
74
- };
75
- }
76
- function buildPollingDescriptor(deps) {
77
- const { pollingUrl } = deps;
78
- if (typeof pollingUrl !== 'string' || pollingUrl.length === 0) {
79
- return undefined;
80
- }
81
- const url = pollingUrl;
82
- // Closure state — last-seen propsJson hash + last-seen stackItemId.
83
- // Diff detection: emit only on change. First-poll fires (lastSeenHash
84
- // is null initially) so the iframe gets the current props as its
85
- // starting state when WS is absent.
86
- let lastSeenHash = null;
87
- let lastSeenStackItemId = null;
88
- return {
89
- url,
90
- intervalMs: deps.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS,
91
- parse: (body) => {
92
- if (body === null || typeof body !== 'object')
93
- return null;
94
- const propsJson = body.propsJson;
95
- const bootstrapStackItemId = body
96
- .stackItemId;
97
- if (typeof propsJson !== 'string' ||
98
- typeof bootstrapStackItemId !== 'string' ||
99
- bootstrapStackItemId.length === 0) {
100
- return null;
101
- }
102
- const hash = fnv1a(propsJson);
103
- if (hash === lastSeenHash &&
104
- bootstrapStackItemId === lastSeenStackItemId) {
105
- return null; // Unchanged — skip dispatch.
106
- }
107
- lastSeenHash = hash;
108
- lastSeenStackItemId = bootstrapStackItemId;
109
- let parsedProps;
110
- try {
111
- parsedProps = JSON.parse(propsJson);
112
- }
113
- catch {
114
- return null;
115
- }
116
- if (parsedProps === null ||
117
- typeof parsedProps !== 'object' ||
118
- Array.isArray(parsedProps)) {
119
- return null;
120
- }
121
- // JSON.parse of an object literal yields a JsonObject by
122
- // construction — JSON only carries JsonValue-compatible
123
- // structure. Narrow to the protocol's typed shape.
124
- return {
125
- stackItemId: bootstrapStackItemId,
126
- props: parsedProps,
127
- };
51
+ await deps.applyRender({ ...current, props });
128
52
  },
129
53
  };
130
54
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `render` channel handler — folds an inbound `render` frame into the
3
+ * single-render mount slot and (when transport wiring is present)
4
+ * activates the per-channel transport router against the newly-
5
+ * mounted render.
6
+ *
7
+ * Post-render-identity-collapse (2026-05-27): each iframe holds
8
+ * exactly one mounted render for its lifetime. The handler routes the
9
+ * inbound `render` to a caller-supplied `applyRender` callback that
10
+ * either mounts (first frame) or re-applies (subsequent frames to the
11
+ * same id). Frames addressed to a different `renderId` are out-of-spec
12
+ * and drop with a console warning — the host spawns a fresh iframe
13
+ * per unique render id.
14
+ *
15
+ * Placeholder mode (boot.test.ts and the C7a placeholder-only spec)
16
+ * omits `applyRender` + `getChannelTransport`. The handler still fires
17
+ * for those callers — status log keeps firing — but no React mount
18
+ * happens. The boot-without-renderer path remains testable.
19
+ */
20
+ import type { ChannelHandler } from '@ggui-ai/live-channel';
21
+ import type { RenderPayload, Render } from '@ggui-ai/protocol';
22
+ import type { ChannelTransportRouter } from '../channel-transport.js';
23
+ import { type StatusRefs } from '../status-dom.js';
24
+ export interface RenderHandlerDeps {
25
+ /**
26
+ * Status DOM refs the connected-status log updates. Required —
27
+ * even renderer-mode consumers want a `[ggui:connected]` console
28
+ * log on every render frame for operator-visible boot debugging.
29
+ */
30
+ readonly statusRefs: StatusRefs;
31
+ /**
32
+ * Pin — when set, render frames with a different `renderId`
33
+ * are dropped with a console warning. Set by the renderer hook to
34
+ * the render id the iframe was bootstrapped against (every
35
+ * post-displayMode iframe is pinned to a single render).
36
+ */
37
+ readonly pinnedRenderId?: string;
38
+ /**
39
+ * Apply the inbound render to the single mount slot. Absent in
40
+ * placeholder mode (no React mount). When present, the handler
41
+ * `await`s the apply before activating the per-channel transport
42
+ * so the router targets the render React already mounted.
43
+ */
44
+ readonly applyRender?: (render: Render) => Promise<void>;
45
+ /**
46
+ * Renderer-mode hook — returns the per-channel transport router so
47
+ * the render handler can activate `source.tool` channel subscriptions
48
+ * against the newly-mounted render. Absent in placeholder-only mode.
49
+ */
50
+ readonly getChannelTransport?: () => ChannelTransportRouter;
51
+ }
52
+ export declare function createRenderHandler(deps: RenderHandlerDeps): ChannelHandler<RenderPayload>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `render` channel handler — folds an inbound `render` frame into the
3
+ * single-render mount slot and (when transport wiring is present)
4
+ * activates the per-channel transport router against the newly-
5
+ * mounted render.
6
+ *
7
+ * Post-render-identity-collapse (2026-05-27): each iframe holds
8
+ * exactly one mounted render for its lifetime. The handler routes the
9
+ * inbound `render` to a caller-supplied `applyRender` callback that
10
+ * either mounts (first frame) or re-applies (subsequent frames to the
11
+ * same id). Frames addressed to a different `renderId` are out-of-spec
12
+ * and drop with a console warning — the host spawns a fresh iframe
13
+ * per unique render id.
14
+ *
15
+ * Placeholder mode (boot.test.ts and the C7a placeholder-only spec)
16
+ * omits `applyRender` + `getChannelTransport`. The handler still fires
17
+ * for those callers — status log keeps firing — but no React mount
18
+ * happens. The boot-without-renderer path remains testable.
19
+ */
20
+ import { setConnectedStatus } from '../status-dom.js';
21
+ export function createRenderHandler(deps) {
22
+ return {
23
+ type: 'render',
24
+ onMessage: async (payload) => {
25
+ const render = payload.render;
26
+ if (deps.pinnedRenderId !== undefined && render.id !== deps.pinnedRenderId) {
27
+ // Out-of-spec: each iframe is pinned to exactly one render id
28
+ // post-displayMode-divergence; the host spawns a fresh iframe
29
+ // per render. A frame for some other id means the host wired
30
+ // the wrong render or the server is broadcasting cross-render
31
+ // frames.
32
+ // eslint-disable-next-line no-console
33
+ console.warn(`[ggui:render] ignoring render for ${render.id} — iframe pinned to ${deps.pinnedRenderId}`);
34
+ return;
35
+ }
36
+ if (deps.applyRender !== undefined) {
37
+ await deps.applyRender(render);
38
+ }
39
+ setConnectedStatus(deps.statusRefs);
40
+ const channelTransport = deps.getChannelTransport?.();
41
+ if (channelTransport === undefined)
42
+ return;
43
+ if (render.type === 'mcpApps' || render.type === 'system')
44
+ return;
45
+ channelTransport.applyRender({
46
+ renderId: render.id,
47
+ ...(render.streamSpec !== undefined
48
+ ? { streamSpec: render.streamSpec }
49
+ : {}),
50
+ });
51
+ },
52
+ };
53
+ }
@@ -17,7 +17,7 @@
17
17
  * it, so there's nothing actionable to surface.
18
18
  * - `onObserve` absent — the observation has nowhere to go.
19
19
  */
20
- import type { ChannelHandler } from '@ggui-ai/channel-client';
20
+ import type { ChannelHandler } from '@ggui-ai/live-channel';
21
21
  import type { SystemPayload } from '@ggui-ai/protocol';
22
22
  import type { ObservabilityEmitter } from '../observability.js';
23
23
  export interface SystemHandlerDeps {
@@ -38,3 +38,18 @@ export declare const EMPTY_COMPILED_VALIDATOR_SET: CompiledValidatorSet;
38
38
  * bootstrap carries no `compiledValidators`.
39
39
  */
40
40
  export declare function loadCompiledValidators(meta: CompiledContractValidators | undefined, warn?: (message: string, detail: unknown) => void): Promise<CompiledValidatorSet>;
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}
45
+ * when `url` is undefined OR the fetch/import fails — sidecar
46
+ * semantics, the server-side `assertActionContract` gate remains
47
+ * authoritative on validator absence.
48
+ *
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 pushes with the same contract hit browser cache with no
53
+ * round-trip.
54
+ */
55
+ export declare function loadCompiledValidatorsFromUrl(url: string | undefined, warn?: (message: string, detail: unknown) => void): Promise<CompiledValidatorSet>;
@@ -72,3 +72,34 @@ export async function loadCompiledValidators(meta, warn) {
72
72
  context,
73
73
  };
74
74
  }
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}
79
+ * when `url` is undefined OR the fetch/import fails — sidecar
80
+ * semantics, the server-side `assertActionContract` gate remains
81
+ * authoritative on validator absence.
82
+ *
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 pushes with the same contract hit browser cache with no
87
+ * round-trip.
88
+ */
89
+ export async function loadCompiledValidatorsFromUrl(url, warn) {
90
+ if (url === undefined)
91
+ return EMPTY_COMPILED_VALIDATOR_SET;
92
+ try {
93
+ const mod = (await import(/* @vite-ignore */ url));
94
+ const compiled = mod.default;
95
+ if (compiled === null || typeof compiled !== 'object') {
96
+ warn?.('[ggui:validators] contract bundle: default export is not an object', compiled);
97
+ return EMPTY_COMPILED_VALIDATOR_SET;
98
+ }
99
+ return loadCompiledValidators(compiled, warn);
100
+ }
101
+ catch (err) {
102
+ warn?.(`[ggui:validators] contract bundle: failed to load ${url}`, err);
103
+ return EMPTY_COMPILED_VALIDATOR_SET;
104
+ }
105
+ }
@@ -47,7 +47,7 @@ import type { JsonSchema } from '@ggui-ai/protocol';
47
47
  import type { GguiContextRegistry } from './globals.js';
48
48
  /**
49
49
  * Wire-shape entry for a single contextSpec slot, mirrored from
50
- * {@link import('@ggui-ai/protocol/integrations/mcp-apps').GguiBootstrapMeta.contextSlots}.
50
+ * {@link import('@ggui-ai/protocol/integrations/mcp-apps').McpAppAiGguiRenderMeta.contextSlots}.
51
51
  */
52
52
  export interface ContextSlotInfo {
53
53
  readonly name: string;
@@ -110,14 +110,74 @@ export declare function installContextRegistry(registry: GguiContextRegistry, re
110
110
  * Identity bundle carried alongside every snapshot post. The runtime
111
111
  * captures these from the bootstrap envelope at boot and threads
112
112
  * them through `createContextStateHost` so each snapshot's two
113
- * destinations (host + server) carry the same authoritative session
113
+ * destinations (host + server) carry the same authoritative render
114
114
  * binding. Absent on dev / test code paths that don't have a real
115
115
  * bootstrap; the server mirror is skipped in that case.
116
+ *
117
+ * Post-render-identity-collapse (2026-05-27): the previous
118
+ * `{sessionId, appId, stackItemId}` tuple collapsed to
119
+ * `{renderId, appId}` — render is the single identity key.
116
120
  */
117
121
  export interface ContextPostIdentity {
118
- readonly sessionId: string;
122
+ readonly renderId: string;
119
123
  readonly appId: string;
120
- readonly stackItemId: string;
124
+ }
125
+ /**
126
+ * Snapshot poster contract — fans the same snapshot out to two
127
+ * destinations:
128
+ *
129
+ * 1. {@link postUpdateModelContext} — spec-canonical
130
+ * `ui/update-model-context` notification. Production wires this
131
+ * to `app.updateModelContext(...)`; tests inject a spy.
132
+ * 2. {@link postContextMirror} — server mirror via the
133
+ * `ggui_runtime_sync_context` tools/call. Production wires this
134
+ * to a raw `postToParent` envelope (the existing host-relay
135
+ * path); tests inject a spy.
136
+ *
137
+ * Splitting the seam keeps spec-canonical MCP-Apps notifications
138
+ * (Destination 1) on the App method surface while leaving the
139
+ * non-spec `tools/call` server mirror (Destination 2) on the raw
140
+ * postMessage channel — matches the broader Phase 1.19b.3 followup
141
+ * scope (notifications via App; tools/call audit stays on raw post
142
+ * until a separate cleanup migrates it).
143
+ */
144
+ /**
145
+ * Params shape accepted by the spec-canonical
146
+ * `app.updateModelContext` method — re-imported from
147
+ * `@modelcontextprotocol/ext-apps` via `Parameters<App['updateModelContext']>`
148
+ * in the runtime so the production poster can forward verbatim
149
+ * without a type cast.
150
+ *
151
+ * Tests building a recording {@link ContextSnapshotPoster} construct
152
+ * a `{ content: [{type: 'text', text}] }` payload — the only shape
153
+ * the iframe-runtime emits today.
154
+ */
155
+ export interface UpdateModelContextParams {
156
+ content?: Array<{
157
+ type: 'text';
158
+ text: string;
159
+ }>;
160
+ structuredContent?: Record<string, unknown>;
161
+ }
162
+ export interface ContextSnapshotPoster {
163
+ /**
164
+ * Fire the spec-canonical `ui/update-model-context` notification
165
+ * carrying the prose snapshot text. Production: routes through
166
+ * `app.updateModelContext` (no-op when no App is bound).
167
+ */
168
+ readonly postUpdateModelContext: (params: UpdateModelContextParams) => void;
169
+ /**
170
+ * Fire the `ggui_runtime_sync_context` tools/call server mirror.
171
+ * Production: raw postMessage envelope (the host relays to the
172
+ * MCP server). Caller is responsible for honoring `identity`
173
+ * presence — pass an undefined-returning closure to disable the
174
+ * mirror.
175
+ */
176
+ readonly postContextMirror: (params: {
177
+ readonly renderId: string;
178
+ readonly appId: string;
179
+ readonly snapshot: Record<string, unknown>;
180
+ }) => void;
121
181
  }
122
182
  /**
123
183
  * React surface the host factories need. Production callers pass
@@ -133,7 +193,7 @@ export type ReactCoreForHost = Pick<typeof import('react'), 'createElement' | 'F
133
193
  * these per declared slot (via {@link createContextStateHost}).
134
194
  *
135
195
  * Built as a factory so the runtime can pass dependencies (`react`,
136
- * `postToParent`, `consoleWarn`) without re-importing in this module
196
+ * `poster`, `consoleWarn`) without re-importing in this module
137
197
  * — keeps the file pure and unit-testable.
138
198
  *
139
199
  * Exported because `__tests__/context-observer.test.ts` mounts it
@@ -141,7 +201,7 @@ export type ReactCoreForHost = Pick<typeof import('react'), 'createElement' | 'F
141
201
  */
142
202
  export declare function createSingleSlotProvider(deps: {
143
203
  readonly react: ReactCoreForHost;
144
- readonly postToParent: (envelope: unknown) => void;
204
+ readonly poster: ContextSnapshotPoster;
145
205
  readonly consoleWarn?: (...args: unknown[]) => void;
146
206
  /** Identity bundle threaded into every snapshot post — server
147
207
  * mirror skipped when undefined. */
@@ -174,7 +234,7 @@ export declare function createSingleSlotProvider(deps: {
174
234
  */
175
235
  export declare function createContextStateHost(deps: {
176
236
  readonly react: ReactCoreForHost;
177
- readonly postToParent: (envelope: unknown) => void;
237
+ readonly poster: ContextSnapshotPoster;
178
238
  readonly consoleWarn?: (...args: unknown[]) => void;
179
239
  /** Identity bundle for the server-mirror destination. Threaded
180
240
  * unchanged into `createSingleSlotProvider` so every slot's debounced
@@ -202,4 +262,4 @@ export declare function createContextStateHost(deps: {
202
262
  * bare module). Runtime call sites pass the active slots so the leak
203
263
  * path is closed by construction.
204
264
  */
205
- export declare function reemitLastContextValues(postToParent: (envelope: unknown) => void, activeSlotNames?: ReadonlySet<string>, identity?: ContextPostIdentity): void;
265
+ export declare function reemitLastContextValues(poster: ContextSnapshotPoster, activeSlotNames?: ReadonlySet<string>, identity?: ContextPostIdentity): void;
@@ -82,64 +82,44 @@ function specFor(slot) {
82
82
  * — the existing path; agent's LLM reads these via
83
83
  * `read_widget_context` for in-turn awareness.
84
84
  * 2. `tools/call` for `ggui_runtime_sync_context` — the server mirror.
85
- * Server stores the snapshot on the active StackItem; chat-
85
+ * Server stores the snapshot on the active Render; chat-
86
86
  * history rehydrate seeds `contextSlots[i].default` with the
87
87
  * snapshotted values, restoring the user's last-known state.
88
88
  *
89
- * Both posts are postMessage to the parent — fire-and-forget,
90
- * non-blocking, naturally concurrent (queued back-to-back to the
91
- * postMessage channel; the host receives them in order without
92
- * blocking either delivery on the other). No explicit `Promise.all`
93
- * is needed: postMessage isn't a promise-returning API; both calls
94
- * return synchronously and the channel handles the ordering. This
95
- * is the right primitive for a snapshot mirror — neither destination
96
- * needs to wait for the other to ACK.
89
+ * Both destinations are fire-and-forget; the channel queues them
90
+ * back-to-back, no sequential dependency between them. This is the
91
+ * right primitive for a snapshot mirror — neither destination needs
92
+ * to wait for the other to ACK.
97
93
  *
98
94
  * Snapshot replaces an earlier per-slot delta format
99
95
  * (`[ggui:context-slot] {slot,value}`). Empirical: claude.ai's host
100
96
  * treats each `ui/update-model-context` post as a REPLACE of the
101
97
  * widget's tracked context, not a per-slot merge. Server mirror
102
- * matches: REPLACE-per-(sessionId, stackItemId), last-write-wins.
103
- * Both destinations therefore stay structurally consistent.
98
+ * matches: REPLACE-per-renderId, last-write-wins. Both destinations
99
+ * therefore stay structurally consistent.
104
100
  *
105
101
  * Server-mirror skipped when `identity` is undefined — dev/test code
106
102
  * paths that don't supply a real bootstrap shouldn't pollute the
107
103
  * server's store with synthetic ids.
108
104
  */
109
- function postContextSnapshot(postToParent, snapshot, identity) {
105
+ function postContextSnapshot(poster, snapshot, identity) {
110
106
  // Destination 1: host. Always fires.
111
107
  const text = `[ggui:context] ${JSON.stringify(snapshot)}`;
112
- postToParent({
113
- jsonrpc: '2.0',
114
- id: Math.floor(Math.random() * 1e9),
115
- method: 'ui/update-model-context',
116
- params: {
117
- content: [
118
- {
119
- type: 'text',
120
- text,
121
- },
122
- ],
123
- },
108
+ poster.postUpdateModelContext({
109
+ content: [
110
+ {
111
+ type: 'text',
112
+ text,
113
+ },
114
+ ],
124
115
  });
125
116
  // Destination 2: server mirror. Skipped when identity is absent
126
- // (dev / test paths without a real bootstrap). Both destinations
127
- // are postMessage — the channel queues these back-to-back, no
128
- // sequential dependency between them.
117
+ // (dev / test paths without a real bootstrap).
129
118
  if (identity) {
130
- postToParent({
131
- jsonrpc: '2.0',
132
- id: Math.floor(Math.random() * 1e9),
133
- method: 'tools/call',
134
- params: {
135
- name: 'ggui_runtime_sync_context',
136
- arguments: {
137
- sessionId: identity.sessionId,
138
- appId: identity.appId,
139
- stackItemId: identity.stackItemId,
140
- snapshot,
141
- },
142
- },
119
+ poster.postContextMirror({
120
+ renderId: identity.renderId,
121
+ appId: identity.appId,
122
+ snapshot,
143
123
  });
144
124
  }
145
125
  }
@@ -163,14 +143,14 @@ function buildSnapshot() {
163
143
  * these per declared slot (via {@link createContextStateHost}).
164
144
  *
165
145
  * Built as a factory so the runtime can pass dependencies (`react`,
166
- * `postToParent`, `consoleWarn`) without re-importing in this module
146
+ * `poster`, `consoleWarn`) without re-importing in this module
167
147
  * — keeps the file pure and unit-testable.
168
148
  *
169
149
  * Exported because `__tests__/context-observer.test.ts` mounts it
170
150
  * standalone to verify the value→post→Provider cycle.
171
151
  */
172
152
  export function createSingleSlotProvider(deps) {
173
- const { react, postToParent, consoleWarn, identity } = deps;
153
+ const { react, poster, consoleWarn, identity } = deps;
174
154
  const SingleSlotProvider = (props) => {
175
155
  const { slot, children } = props;
176
156
  // Slot-owned state. Re-mounts re-seed from `slot.default` because
@@ -204,7 +184,7 @@ export function createSingleSlotProvider(deps) {
204
184
  // treats `ui/update-model-context` as REPLACE, so deltas
205
185
  // wiped the un-mentioned slots).
206
186
  contextSlotLastValues.set(slot.name, value);
207
- postContextSnapshot(postToParent, buildSnapshot(), identity);
187
+ postContextSnapshot(poster, buildSnapshot(), identity);
208
188
  };
209
189
  if (slot.debounceMs <= 0) {
210
190
  // Immediate path — tests + step / tab switches use this.
@@ -292,7 +272,7 @@ export function createContextStateHost(deps) {
292
272
  * bare module). Runtime call sites pass the active slots so the leak
293
273
  * path is closed by construction.
294
274
  */
295
- export function reemitLastContextValues(postToParent, activeSlotNames, identity) {
275
+ export function reemitLastContextValues(poster, activeSlotNames, identity) {
296
276
  if (activeSlotNames !== undefined) {
297
277
  // Drain stale keys before re-emitting. Keep the map a faithful
298
278
  // reflection of the active mount.
@@ -307,6 +287,6 @@ export function reemitLastContextValues(postToParent, activeSlotNames, identity)
307
287
  // delta on the host side; the host ended up with only the
308
288
  // LAST-fired slot. One snapshot delivers all slots atomically.
309
289
  if (contextSlotLastValues.size > 0) {
310
- postContextSnapshot(postToParent, buildSnapshot(), identity);
290
+ postContextSnapshot(poster, buildSnapshot(), identity);
311
291
  }
312
292
  }