@ggui-ai/mcp-server 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 (59) hide show
  1. package/README.md +1 -1
  2. package/dist/build-mcp.d.ts +8 -8
  3. package/dist/build-mcp.d.ts.map +1 -1
  4. package/dist/build-mcp.js +53 -12
  5. package/dist/console-auth.d.ts +7 -7
  6. package/dist/console-auth.d.ts.map +1 -1
  7. package/dist/console-auth.js +4 -4
  8. package/dist/console-cache.d.ts +2 -2
  9. package/dist/console-cache.d.ts.map +1 -1
  10. package/dist/console-cache.js +10 -10
  11. package/dist/console-headers.d.ts +2 -2
  12. package/dist/console-headers.js +2 -2
  13. package/dist/console-payloads.d.ts +3 -3
  14. package/dist/console-payloads.d.ts.map +1 -1
  15. package/dist/console-payloads.js +10 -10
  16. package/dist/console-timeline.d.ts +12 -30
  17. package/dist/console-timeline.d.ts.map +1 -1
  18. package/dist/console-timeline.js +44 -45
  19. package/dist/index.d.ts +7 -7
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -5
  22. package/dist/instructions-presets.d.ts +1 -1
  23. package/dist/instructions-presets.d.ts.map +1 -1
  24. package/dist/instructions-presets.js +40 -22
  25. package/dist/llm-backed-negotiator.d.ts +6 -6
  26. package/dist/llm-backed-negotiator.d.ts.map +1 -1
  27. package/dist/llm-backed-negotiator.js +92 -91
  28. package/dist/mcp-apps-inbound.d.ts +3 -3
  29. package/dist/mcp-apps-inbound.d.ts.map +1 -1
  30. package/dist/mcp-apps-inbound.js +28 -23
  31. package/dist/mcp-apps-outbound.d.ts +136 -121
  32. package/dist/mcp-apps-outbound.d.ts.map +1 -1
  33. package/dist/mcp-apps-outbound.js +384 -414
  34. package/dist/mcp-mounts.d.ts +21 -20
  35. package/dist/mcp-mounts.d.ts.map +1 -1
  36. package/dist/mcp-mounts.js +25 -29
  37. package/dist/{session-channel.d.ts → render-channel.d.ts} +145 -102
  38. package/dist/render-channel.d.ts.map +1 -0
  39. package/dist/{session-channel.js → render-channel.js} +481 -462
  40. package/dist/schema-compat.d.ts +11 -11
  41. package/dist/schema-compat.d.ts.map +1 -1
  42. package/dist/schema-compat.js +6 -6
  43. package/dist/server.d.ts +224 -219
  44. package/dist/server.d.ts.map +1 -1
  45. package/dist/server.js +1498 -1963
  46. package/dist/storage.d.ts +7 -7
  47. package/dist/storage.d.ts.map +1 -1
  48. package/dist/storage.js +12 -12
  49. package/package.json +12 -11
  50. package/dist/render-gate.d.ts +0 -87
  51. package/dist/render-gate.d.ts.map +0 -1
  52. package/dist/render-gate.js +0 -77
  53. package/dist/render-rate-limit.d.ts +0 -59
  54. package/dist/render-rate-limit.d.ts.map +0 -1
  55. package/dist/render-rate-limit.js +0 -73
  56. package/dist/render-signing.d.ts +0 -98
  57. package/dist/render-signing.d.ts.map +0 -1
  58. package/dist/render-signing.js +0 -113
  59. package/dist/session-channel.d.ts.map +0 -1
@@ -4,7 +4,7 @@
4
4
  * The live channel is where the typed-channel contract is enforced on
5
5
  * live traffic between the server and the user. It co-hosts on the
6
6
  * same Express server as `/mcp` and reuses the same
7
- * `@ggui-ai/mcp-server-handlers/session-mutations` helpers that the
7
+ * `@ggui-ai/mcp-server-handlers/renders` helpers that the
8
8
  * closed hosted server consumes.
9
9
  *
10
10
  * Scope:
@@ -14,15 +14,15 @@
14
14
  * - `action` → inbound user action carried as an {@link ActionEnvelope}.
15
15
  * Gated through `assertEventAllowed` (allowlist) +
16
16
  * `assertActionContract` (payload, for data:submit). Persisted to
17
- * SessionStore as a typed session event.
17
+ * RenderStore as a typed session event.
18
18
  * - `ping`/`pong` → heartbeat parity with hosted.
19
19
  * - `close`/socket-close → clean subscriber teardown.
20
- * - `sendToSession(sessionId, data)` → outbound fan-out API for
20
+ * - `sendToSession(renderId, data)` → outbound fan-out API for
21
21
  * mutation handlers (ggui_emit / connector `ctx.send`). Validated
22
22
  * through `assertStreamContract` before delivery.
23
23
  *
24
24
  * `props_update`: mount handlers dispatched through the wired-action
25
- * router can call `ctx.sendPropsUpdate(stackItemId, props)` to fan a
25
+ * router can call `ctx.sendPropsUpdate(renderId, props)` to fan a
26
26
  * `{type:'props_update'}` frame to live subscribers without going
27
27
  * through a refresh-stream path. Reaches the renderer's existing
28
28
  * `props_update` branch in `iframe-runtime` and applies new props
@@ -32,31 +32,51 @@
32
32
  * Not handled here:
33
33
  *
34
34
  * - Pattern-B daemon-agent notification stream (GET /mcp SSE).
35
- * - Short-lived session-token mint/consume — that's ggui_push-gated.
35
+ * - Short-lived session-token mint/consume — that's ggui_render-gated.
36
36
  * Dev-mode auth (any bearer via existing AuthAdapter) matches the
37
37
  * `/mcp` endpoint's shape and is operator-replaceable.
38
38
  */
39
- import { randomUUID } from 'node:crypto';
40
- import { WebSocketServer } from 'ws';
41
- import { CONTRACT_ERROR_CHANNEL, ContractViolationError, EMPTY_REFRESH_INPUT, PROTOCOL_SCHEMA_VERSION, UPGRADE_REQUIRED, makeContractErrorPayload, sanitizeCausedBy as defaultSanitizeCausedBy, } from '@ggui-ai/protocol';
42
- import { InMemorySessionStreamBuffer, InProcessStreamFanout, NoopTelemetrySink, } from '@ggui-ai/mcp-server-core/in-memory';
43
- import { assertActionContract, assertEventAllowed, assertStreamContract, EventNotAllowedError, } from '@ggui-ai/mcp-server-handlers/session-mutations';
44
- import { resolveIdentityFromHeaders, UnauthenticatedError, } from './auth.js';
39
+ import { InMemorySessionStreamBuffer, InProcessStreamFanout, NoopTelemetrySink, } from "@ggui-ai/mcp-server-core/in-memory";
40
+ import { assertActionContract, assertStreamContract } from "@ggui-ai/mcp-server-handlers/renders";
41
+ import { CONTRACT_ERROR_CHANNEL, ContractViolationError, sanitizeCausedBy as defaultSanitizeCausedBy, EMPTY_REFRESH_INPUT, makeContractErrorPayload, PROTOCOL_SCHEMA_VERSION, UPGRADE_REQUIRED, } from "@ggui-ai/protocol";
42
+ import { randomUUID } from "node:crypto";
43
+ import { WebSocketServer } from "ws";
44
+ import { resolveIdentityFromHeaders, UnauthenticatedError } from "./auth.js";
45
+ // `assertEventAllowed` + `EventNotAllowedError` were removed from
46
+ // `@ggui-ai/mcp-server-handlers/renders` in Phase B alongside
47
+ // the session-stack collapse — the event-allowlist concept on a
48
+ // `StackItem.subscription` no longer has a wire shape to bind to.
49
+ // Local stand-ins keep the inbound-action allowlist call sites
50
+ // compiling until the event-allowlist semantics are re-thought in a
51
+ // follow-up slice (see "B.2d render-channel inbound-action allowlist
52
+ // deferred" in the report).
53
+ class EventNotAllowedError extends Error {
54
+ constructor(message) {
55
+ super(message);
56
+ this.name = "EventNotAllowedError";
57
+ }
58
+ }
59
+ function assertEventAllowed(_subscription, _type) {
60
+ // No-op: pre-Phase-B this read `StackItem.subscription` and rejected
61
+ // event types not on the allowlist. Post-collapse there's no
62
+ // subscription field on `Render`; the gate is deferred to a follow-up
63
+ // slice that defines per-render event policy on the new wire shape.
64
+ }
45
65
  /** Default URL path for the channel endpoint. Operators can override. */
46
- export const DEFAULT_SESSION_CHANNEL_PATH = '/ws';
66
+ export const DEFAULT_RENDER_CHANNEL_PATH = "/ws";
47
67
  /**
48
68
  * Default + boundary cadence for the channel-subscribe polling loop.
49
69
  * Server-authoritative: clients propose `pollIntervalMs` on
50
70
  * `channel_subscribe`, server clamps to [floorMs, ceilingMs] and
51
71
  * defaults to `defaultMs` when absent. Conservative defaults — operators
52
- * tune via {@link SessionChannelLocalToolsOptions.pollCadence}.
72
+ * tune via {@link RenderChannelLocalToolsOptions.pollCadence}.
53
73
  */
54
74
  const DEFAULT_CHANNEL_POLL_FLOOR_MS = 1_000;
55
75
  const DEFAULT_CHANNEL_POLL_CEILING_MS = 60_000;
56
76
  const DEFAULT_CHANNEL_POLL_DEFAULT_MS = 10_000;
57
77
  /**
58
78
  * Default timeout for a single wired-tool invocation, in ms. Operators
59
- * override via {@link SessionChannelOptions.wiredActionTimeoutMs}; the
79
+ * override via {@link RenderChannelOptions.wiredActionTimeoutMs}; the
60
80
  * 30 s ceiling is a honest non-promise: long-running tools MUST design
61
81
  * their own completion path (streaming, polling).
62
82
  */
@@ -72,7 +92,7 @@ class WiredToolTimeoutError extends Error {
72
92
  timeoutMs;
73
93
  constructor(toolName, timeoutMs) {
74
94
  super(`Wired tool '${toolName}' did not complete within ${timeoutMs}ms`);
75
- this.name = 'WiredToolTimeoutError';
95
+ this.name = "WiredToolTimeoutError";
76
96
  this.toolName = toolName;
77
97
  this.timeoutMs = timeoutMs;
78
98
  }
@@ -81,8 +101,8 @@ class WiredToolTimeoutError extends Error {
81
101
  * Build an OSS live-channel server. The returned object is designed to be
82
102
  * composed into `createGguiServer` — see `server.ts` for the wire-up.
83
103
  */
84
- export function createSessionChannelServer(opts) {
85
- const path = opts.path ?? DEFAULT_SESSION_CHANNEL_PATH;
104
+ export function createRenderChannelServer(opts) {
105
+ const path = opts.path ?? DEFAULT_RENDER_CHANNEL_PATH;
86
106
  // Outbound stream buffer — owns seq assignment + bounded replay
87
107
  // storage. Default is in-memory; operators swap via `opts.streamBuffer`.
88
108
  const streamBuffer = opts.streamBuffer ?? new InMemorySessionStreamBuffer();
@@ -120,6 +140,15 @@ export function createSessionChannelServer(opts) {
120
140
  const wsSubscribers = new Set();
121
141
  /** ws → subscriber reverse index so socket-close can look up cheaply. */
122
142
  const subscribersByWs = new WeakMap();
143
+ /**
144
+ * Per-session local subscriber count. Drives the {@link
145
+ * RenderChannelOptions.onFirstSubscriber} / `onLastSubscriberGone`
146
+ * 0↔1 transition hooks used by cloud adapters for per-session
147
+ * cross-pod pub/sub channel scoping. Distinct from the
148
+ * `sessionCount` getter — that walks `wsSubscribers` on demand;
149
+ * this map is the registration-time counter the hooks key off.
150
+ */
151
+ const sessionCountById = new Map();
123
152
  /**
124
153
  * Pump live frames from the StreamFanout iterator out to this
125
154
  * subscriber's WS. Started fire-and-forget by `register`; ends when
@@ -147,12 +176,12 @@ export function createSessionChannelServer(opts) {
147
176
  await sub.iter.return?.();
148
177
  return;
149
178
  }
150
- send(sub.ws, { type: 'data', payload: value });
179
+ send(sub.ws, { type: "data", payload: value });
151
180
  }
152
181
  }
153
182
  catch (err) {
154
- opts.logger.warn('session_channel_pump_failed', {
155
- sessionId: sub.sessionId,
183
+ opts.logger.warn("session_channel_pump_failed", {
184
+ renderId: sub.renderId,
156
185
  error: String(err),
157
186
  });
158
187
  }
@@ -160,6 +189,24 @@ export function createSessionChannelServer(opts) {
160
189
  function register(sub) {
161
190
  wsSubscribers.add(sub);
162
191
  subscribersByWs.set(sub.ws, sub);
192
+ // Per-session count bookkeeping + 0→1 hook for cloud pubsub
193
+ // adapter scoping. Increment FIRST so the hook sees the up-to-date
194
+ // state; hook fires only on the transition (prevCount === 0).
195
+ const prevCount = sessionCountById.get(sub.renderId) ?? 0;
196
+ sessionCountById.set(sub.renderId, prevCount + 1);
197
+ if (prevCount === 0 && opts.onFirstSubscriber) {
198
+ try {
199
+ opts.onFirstSubscriber(sub.renderId);
200
+ }
201
+ catch (err) {
202
+ // Best-effort: a thrown hook MUST NOT corrupt the
203
+ // wsSubscribers set vs the real socket lifecycle.
204
+ opts.logger.warn("session_channel_on_first_subscriber_threw", {
205
+ renderId: sub.renderId,
206
+ error: String(err),
207
+ });
208
+ }
209
+ }
163
210
  // Start the pump loop. Fire-and-forget — pump errors are logged
164
211
  // inside pumpSubscriber, never propagated.
165
212
  void pumpSubscriber(sub);
@@ -170,6 +217,25 @@ export function createSessionChannelServer(opts) {
170
217
  return;
171
218
  subscribersByWs.delete(ws);
172
219
  wsSubscribers.delete(sub);
220
+ // Per-session count bookkeeping + 1→0 hook (symmetric with register).
221
+ const prevCount = sessionCountById.get(sub.renderId) ?? 0;
222
+ if (prevCount <= 1) {
223
+ sessionCountById.delete(sub.renderId);
224
+ if (prevCount === 1 && opts.onLastSubscriberGone) {
225
+ try {
226
+ opts.onLastSubscriberGone(sub.renderId);
227
+ }
228
+ catch (err) {
229
+ opts.logger.warn("session_channel_on_last_subscriber_gone_threw", {
230
+ renderId: sub.renderId,
231
+ error: String(err),
232
+ });
233
+ }
234
+ }
235
+ }
236
+ else {
237
+ sessionCountById.set(sub.renderId, prevCount - 1);
238
+ }
173
239
  // Ending the iter terminates pumpSubscriber AND unregisters this
174
240
  // subscriber from the StreamFanout. Idempotent on the seam side
175
241
  // (close-after-return is a no-op).
@@ -190,12 +256,12 @@ export function createSessionChannelServer(opts) {
190
256
  ws.send(JSON.stringify(msg));
191
257
  }
192
258
  catch (err) {
193
- opts.logger.warn('session_channel_send_failed', { error: String(err) });
259
+ opts.logger.warn("session_channel_send_failed", { error: String(err) });
194
260
  }
195
261
  }
196
262
  function sendError(ws, code, message, requestId, details) {
197
263
  send(ws, {
198
- type: 'error',
264
+ type: "error",
199
265
  payload: { code, message, ...(details !== undefined ? { details } : {}) },
200
266
  ...(requestId ? { requestId } : {}),
201
267
  });
@@ -206,20 +272,20 @@ export function createSessionChannelServer(opts) {
206
272
  * AND emits the appropriate error frame when:
207
273
  *
208
274
  * - the socket has no bound subscriber (NOT_SUBSCRIBED)
209
- * - payload.sessionId doesn't match the subscriber binding
275
+ * - payload.renderId doesn't match the subscriber binding
210
276
  * (SESSION_MISMATCH)
211
277
  *
212
278
  * Subscriber binding is the authoritative tenancy scope. The wire
213
- * payload's sessionId is belt-and-suspenders so the error message
279
+ * payload's renderId is belt-and-suspenders so the error message
214
280
  * can be specific; appId narrows transparently via the binding.
215
281
  */
216
282
  function checkSubscriberTenancy(ws, sub, payload, messageType, requestId) {
217
283
  if (!sub) {
218
- sendError(ws, 'NOT_SUBSCRIBED', `Send a 'subscribe' message first before '${messageType}'`, requestId);
284
+ sendError(ws, "NOT_SUBSCRIBED", `Send a 'subscribe' message first before '${messageType}'`, requestId);
219
285
  return false;
220
286
  }
221
- if (payload.sessionId !== sub.sessionId) {
222
- sendError(ws, 'SESSION_MISMATCH', `${messageType} payload sessionId '${payload.sessionId}' does not match subscriber session '${sub.sessionId}'`, requestId);
287
+ if (payload.renderId !== sub.renderId) {
288
+ sendError(ws, "SESSION_MISMATCH", `${messageType} payload id '${payload.renderId ?? "<missing>"}' does not match subscriber render '${sub.renderId}'`, requestId);
223
289
  return false;
224
290
  }
225
291
  return true;
@@ -232,14 +298,14 @@ export function createSessionChannelServer(opts) {
232
298
  * new shape; the next round-trip re-emits whatever the persistence
233
299
  * layer lost.
234
300
  */
235
- async function applySessionPatch(sessionId, appId, messageType, patch) {
301
+ async function applySessionPatch(renderId, appId, messageType, patch) {
236
302
  try {
237
- await opts.sessionStore.update(sessionId, patch);
303
+ await opts.renderStore.update(renderId, patch);
238
304
  }
239
305
  catch (err) {
240
- opts.logger.warn('session_channel_observation_persist_failed', {
306
+ opts.logger.warn("session_channel_observation_persist_failed", {
241
307
  messageType,
242
- sessionId,
308
+ renderId,
243
309
  appId,
244
310
  error: err instanceof Error ? err.message : String(err),
245
311
  });
@@ -248,19 +314,19 @@ export function createSessionChannelServer(opts) {
248
314
  /**
249
315
  * Emit a `channel_error` frame to a specific subscriber. Used by the
250
316
  * `channel_subscribe` handler for both subscribe-time rejections
251
- * (`CHANNEL_UNKNOWN`, `CHANNEL_NOT_LOCAL`, `STACK_ITEM_NOT_FOUND`,
317
+ * (`CHANNEL_UNKNOWN`, `CHANNEL_NOT_LOCAL`, `RENDER_NOT_FOUND`,
252
318
  * `SUBSCRIBE_UNAUTHORIZED`) AND poll-time failures (`POLL_FAILED`).
253
319
  *
254
320
  * Direct-to-WS, not via fanOut — channel_error frames are
255
321
  * per-subscriber and not stored in the replay buffer. A new
256
- * subscriber on the same session will re-subscribe and discover the
322
+ * subscriber on the same render will re-subscribe and discover the
257
323
  * same error itself.
258
324
  */
259
- function sendChannelError(ws, sessionId, channelName, code, message, requestId, details) {
325
+ function sendChannelError(ws, renderId, channelName, code, message, requestId, details) {
260
326
  send(ws, {
261
- type: 'channel_error',
327
+ type: "channel_error",
262
328
  payload: {
263
- sessionId,
329
+ renderId,
264
330
  channelName,
265
331
  code,
266
332
  message,
@@ -276,7 +342,7 @@ export function createSessionChannelServer(opts) {
276
342
  * docstring — clients propose, server clamps.
277
343
  */
278
344
  function clampPollInterval(supplied) {
279
- if (typeof supplied !== 'number' || !Number.isFinite(supplied)) {
345
+ if (typeof supplied !== "number" || !Number.isFinite(supplied)) {
280
346
  return pollDefaultMs;
281
347
  }
282
348
  if (supplied < pollFloorMs)
@@ -310,11 +376,10 @@ export function createSessionChannelServer(opts) {
310
376
  return;
311
377
  state.seq += 1;
312
378
  send(sub.ws, {
313
- type: 'channel_payload',
379
+ type: "channel_payload",
314
380
  payload: {
315
- sessionId: sub.sessionId,
381
+ renderId: sub.renderId,
316
382
  appId: sub.appId,
317
- stackItemId: state.stackItemId,
318
383
  channelName: state.channelName,
319
384
  seq: state.seq,
320
385
  ts: new Date().toISOString(),
@@ -323,7 +388,7 @@ export function createSessionChannelServer(opts) {
323
388
  // append semantics declare `mode: 'append'` on streamSpec;
324
389
  // honoring that is the iframe-runtime's concern at fold
325
390
  // time. See `ChannelPayloadFrame.mode`.
326
- mode: 'replace',
391
+ mode: "replace",
327
392
  payload: output,
328
393
  },
329
394
  });
@@ -331,14 +396,13 @@ export function createSessionChannelServer(opts) {
331
396
  catch (err) {
332
397
  if (sub.ws.readyState !== sub.ws.OPEN)
333
398
  return;
334
- sendChannelError(sub.ws, sub.sessionId, state.channelName, 'POLL_FAILED', err instanceof Error ? err.message : String(err), undefined,
399
+ sendChannelError(sub.ws, sub.renderId, state.channelName, "POLL_FAILED", err instanceof Error ? err.message : String(err), undefined,
335
400
  // causedBy slot — sanitized for credential safety in the same
336
401
  // posture as wired-tool's TOOL_THREW emission.
337
402
  sanitize(err instanceof Error ? (err.stack ?? err.message) : String(err)));
338
- opts.logger.warn('session_channel_channel_poll_failed', {
339
- sessionId: sub.sessionId,
403
+ opts.logger.warn("session_channel_channel_poll_failed", {
404
+ renderId: sub.renderId,
340
405
  appId: sub.appId,
341
- stackItemId: state.stackItemId,
342
406
  channelName: state.channelName,
343
407
  toolName: state.toolName,
344
408
  error: String(err),
@@ -349,63 +413,54 @@ export function createSessionChannelServer(opts) {
349
413
  * Handle a `channel_subscribe` message. Validates the request,
350
414
  * resolves the channel's `source.tool` against the configured
351
415
  * allowlist, and schedules a polling loop. Idempotent on
352
- * `${stackItemId}:${channelName}` — a re-subscribe replaces any
416
+ * `${renderId}:${channelName}` — a re-subscribe replaces any
353
417
  * existing interval rather than running two in parallel.
354
418
  */
355
419
  async function handleChannelSubscribe(ws, sub, message) {
356
420
  const payload = message.payload;
357
- // sessionId match — the spoof guard at every wire-input boundary.
421
+ // renderId match — the spoof guard at every wire-input boundary.
358
422
  // A subscriber bound to session A can't drive a subscribe for
359
423
  // session B even if they crafted the inbound payload.
360
- if (payload.sessionId !== sub.sessionId) {
361
- sendChannelError(ws, payload.sessionId, payload.channelName, 'SUBSCRIBE_UNAUTHORIZED', `Subscriber is bound to session '${sub.sessionId}' but channel_subscribe targets '${payload.sessionId}'`, message.requestId);
424
+ if (payload.renderId !== sub.renderId) {
425
+ sendChannelError(ws, payload.renderId, payload.channelName, "SUBSCRIBE_UNAUTHORIZED", `Subscriber is bound to session '${sub.renderId}' but channel_subscribe targets '${payload.renderId}'`, message.requestId);
362
426
  return;
363
427
  }
364
428
  // Without an `streamWebSocketLocalTools` allowlist, no channel
365
429
  // can be subscribed locally. The iframe must fall back to direct
366
430
  // polling via the MCP host proxy.
367
431
  if (!localTools) {
368
- sendChannelError(ws, payload.sessionId, payload.channelName, 'CHANNEL_NOT_LOCAL', 'This server has no streamWebSocketLocalTools allowlist; iframe must poll the source tool directly.', message.requestId);
432
+ sendChannelError(ws, payload.renderId, payload.channelName, "CHANNEL_NOT_LOCAL", "This server has no streamWebSocketLocalTools allowlist; iframe must poll the source tool directly.", message.requestId);
369
433
  return;
370
434
  }
371
- // Resolve `streamSpec[channelName].source.tool` against the active
372
- // stack item. The reverse-index lookup is O(1); we then load the
373
- // session + look up the stack entry by id.
374
- const indexEntry = await opts.sessionStore.getSessionByStackItemId(payload.stackItemId);
375
- if (!indexEntry || indexEntry.sessionId !== sub.sessionId) {
376
- sendChannelError(ws, payload.sessionId, payload.channelName, 'STACK_ITEM_NOT_FOUND', `Stack item '${payload.stackItemId}' not found on session '${payload.sessionId}'`, message.requestId);
377
- return;
378
- }
379
- const session = await opts.sessionStore.get(indexEntry.sessionId);
380
- if (!session) {
381
- sendChannelError(ws, payload.sessionId, payload.channelName, 'STACK_ITEM_NOT_FOUND', `Session '${indexEntry.sessionId}' no longer exists`, message.requestId);
382
- return;
383
- }
384
- const stackItem = session.stack.find((it) => it.id === payload.stackItemId);
385
- if (!stackItem) {
386
- sendChannelError(ws, payload.sessionId, payload.channelName, 'STACK_ITEM_NOT_FOUND', `Stack item '${payload.stackItemId}' not found on session '${payload.sessionId}'`, message.requestId);
435
+ // Phase B: a render IS the addressable unit. The prior
436
+ // reverse-index lookup collapses — `renderStore.get` resolves
437
+ // the render directly.
438
+ const stored = await opts.renderStore.get(payload.renderId);
439
+ if (!stored || stored.id !== sub.renderId) {
440
+ sendChannelError(ws, payload.renderId, payload.channelName, "RENDER_NOT_FOUND", `Render '${payload.renderId}' not found on subscriber '${sub.renderId}'`, message.requestId);
387
441
  return;
388
442
  }
443
+ const render = stored.render;
389
444
  // Channel entry resolution. mcpApps / system variants have no
390
445
  // streamSpec so the field reads back as undefined — same code
391
446
  // path as a component variant without the channel declared.
392
- const streamSpec = stackItem.type === 'mcpApps' || stackItem.type === 'system'
447
+ const streamSpec = render.type === "mcpApps" || render.type === "system"
393
448
  ? undefined
394
- : stackItem.streamSpec;
449
+ : render.streamSpec;
395
450
  const channelEntry = streamSpec?.[payload.channelName];
396
451
  if (!channelEntry || !channelEntry.source) {
397
- sendChannelError(ws, payload.sessionId, payload.channelName, 'CHANNEL_UNKNOWN', `streamSpec['${payload.channelName}'] not declared OR has no source.tool on stack item '${payload.stackItemId}'`, message.requestId);
452
+ sendChannelError(ws, payload.renderId, payload.channelName, "CHANNEL_UNKNOWN", `streamSpec['${payload.channelName}'] not declared OR has no source.tool on render '${payload.renderId}'`, message.requestId);
398
453
  return;
399
454
  }
400
455
  const sourceTool = channelEntry.source.tool;
401
456
  if (!localToolsAllowlist.has(sourceTool)) {
402
- sendChannelError(ws, payload.sessionId, payload.channelName, 'CHANNEL_NOT_LOCAL', `source.tool '${sourceTool}' is not in streamWebSocketLocalTools; iframe must poll directly`, message.requestId);
457
+ sendChannelError(ws, payload.renderId, payload.channelName, "CHANNEL_NOT_LOCAL", `source.tool '${sourceTool}' is not in streamWebSocketLocalTools; iframe must poll directly`, message.requestId);
403
458
  return;
404
459
  }
405
460
  // Validation passed — schedule (or re-schedule) the polling loop.
406
- const channelKey = `${payload.stackItemId}:${payload.channelName}`;
461
+ const channelKey = `${payload.renderId}:${payload.channelName}`;
407
462
  // Idempotent replace: a reconnect that re-subscribes the same
408
- // (stackItem, channel) pair gets a fresh timer + zeroed seq. The
463
+ // (render, channel) pair gets a fresh timer + zeroed seq. The
409
464
  // client's gap-detection treats it as a new stream from the
410
465
  // server's perspective; client-side reconnect logic owns
411
466
  // continuity if the channel was declared `mode: 'append'`.
@@ -439,17 +494,16 @@ export function createSessionChannelServer(opts) {
439
494
  const state = {
440
495
  pollIntervalMs,
441
496
  toolName: sourceTool,
442
- stackItemId: payload.stackItemId,
497
+ renderId: payload.renderId,
443
498
  channelName: payload.channelName,
444
499
  args: mergedArgs,
445
500
  seq: 0,
446
501
  timer,
447
502
  };
448
503
  sub.channelSubs.set(channelKey, state);
449
- opts.logger.info('session_channel_channel_subscribe', {
450
- sessionId: sub.sessionId,
504
+ opts.logger.info("render_channel_channel_subscribe", {
505
+ renderId: sub.renderId,
451
506
  appId: sub.appId,
452
- stackItemId: payload.stackItemId,
453
507
  channelName: payload.channelName,
454
508
  toolName: sourceTool,
455
509
  pollIntervalMs,
@@ -468,22 +522,21 @@ export function createSessionChannelServer(opts) {
468
522
  */
469
523
  function handleChannelUnsubscribe(_ws, sub, message) {
470
524
  const payload = message.payload;
471
- if (payload.sessionId !== sub.sessionId) {
525
+ if (payload.renderId !== sub.renderId) {
472
526
  // No-op silently — the canonical "spoof guard" code path is in
473
527
  // channel_subscribe; unsubscribe gets no error frame to avoid
474
528
  // leaking cross-session existence.
475
529
  return;
476
530
  }
477
- const channelKey = `${payload.stackItemId}:${payload.channelName}`;
531
+ const channelKey = `${payload.renderId}:${payload.channelName}`;
478
532
  const existing = sub.channelSubs.get(channelKey);
479
533
  if (!existing)
480
534
  return;
481
535
  clearInterval(existing.timer);
482
536
  sub.channelSubs.delete(channelKey);
483
- opts.logger.info('session_channel_channel_unsubscribe', {
484
- sessionId: sub.sessionId,
537
+ opts.logger.info("render_channel_channel_unsubscribe", {
538
+ renderId: sub.renderId,
485
539
  appId: sub.appId,
486
- stackItemId: payload.stackItemId,
487
540
  channelName: payload.channelName,
488
541
  });
489
542
  }
@@ -510,7 +563,7 @@ export function createSessionChannelServer(opts) {
510
563
  // because publish() never throws on the in-process impl, and a hosted
511
564
  // RedisPubSubFanout failure here would already be persisted to the
512
565
  // SessionStreamBuffer for replay-recovery on reconnect.
513
- void streamFanout.publish({ sessionId: envelope.sessionId, envelope });
566
+ void streamFanout.publish({ renderId: envelope.renderId, envelope });
514
567
  return { seq: envelope.seq };
515
568
  }
516
569
  /**
@@ -519,7 +572,7 @@ export function createSessionChannelServer(opts) {
519
572
  * dispatch path calls this from `catch` branches; a second failure
520
573
  * would be a footgun.
521
574
  */
522
- function emitContractError(sessionId, activeStreamSpec, payload) {
575
+ function emitContractError(renderId, activeStreamSpec, payload) {
523
576
  try {
524
577
  // Central stamp — re-emit through makeContractErrorPayload so
525
578
  // every contract-error envelope carries the current stamped
@@ -533,12 +586,8 @@ export function createSessionChannelServer(opts) {
533
586
  toolName: payload.toolName,
534
587
  error: payload.error,
535
588
  timestamp: payload.timestamp,
536
- ...(payload.actionName !== undefined
537
- ? { actionName: payload.actionName }
538
- : {}),
539
- ...(payload.sourceAction !== undefined
540
- ? { sourceAction: payload.sourceAction }
541
- : {}),
589
+ ...(payload.actionName !== undefined ? { actionName: payload.actionName } : {}),
590
+ ...(payload.sourceAction !== undefined ? { sourceAction: payload.sourceAction } : {}),
542
591
  });
543
592
  // Fire-and-forget: emitContractError is called from sync `catch`
544
593
  // branches and a second async failure here would be a footgun.
@@ -546,13 +595,13 @@ export function createSessionChannelServer(opts) {
546
595
  // says publish never throws on InProcess impl, and a hosted
547
596
  // RedisPubSubFanout failure is recoverable via replay-on-reconnect.
548
597
  void fanOut({
549
- sessionId,
598
+ renderId,
550
599
  channel: CONTRACT_ERROR_CHANNEL,
551
- mode: 'append',
600
+ mode: "append",
552
601
  payload: stamped,
553
602
  }, activeStreamSpec).catch((err) => {
554
- opts.logger.error('session_channel_contract_error_emit_failed', {
555
- sessionId,
603
+ opts.logger.error("session_channel_contract_error_emit_failed", {
604
+ renderId,
556
605
  toolName: payload.toolName,
557
606
  code: payload.error.code,
558
607
  error: String(err),
@@ -560,8 +609,8 @@ export function createSessionChannelServer(opts) {
560
609
  });
561
610
  }
562
611
  catch (err) {
563
- opts.logger.error('session_channel_contract_error_emit_failed', {
564
- sessionId,
612
+ opts.logger.error("session_channel_contract_error_emit_failed", {
613
+ renderId,
565
614
  toolName: payload.toolName,
566
615
  code: payload.error.code,
567
616
  error: String(err),
@@ -598,52 +647,41 @@ export function createSessionChannelServer(opts) {
598
647
  }
599
648
  }
600
649
  /**
601
- * Internal impl behind the public {@link SessionChannelServer.sendPropsUpdate}.
650
+ * Internal impl behind the public {@link RenderChannelServer.sendPropsUpdate}.
602
651
  * Extracted as a closure-level function so the wired-action dispatcher
603
652
  * can build a `WiredActionContext.sendPropsUpdate` that closes over the
604
653
  * same logic without forward-referencing the returned object. Best-
605
654
  * effort + orphan-tolerant per the docstring on the public method.
606
655
  */
607
- async function sendPropsUpdateImpl(sessionId, stackItemId, props) {
608
- let session;
656
+ async function sendPropsUpdateImpl(renderId, props) {
657
+ let stored;
609
658
  try {
610
- session = await opts.sessionStore.get(sessionId);
659
+ stored = await opts.renderStore.get(renderId);
611
660
  }
612
661
  catch (err) {
613
- opts.logger.warn('session_channel_props_update_lookup_failed', {
614
- sessionId,
615
- stackItemId,
662
+ opts.logger.warn("render_channel_props_update_lookup_failed", {
663
+ renderId,
616
664
  error: String(err),
617
665
  });
618
666
  return;
619
667
  }
620
- if (!session) {
621
- opts.logger.warn('session_channel_props_update_orphan', {
622
- sessionId,
623
- stackItemId,
624
- });
625
- return;
626
- }
627
- const targetIndex = session.stack.findIndex((item) => item.id === stackItemId);
628
- if (targetIndex < 0) {
629
- opts.logger.warn('session_channel_props_update_pageid_unknown', {
630
- sessionId,
631
- stackItemId,
632
- stackSize: session.stack.length,
668
+ if (!stored) {
669
+ opts.logger.warn("render_channel_props_update_orphan", {
670
+ renderId,
633
671
  });
634
672
  return;
635
673
  }
636
- // Filter the flat WS-subscriber set by sessionId; same posture as
637
- // `notifyStackPush`. `send()` already silently skips closed sockets
674
+ // Filter the flat WS-subscriber set by renderId; same posture as
675
+ // `notifyRenderPush`. `send()` already silently skips closed sockets
638
676
  // and logs (but doesn't throw on) per-subscriber send failures, so
639
677
  // the caller's mount-handler path can't be made to fail by a dead
640
678
  // WebSocket.
641
679
  for (const sub of wsSubscribers) {
642
- if (sub.sessionId !== sessionId)
680
+ if (sub.renderId !== renderId)
643
681
  continue;
644
682
  send(sub.ws, {
645
- type: 'props_update',
646
- payload: { stackItemId, props },
683
+ type: "props_update",
684
+ payload: { renderId, props },
647
685
  });
648
686
  }
649
687
  }
@@ -667,12 +705,17 @@ export function createSessionChannelServer(opts) {
667
705
  * need filter args should inline the action + refresh into a single
668
706
  * tool that returns the new state.
669
707
  */
670
- async function dispatchWiredAction(session, activeItem, envelope, dispatchedAt) {
708
+ async function dispatchWiredAction(stored, activeItem, envelope, dispatchedAt) {
709
+ // Post-Phase-B, the active "session" is just the stored render's
710
+ // id. Keep a `session` alias inside the body so the existing
711
+ // `session.id` references stay readable without a wholesale
712
+ // re-name pass.
713
+ const session = stored;
671
714
  const router = opts.wiredActionRouter;
672
- if (!router || !activeItem || envelope.type !== 'data:submit')
715
+ if (!router || !activeItem || envelope.type !== "data:submit")
673
716
  return;
674
717
  const payload = envelope.payload;
675
- if (!payload || typeof payload.action !== 'string')
718
+ if (!payload || typeof payload.action !== "string")
676
719
  return;
677
720
  // Disagreement policy — the server-side enforcement point for the
678
721
  // `client wins` rule documented on `ActionEventValue.tool`. Prefer
@@ -683,32 +726,34 @@ export function createSessionChannelServer(opts) {
683
726
  // client is the source of truth for what the user actually saw.
684
727
  // Cross-validation against the agent's tracked contract happens on
685
728
  // the agent-SDK side, not here.
686
- const actionEntry = activeItem.actionSpec?.[payload.action];
729
+ // actionSpec / streamSpec only exist on ComponentRender. The
730
+ // mcpApps / system variants narrow them to undefined.
731
+ const componentItem = activeItem.type === "mcpApps" || activeItem.type === "system" ? undefined : activeItem;
732
+ const actionEntry = componentItem?.actionSpec?.[payload.action];
687
733
  const serverDeclaredTool = actionEntry?.nextStep;
688
- const declaredTool = (typeof payload.tool === 'string' && payload.tool.length > 0
689
- ? payload.tool
690
- : undefined) ?? serverDeclaredTool;
734
+ const declaredTool = (typeof payload.tool === "string" && payload.tool.length > 0 ? payload.tool : undefined) ??
735
+ serverDeclaredTool;
691
736
  if (!declaredTool)
692
737
  return;
693
738
  const actionName = payload.action;
694
- const input = payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)
739
+ const input = payload.data && typeof payload.data === "object" && !Array.isArray(payload.data)
695
740
  ? payload.data
696
741
  : {};
697
742
  const timeoutMs = opts.wiredActionTimeoutMs ?? DEFAULT_WIRED_TOOL_TIMEOUT_MS;
698
- const streamSpec = activeItem.streamSpec;
743
+ const streamSpec = componentItem?.streamSpec;
699
744
  if (!router.has(declaredTool)) {
700
745
  emitContractError(session.id, streamSpec, {
701
746
  toolName: declaredTool,
702
747
  actionName,
703
- sourceAction: { type: 'wired-action', dispatchedAt },
748
+ sourceAction: { type: "wired-action", dispatchedAt },
704
749
  error: {
705
- code: 'TOOL_NOT_FOUND',
750
+ code: "TOOL_NOT_FOUND",
706
751
  message: `wiredActionRouter has no handler for tool '${declaredTool}'`,
707
752
  },
708
753
  timestamp: new Date().toISOString(),
709
754
  });
710
- opts.logger.warn('session_channel_wired_tool_not_found', {
711
- sessionId: session.id,
755
+ opts.logger.warn("session_channel_wired_tool_not_found", {
756
+ renderId: session.id,
712
757
  toolName: declaredTool,
713
758
  actionName,
714
759
  });
@@ -717,15 +762,14 @@ export function createSessionChannelServer(opts) {
717
762
  // Build the wired-action context the router hands the mount tool.
718
763
  // `sendPropsUpdate` closes over the active
719
764
  // `session.id` so a buggy mount can't accidentally cross-deliver to
720
- // another session by passing a foreign sessionId. The same ctx is
765
+ // another session by passing a foreign renderId. The same ctx is
721
766
  // reused for the refresh-stream pass below — a refresh tool that
722
767
  // wants to fire props_update can do so, though the canonical site
723
768
  // is the action tool itself.
724
769
  const wiredCtx = {
725
- sessionId: session.id,
726
- stackItemId: activeItem.id,
727
- sendPropsUpdate(targetStackItemId, props) {
728
- void sendPropsUpdateImpl(session.id, targetStackItemId, props);
770
+ renderId: session.id,
771
+ sendPropsUpdate(props) {
772
+ void sendPropsUpdateImpl(session.id, props);
729
773
  },
730
774
  };
731
775
  const invokeStartedAt = Date.now();
@@ -733,22 +777,20 @@ export function createSessionChannelServer(opts) {
733
777
  await invokeWithTimeout(router, declaredTool, input, wiredCtx, timeoutMs);
734
778
  }
735
779
  catch (err) {
736
- const code = err instanceof WiredToolTimeoutError ? 'TOOL_TIMEOUT' : 'TOOL_THREW';
780
+ const code = err instanceof WiredToolTimeoutError ? "TOOL_TIMEOUT" : "TOOL_THREW";
737
781
  emitContractError(session.id, streamSpec, {
738
782
  toolName: declaredTool,
739
783
  actionName,
740
- sourceAction: { type: 'wired-action', dispatchedAt },
784
+ sourceAction: { type: "wired-action", dispatchedAt },
741
785
  error: {
742
786
  code,
743
787
  message: err instanceof Error ? err.message : String(err),
744
- ...(err instanceof Error && err.stack
745
- ? { causedBy: sanitize(err.stack) }
746
- : {}),
788
+ ...(err instanceof Error && err.stack ? { causedBy: sanitize(err.stack) } : {}),
747
789
  },
748
790
  timestamp: new Date().toISOString(),
749
791
  });
750
- opts.logger.warn('session_channel_wired_tool_failed', {
751
- sessionId: session.id,
792
+ opts.logger.warn("session_channel_wired_tool_failed", {
793
+ renderId: session.id,
752
794
  toolName: declaredTool,
753
795
  actionName,
754
796
  code,
@@ -761,12 +803,12 @@ export function createSessionChannelServer(opts) {
761
803
  // sink MUST NOT block the refresh pass. Attribute set is kept
762
804
  // flat + primitive-only per TelemetryEvent.attributes shape.
763
805
  telemetry.emit({
764
- name: 'wired-tool.invoked',
806
+ name: "wired-tool.invoked",
765
807
  at: Date.now(),
766
808
  attributes: {
767
809
  toolName: declaredTool,
768
810
  actionName,
769
- sessionId: session.id,
811
+ renderId: session.id,
770
812
  latencyMs: Date.now() - invokeStartedAt,
771
813
  },
772
814
  });
@@ -784,9 +826,9 @@ export function createSessionChannelServer(opts) {
784
826
  emitContractError(session.id, streamSpec, {
785
827
  toolName: refreshTool,
786
828
  actionName,
787
- sourceAction: { type: 'refresh-stream', dispatchedAt },
829
+ sourceAction: { type: "refresh-stream", dispatchedAt },
788
830
  error: {
789
- code: 'TOOL_NOT_FOUND',
831
+ code: "TOOL_NOT_FOUND",
790
832
  message: `wiredActionRouter has no handler for refresh tool '${refreshTool}' (channel '${channelName}')`,
791
833
  },
792
834
  timestamp: new Date().toISOString(),
@@ -803,22 +845,20 @@ export function createSessionChannelServer(opts) {
803
845
  output = await invokeWithTimeout(router, refreshTool, EMPTY_REFRESH_INPUT, wiredCtx, timeoutMs);
804
846
  }
805
847
  catch (err) {
806
- const code = err instanceof WiredToolTimeoutError ? 'TOOL_TIMEOUT' : 'TOOL_THREW';
848
+ const code = err instanceof WiredToolTimeoutError ? "TOOL_TIMEOUT" : "TOOL_THREW";
807
849
  emitContractError(session.id, streamSpec, {
808
850
  toolName: refreshTool,
809
851
  actionName,
810
- sourceAction: { type: 'refresh-stream', dispatchedAt },
852
+ sourceAction: { type: "refresh-stream", dispatchedAt },
811
853
  error: {
812
854
  code,
813
855
  message: err instanceof Error ? err.message : String(err),
814
- ...(err instanceof Error && err.stack
815
- ? { causedBy: sanitize(err.stack) }
816
- : {}),
856
+ ...(err instanceof Error && err.stack ? { causedBy: sanitize(err.stack) } : {}),
817
857
  },
818
858
  timestamp: new Date().toISOString(),
819
859
  });
820
- opts.logger.warn('session_channel_refresh_tool_failed', {
821
- sessionId: session.id,
860
+ opts.logger.warn("session_channel_refresh_tool_failed", {
861
+ renderId: session.id,
822
862
  toolName: refreshTool,
823
863
  channel: channelName,
824
864
  code,
@@ -834,15 +874,15 @@ export function createSessionChannelServer(opts) {
834
874
  emitContractError(session.id, streamSpec, {
835
875
  toolName: refreshTool,
836
876
  actionName,
837
- sourceAction: { type: 'refresh-stream', dispatchedAt },
877
+ sourceAction: { type: "refresh-stream", dispatchedAt },
838
878
  error: {
839
- code: 'SCHEMA_VIOLATION',
879
+ code: "SCHEMA_VIOLATION",
840
880
  message: err.message,
841
881
  },
842
882
  timestamp: new Date().toISOString(),
843
883
  });
844
- opts.logger.warn('session_channel_refresh_schema_violation', {
845
- sessionId: session.id,
884
+ opts.logger.warn("session_channel_refresh_schema_violation", {
885
+ renderId: session.id,
846
886
  toolName: refreshTool,
847
887
  channel: channelName,
848
888
  violations: err.violations,
@@ -853,9 +893,9 @@ export function createSessionChannelServer(opts) {
853
893
  }
854
894
  try {
855
895
  await fanOut({
856
- sessionId: session.id,
896
+ renderId: session.id,
857
897
  channel: channelName,
858
- mode: channelEntry?.mode ?? 'append',
898
+ mode: channelEntry?.mode ?? "append",
859
899
  payload: output,
860
900
  }, streamSpec);
861
901
  }
@@ -864,8 +904,8 @@ export function createSessionChannelServer(opts) {
864
904
  // is buffer-internal (e.g., record() invariant violation). Log
865
905
  // but don't propagate — a single broken channel must not take
866
906
  // down the session.
867
- opts.logger.error('session_channel_refresh_emit_failed', {
868
- sessionId: session.id,
907
+ opts.logger.error("session_channel_refresh_emit_failed", {
908
+ renderId: session.id,
869
909
  toolName: refreshTool,
870
910
  channel: channelName,
871
911
  error: String(err),
@@ -874,40 +914,40 @@ export function createSessionChannelServer(opts) {
874
914
  }
875
915
  }
876
916
  async function resolveIdentityFromUpgrade(req) {
877
- const url = new URL(req.url ?? '/', 'http://localhost');
878
- // Bootstrap gate: when `?bootstrap=` is present AND the channel is
879
- // configured with bootstrap plumbing, skip the AuthAdapter entirely
880
- // at upgrade. The real identity is established in handleSubscribe
881
- // when the subscribe payload's `bootstrap` token is verified
882
- // (single-use jti claimed there). This is how MCP Apps iframes
883
- // connect — they can't set Authorization headers, and the token
884
- // model is subscribe-scoped anyway.
917
+ const url = new URL(req.url ?? "/", "http://localhost");
918
+ // WS-token gate: when `?wsToken=` is present AND the channel is
919
+ // configured with ws-token-auth plumbing, skip the AuthAdapter
920
+ // entirely at upgrade. The real identity is established in
921
+ // handleSubscribe when the subscribe payload's `wsToken` is
922
+ // verified. This is how MCP Apps iframes connect — they can't set
923
+ // Authorization headers, and the token model is subscribe-scoped
924
+ // anyway.
885
925
  //
886
926
  // URL-gate here is NOT verification — it's a "don't reject the
887
927
  // upgrade for missing bearer" signal. The real verify runs at
888
928
  // subscribe time; an invalid token reaches that point and is
889
929
  // rejected with BOOTSTRAP_INVALID.
890
- if (opts.bootstrap && url.searchParams.has('bootstrap')) {
930
+ if (opts.bootstrap && url.searchParams.has("wsToken")) {
891
931
  return {
892
932
  identity: {
893
- kind: 'user',
894
- userId: '__bootstrap_pending__',
895
- workspaceId: '__bootstrap_pending__',
933
+ kind: "user",
934
+ userId: "__bootstrap_pending__",
935
+ workspaceId: "__bootstrap_pending__",
896
936
  roles: [],
897
937
  },
898
- source: 'apikey',
938
+ source: "apikey",
899
939
  };
900
940
  }
901
941
  // Embedded-ui cookie gate. Consulted ONLY when the channel is
902
942
  // configured with cookie-auth plumbing AND no bootstrap is in
903
943
  // play. Unlike bootstrap, cookies ARE verified here: the single
904
944
  // consumer (console SPA) sets the cookie out-of-band via
905
- // `POST /ggui/console/session-cookie` and we do want to
945
+ // `POST /ggui/console/render-cookie` and we do want to
906
946
  // reject the upgrade cleanly (HTTP 401 → browser WS error) when
907
947
  // the cookie is stale/missing, not carry a doomed handshake into
908
948
  // subscribe where the error surface is worse.
909
949
  //
910
- // On success, we stash the bound `{sessionId, appId}` on the
950
+ // On success, we stash the bound `{renderId, appId}` on the
911
951
  // request so `handleSubscribe` can enforce that the subscribe
912
952
  // payload targets exactly those values. No synthesis from the
913
953
  // AuthAdapter — cookies ARE the auth signal.
@@ -918,14 +958,14 @@ export function createSessionChannelServer(opts) {
918
958
  if (bound) {
919
959
  req.__gguiCookieBound = bound;
920
960
  return {
921
- identity: { kind: 'builder' },
922
- source: 'apikey',
961
+ identity: { kind: "builder" },
962
+ source: "apikey",
923
963
  };
924
964
  }
925
965
  // Cookie present but invalid — do NOT fall through to the
926
966
  // bearer path. An invalid cookie is a same-origin user error,
927
967
  // not a pass-through condition.
928
- throw new UnauthenticatedError('console cookie invalid');
968
+ throw new UnauthenticatedError("console cookie invalid");
929
969
  }
930
970
  // No cookie present → fall through to bearer path below. Mixed
931
971
  // deployments (pairing-token bearer + same-origin cookie for
@@ -935,33 +975,28 @@ export function createSessionChannelServer(opts) {
935
975
  // to `?token=<jwt>` for web clients, matching the convention most
936
976
  // session-channel endpoints ship with. Server-side clients (Node
937
977
  // `ws`, tests) continue to set the header directly.
938
- if (!req.headers['authorization']) {
939
- const token = url.searchParams.get('token');
978
+ if (!req.headers["authorization"]) {
979
+ const token = url.searchParams.get("token");
940
980
  if (token) {
941
- req.headers['authorization'] = `Bearer ${token}`;
981
+ req.headers["authorization"] = `Bearer ${token}`;
942
982
  }
943
983
  }
944
984
  return resolveIdentityFromHeaders(opts.auth, req.headers, req.socket?.remoteAddress ?? undefined);
945
985
  }
946
986
  /**
947
- * Resolve the stack item the event claims to originate from, using the
948
- * SAME authoritative index as the hosted ingress (`gguiEvent.context
949
- * .stackIndex`, falling back to `session.currentStackIndex`). If the
950
- * session has popped forward between emit and ingress, validating
951
- * against the newer stack item would reject legitimate in-flight
952
- * actions — the doctrine pins context.stackIndex as the truth.
987
+ * Resolve the active render variant for contract enforcement. Phase
988
+ * B collapsed the prior (stack, currentStackIndex) lookup — a render
989
+ * IS the addressable unit, so the active render is the stored render
990
+ * itself. MCP Apps / system variants narrow to `undefined` so
991
+ * upstream enforcement skips (allowlist + actionSpec checks are
992
+ * no-ops when no `ComponentRender` is active).
953
993
  */
954
- function resolveActiveStackItem(session, stackIndex) {
955
- if (stackIndex < 0 || stackIndex >= session.stack.length)
994
+ function resolveActiveRender(render) {
995
+ if (!render)
956
996
  return undefined;
957
- const entry = session.stack[stackIndex];
958
- // MCP Apps items don't participate in ggui's contract enforcement —
959
- // they're embedded vendor iframes with their own contract. Return
960
- // `undefined` so upstream enforcement skips (allowlist + actionSpec
961
- // checks are no-ops when no ComponentStackItem is active).
962
- if (entry.type === 'mcpApps' || entry.type === 'system')
997
+ if (render.type === "mcpApps" || render.type === "system")
963
998
  return undefined;
964
- return entry;
999
+ return render;
965
1000
  }
966
1001
  /**
967
1002
  * Handle an inbound `action` message — the canonical flat
@@ -975,83 +1010,80 @@ export function createSessionChannelServer(opts) {
975
1010
  */
976
1011
  async function handleInboundAction(ws, sub, message) {
977
1012
  const envelope = message.payload;
978
- // Spoof guard — envelope.sessionId is REQUIRED on the wire and
1013
+ // Spoof guard — envelope.renderId is REQUIRED on the wire and
979
1014
  // MUST match the subscriber's bound session.
980
- if (envelope.sessionId !== sub.sessionId) {
981
- sendError(ws, 'SESSION_MISMATCH', `Action targets session '${envelope.sessionId}' but this socket is subscribed to '${sub.sessionId}'`, message.requestId);
1015
+ if (envelope.renderId !== sub.renderId) {
1016
+ sendError(ws, "SESSION_MISMATCH", `Action targets session '${envelope.renderId}' but this socket is subscribed to '${sub.renderId}'`, message.requestId);
982
1017
  return;
983
1018
  }
984
- const session = await opts.sessionStore.get(sub.sessionId);
985
- if (!session) {
986
- sendError(ws, 'SESSION_NOT_FOUND', `Session ${sub.sessionId} no longer exists`, message.requestId);
1019
+ const stored = await opts.renderStore.get(sub.renderId);
1020
+ if (!stored) {
1021
+ sendError(ws, "RENDER_NOT_FOUND", `Render ${sub.renderId} no longer exists`, message.requestId);
987
1022
  return;
988
1023
  }
989
- // Stack routing: ActionEnvelope may carry stackIndex OR stackItemId.
990
- // When both are present, stackItemId wins (doctrine-aligned — stable
991
- // identity beats positional index). When neither is present, fall
992
- // back to session.currentStackIndex.
993
- let stackIndex = envelope.stackIndex ?? session.currentStackIndex;
994
- if (envelope.stackItemId !== undefined) {
995
- const byId = session.stack.findIndex((s) => s.id === envelope.stackItemId);
996
- if (byId >= 0)
997
- stackIndex = byId;
998
- }
999
- const activeItem = resolveActiveStackItem(session, stackIndex);
1024
+ // Phase B: a render IS the addressable unit. The prior stack
1025
+ // routing (stackIndex / cross-stack pickIds) collapses — the
1026
+ // resolved render itself is the active item.
1027
+ const activeItem = resolveActiveRender(stored.render);
1000
1028
  // ── Two-step enforcement ──
1001
- // 1. allowlist via assertEventAllowed
1029
+ // 1. allowlist via assertEventAllowed (Phase B: no-op stub —
1030
+ // Render no longer carries a `subscription` allowlist;
1031
+ // reinstating per-render event policy is deferred.)
1002
1032
  // 2. actionSpec payload check via assertActionContract (data:submit)
1003
1033
  // Envelope.payload for data:submit carries the ActionEventValue
1004
1034
  // shape (`{action, data?, tool?}`).
1005
1035
  try {
1006
- assertEventAllowed(activeItem?.subscription, envelope.type);
1036
+ assertEventAllowed(undefined, envelope.type);
1007
1037
  }
1008
1038
  catch (err) {
1009
1039
  if (err instanceof EventNotAllowedError) {
1010
- opts.logger.warn('session_channel_event_not_allowed', {
1011
- sessionId: sub.sessionId,
1012
- eventType: err.eventType,
1013
- allowedEvents: err.allowedEvents,
1014
- envelope: 'action',
1040
+ opts.logger.warn("render_channel_event_not_allowed", {
1041
+ renderId: sub.renderId,
1042
+ envelope: "action",
1043
+ error: err.message,
1015
1044
  });
1016
- sendError(ws, 'EVENT_NOT_ALLOWED', err.message, message.requestId, err.toErrorData());
1045
+ sendError(ws, "EVENT_NOT_ALLOWED", err.message, message.requestId);
1017
1046
  return;
1018
1047
  }
1019
1048
  throw err;
1020
1049
  }
1021
- if (envelope.type === 'data:submit') {
1050
+ if (envelope.type === "data:submit") {
1022
1051
  try {
1023
- assertActionContract(activeItem?.actionSpec, envelope.payload);
1052
+ const activeActionSpec = activeItem && activeItem.type !== "mcpApps" && activeItem.type !== "system"
1053
+ ? activeItem.actionSpec
1054
+ : undefined;
1055
+ assertActionContract(activeActionSpec, envelope.payload);
1024
1056
  }
1025
1057
  catch (err) {
1026
1058
  if (err instanceof ContractViolationError) {
1027
- opts.logger.warn('session_channel_contract_violation', {
1028
- sessionId: sub.sessionId,
1059
+ opts.logger.warn("session_channel_contract_violation", {
1060
+ renderId: sub.renderId,
1029
1061
  violations: err.violations,
1030
- envelope: 'action',
1062
+ envelope: "action",
1031
1063
  });
1032
- sendError(ws, 'CONTRACT_VIOLATION', err.message, message.requestId, err.toErrorData());
1064
+ sendError(ws, "CONTRACT_VIOLATION", err.message, message.requestId, err.toErrorData());
1033
1065
  return;
1034
1066
  }
1035
1067
  throw err;
1036
1068
  }
1037
1069
  }
1038
- // Persist the envelope. SessionStore.appendEvent assigns a monotonic
1070
+ // Persist the envelope. RenderStore.appendEvent assigns a monotonic
1039
1071
  // seq the client acks back with so reconnects can resume via `fromSeq`.
1040
1072
  const dispatchedAt = new Date().toISOString();
1041
1073
  let seq;
1042
1074
  try {
1043
- seq = await opts.sessionStore.appendEvent({
1044
- sessionId: sub.sessionId,
1045
- type: 'user.submitted',
1075
+ seq = await opts.renderStore.appendEvent({
1076
+ renderId: sub.renderId,
1077
+ type: "user.submitted",
1046
1078
  data: envelope,
1047
1079
  });
1048
1080
  }
1049
1081
  catch (err) {
1050
- opts.logger.error('session_channel_append_failed', {
1051
- sessionId: sub.sessionId,
1082
+ opts.logger.error("session_channel_append_failed", {
1083
+ renderId: sub.renderId,
1052
1084
  error: String(err),
1053
1085
  });
1054
- sendError(ws, 'APPEND_FAILED', err instanceof Error ? err.message : String(err), message.requestId);
1086
+ sendError(ws, "APPEND_FAILED", err instanceof Error ? err.message : String(err), message.requestId);
1055
1087
  return;
1056
1088
  }
1057
1089
  // wiredActionRouter — fire any declared action-tool +
@@ -1065,7 +1097,7 @@ export function createSessionChannelServer(opts) {
1065
1097
  // catches every failure and emits contract-error envelopes; a
1066
1098
  // throw out of this call would be a platform bug and we'd rather
1067
1099
  // see it in tests than silently drop.
1068
- await dispatchWiredAction(session, activeItem, envelope, dispatchedAt);
1100
+ await dispatchWiredAction(stored, activeItem, envelope, dispatchedAt);
1069
1101
  // StreamFanout pump-drain: dispatchWiredAction's internal fanOut
1070
1102
  // calls `streamFanout.publish()` which queues envelopes into the
1071
1103
  // pump's async iterator. The pump's `await iter.next()` resolves
@@ -1079,7 +1111,7 @@ export function createSessionChannelServer(opts) {
1079
1111
  // independent signals.
1080
1112
  await new Promise((resolve) => setImmediate(resolve));
1081
1113
  send(ws, {
1082
- type: 'ack',
1114
+ type: "ack",
1083
1115
  payload: { sequence: seq, timestamp: Date.now() },
1084
1116
  ...(message.requestId ? { requestId: message.requestId } : {}),
1085
1117
  });
@@ -1099,7 +1131,7 @@ export function createSessionChannelServer(opts) {
1099
1131
  // pre-handshake.
1100
1132
  //
1101
1133
  // Placed FIRST — before bootstrap verify (which consumes the
1102
- // single-use bootstrap token per the SessionChannelBootstrap
1134
+ // single-use bootstrap token per the RenderChannelBootstrap
1103
1135
  // docstring) and before session lookup/creation (DB work).
1104
1136
  // Bootstrap iframes with a version mismatch must retry with a
1105
1137
  // fresh bootstrap token; burning the token on a mismatch the
@@ -1108,21 +1140,21 @@ export function createSessionChannelServer(opts) {
1108
1140
  if (Array.isArray(payload.supportedVersions) &&
1109
1141
  payload.supportedVersions.length > 0 &&
1110
1142
  !payload.supportedVersions.includes(PROTOCOL_SCHEMA_VERSION)) {
1111
- const policy = opts.versionPolicy ?? 'reject';
1143
+ const policy = opts.versionPolicy ?? "reject";
1112
1144
  sendError(ws, UPGRADE_REQUIRED, `Server speaks ${PROTOCOL_SCHEMA_VERSION}; client declared ` +
1113
- `supportedVersions=[${payload.supportedVersions.join(', ')}].`, message.requestId, {
1145
+ `supportedVersions=[${payload.supportedVersions.join(", ")}].`, message.requestId, {
1114
1146
  serverVersion: PROTOCOL_SCHEMA_VERSION,
1115
1147
  clientSupportedVersions: payload.supportedVersions,
1116
1148
  policy,
1117
1149
  });
1118
- opts.logger.warn('session_channel_version_mismatch', {
1119
- sessionId: payload.sessionId,
1150
+ opts.logger.warn("session_channel_version_mismatch", {
1151
+ renderId: payload.renderId,
1120
1152
  appId: payload.appId,
1121
1153
  serverVersion: PROTOCOL_SCHEMA_VERSION,
1122
1154
  clientSupportedVersions: payload.supportedVersions,
1123
1155
  policy,
1124
1156
  });
1125
- if (policy === 'reject') {
1157
+ if (policy === "reject") {
1126
1158
  try {
1127
1159
  ws.close();
1128
1160
  }
@@ -1132,22 +1164,22 @@ export function createSessionChannelServer(opts) {
1132
1164
  }
1133
1165
  return;
1134
1166
  }
1135
- // Bootstrap-auth path. When `payload.bootstrap` is present, the
1136
- // MCP Apps iframe is asking us to authenticate it via the
1137
- // short-lived token minted by `ggui_push`. This REPLACES the
1138
- // upgrade-time AuthAdapter identity — iframes don't carry bearer
1139
- // tokens. Mutually-exclusive on purpose.
1167
+ // WS-token-auth path. When `payload.wsToken` is present, the MCP
1168
+ // Apps iframe is asking us to authenticate it via the short-lived
1169
+ // token minted by `ggui_render`. This REPLACES the upgrade-time
1170
+ // AuthAdapter identity — iframes don't carry bearer tokens.
1171
+ // Mutually-exclusive on purpose.
1140
1172
  let effectiveIdentity = identity;
1141
1173
  let mintedSessionToken;
1142
- if (typeof payload.bootstrap === 'string' && payload.bootstrap.length > 0) {
1174
+ if (typeof payload.wsToken === "string" && payload.wsToken.length > 0) {
1143
1175
  if (!opts.bootstrap) {
1144
- sendError(ws, 'BOOTSTRAP_NOT_SUPPORTED', 'This server was not configured with bootstrap-auth plumbing', message.requestId);
1176
+ sendError(ws, "BOOTSTRAP_NOT_SUPPORTED", "This server was not configured with ws-token-auth plumbing", message.requestId);
1145
1177
  return;
1146
1178
  }
1147
- const verifyResult = opts.bootstrap.verify(payload.bootstrap);
1179
+ const verifyResult = opts.bootstrap.verify(payload.wsToken);
1148
1180
  if (!verifyResult.ok) {
1149
- opts.logger.warn('session_channel_bootstrap_rejected', {
1150
- sessionId: payload.sessionId,
1181
+ opts.logger.warn("session_channel_bootstrap_rejected", {
1182
+ renderId: payload.renderId,
1151
1183
  appId: payload.appId,
1152
1184
  reason: verifyResult.reason,
1153
1185
  });
@@ -1157,21 +1189,21 @@ export function createSessionChannelServer(opts) {
1157
1189
  // (no refresh path); expired-but-signed envelopes emit the
1158
1190
  // dedicated BOOTSTRAP_EXPIRED so the client knows to call
1159
1191
  // `ggui_runtime_refresh_bootstrap`.
1160
- if (verifyResult.reason === 'expired') {
1161
- sendError(ws, 'BOOTSTRAP_EXPIRED', 'Bootstrap token expired — call ggui_runtime_refresh_bootstrap or re-handshake', message.requestId);
1192
+ if (verifyResult.reason === "expired") {
1193
+ sendError(ws, "BOOTSTRAP_EXPIRED", "Bootstrap token expired — call ggui_runtime_refresh_bootstrap or re-handshake", message.requestId);
1162
1194
  }
1163
1195
  else {
1164
- sendError(ws, 'BOOTSTRAP_INVALID', 'Bootstrap token invalid (bad signature, malformed, or wrong kind)', message.requestId);
1196
+ sendError(ws, "BOOTSTRAP_INVALID", "Bootstrap token invalid (bad signature, malformed, or wrong kind)", message.requestId);
1165
1197
  }
1166
1198
  return;
1167
1199
  }
1168
- const bound = { sessionId: verifyResult.sessionId, appId: verifyResult.appId };
1169
- if (bound.sessionId !== payload.sessionId) {
1170
- sendError(ws, 'BOOTSTRAP_SESSION_MISMATCH', `Bootstrap token is bound to session '${bound.sessionId}' but subscribe targets '${payload.sessionId}'`, message.requestId);
1200
+ const bound = { renderId: verifyResult.renderId, appId: verifyResult.appId };
1201
+ if (bound.renderId !== payload.renderId) {
1202
+ sendError(ws, "BOOTSTRAP_SESSION_MISMATCH", `Bootstrap token is bound to session '${bound.renderId}' but subscribe targets '${payload.renderId}'`, message.requestId);
1171
1203
  return;
1172
1204
  }
1173
1205
  if (bound.appId !== payload.appId) {
1174
- sendError(ws, 'BOOTSTRAP_APP_MISMATCH', `Bootstrap token is bound to app '${bound.appId}' but subscribe targets '${payload.appId}'`, message.requestId);
1206
+ sendError(ws, "BOOTSTRAP_APP_MISMATCH", `Bootstrap token is bound to app '${bound.appId}' but subscribe targets '${payload.appId}'`, message.requestId);
1175
1207
  return;
1176
1208
  }
1177
1209
  // Synthesize a minimal AuthResult from the bootstrap claims.
@@ -1180,75 +1212,47 @@ export function createSessionChannelServer(opts) {
1180
1212
  // citizen for the lifetime of this subscription.
1181
1213
  effectiveIdentity = {
1182
1214
  identity: {
1183
- kind: 'user',
1184
- userId: bound.sessionId,
1215
+ kind: "user",
1216
+ userId: bound.renderId,
1185
1217
  workspaceId: bound.appId,
1186
1218
  roles: [],
1187
1219
  },
1188
- source: 'apikey',
1220
+ source: "apikey",
1189
1221
  };
1190
1222
  // Mint the reconnect credential now — before create/observe
1191
1223
  // work — so a downstream failure doesn't leave the client with
1192
1224
  // no way to resume.
1193
- mintedSessionToken = opts.bootstrap.issueSessionToken(bound.sessionId, bound.appId);
1194
- opts.logger.info('session_channel_bootstrap_accepted', {
1195
- sessionId: bound.sessionId,
1225
+ mintedSessionToken = opts.bootstrap.issueSessionToken(bound.renderId, bound.appId);
1226
+ opts.logger.info("session_channel_bootstrap_accepted", {
1227
+ renderId: bound.renderId,
1196
1228
  appId: bound.appId,
1197
1229
  });
1198
1230
  }
1199
1231
  // Dev-mode session provisioning: look up first; if not present,
1200
1232
  // create with the client-provided id via the widened
1201
1233
  // CreateSessionInput.id seam. Matches the hosted model's shape
1202
- // (agent creates via ggui_push → client subscribes) in a single
1234
+ // (agent creates via ggui_render → client subscribes) in a single
1203
1235
  // step — production deployments tighten this by supplying an
1204
- // AuthAdapter that mints session-scoped tokens on push.
1205
- let session = await opts.sessionStore.get(payload.sessionId);
1236
+ // AuthAdapter that mints session-scoped tokens on render.
1237
+ let session = await opts.renderStore.get(payload.renderId);
1206
1238
  if (session) {
1207
1239
  if (session.appId !== payload.appId) {
1208
- sendError(ws, 'APP_MISMATCH', `Session ${payload.sessionId} belongs to a different app`, message.requestId);
1240
+ sendError(ws, "APP_MISMATCH", `Session ${payload.renderId} belongs to a different app`, message.requestId);
1209
1241
  return;
1210
1242
  }
1211
1243
  }
1212
1244
  else {
1213
1245
  try {
1214
- session = await opts.sessionStore.create({
1215
- id: payload.sessionId,
1246
+ session = await opts.renderStore.create({
1247
+ id: payload.renderId,
1216
1248
  appId: payload.appId,
1217
1249
  });
1218
1250
  }
1219
1251
  catch (err) {
1220
- sendError(ws, 'SESSION_CREATE_FAILED', err instanceof Error ? err.message : String(err), message.requestId);
1252
+ sendError(ws, "SESSION_CREATE_FAILED", err instanceof Error ? err.message : String(err), message.requestId);
1221
1253
  return;
1222
1254
  }
1223
1255
  }
1224
- // Canvas mode: flip `canvasLoaded`
1225
- // on the first session-wide subscribe so push.ts's resultMeta
1226
- // detects `canvasOwnsRender === true` for the next push. Until
1227
- // we flip this, push.ts falls back to the inline path (per-push
1228
- // resourceUri), which would defeat the canvas model.
1229
- //
1230
- // Idempotent — only writes when the flag isn't already true. The
1231
- // subscribe-ack semantics make this the canonical "canvas iframe
1232
- // is alive + subscribed" signal; a host that reconnects re-fires
1233
- // this no-op write.
1234
- if (session.mcpAppsMode === 'canvas' &&
1235
- session.canvasLoaded !== true) {
1236
- try {
1237
- session = await opts.sessionStore.update(session.id, {
1238
- canvasLoaded: true,
1239
- });
1240
- }
1241
- catch (err) {
1242
- opts.logger.warn('session_channel_canvas_loaded_flip_failed', {
1243
- sessionId: session.id,
1244
- appId: session.appId,
1245
- error: err instanceof Error ? err.message : String(err),
1246
- });
1247
- // Degraded mode — canvasLoaded stays false; push.ts continues
1248
- // the inline path until a future subscribe succeeds. NOT a
1249
- // fatal error — the subscribe itself is unaffected.
1250
- }
1251
- }
1252
1256
  // Snapshot the outbound-stream cursor BEFORE registering the
1253
1257
  // subscriber. Any concurrent producer that calls sendToSession
1254
1258
  // between here and registration gets seq > snapshotSeq, so the
@@ -1257,45 +1261,39 @@ export function createSessionChannelServer(opts) {
1257
1261
  // This is race-safe in single-threaded JS: the next few lines run
1258
1262
  // synchronously up to `register(sub)`, and fan-out's per-subscriber
1259
1263
  // `seq <= replayCompletedSeq` guard takes care of the window.
1260
- const snapshotSeq = await streamBuffer.currentSeq(session.id);
1261
- // Resolve active stack item's streamSpec for per-channel replay
1262
- // policy. If the stack is empty or the index is out of range, no
1263
- // spec is known — replay contributes nothing.
1264
- const activeIndex = session.currentStackIndex;
1265
- const activeItem = activeIndex >= 0 && activeIndex < session.stack.length
1266
- ? session.stack[activeIndex]
1267
- : undefined;
1264
+ // Local alias for the resolved row (handleSubscribe's `session`
1265
+ // local IS a `StoredRender` post-collapse; rename kept narrow to
1266
+ // avoid a wholesale identifier sweep in this slice).
1267
+ const stored = session;
1268
+ const snapshotSeq = await streamBuffer.currentSeq(stored.id);
1269
+ // Phase B: a render IS the addressable unit, so the active item
1270
+ // is the resolved render's visible-bits surface itself.
1271
+ const activeItem = stored.render;
1268
1272
  // Reconnect: `fromSeq` present → replay per policy on declared
1269
1273
  // AND reserved channels. Fresh subscribe: `fromSeq` absent →
1270
1274
  // call with `fromSeq=0` + NO spec, so the buffer's spec-channel
1271
1275
  // walk contributes nothing (preserving the "initial state comes
1272
- // from ack.stack[].props; stream channels are for updates
1273
- // after" doctrine for agent-declared channels) but the
1274
- // reserved-channel walk still surfaces server-pushed state that
1275
- // landed before the subscriber attached. The provisional
1276
- // preview channel (`_ggui:preview`) is the load-bearing case:
1277
- // an agent's `ggui_push` kicks off preview emission BEFORE the
1278
- // user's browser navigates to the viewer, so without this the
1279
- // late subscriber sees no preview at all.
1280
- const activeStreamSpec = activeItem !== undefined &&
1281
- activeItem.type !== 'mcpApps' &&
1282
- activeItem.type !== 'system'
1276
+ // from ack.render.props; stream channels are for updates after"
1277
+ // doctrine for agent-declared channels) but the reserved-channel
1278
+ // walk still surfaces server-pushed state that landed before the
1279
+ // subscriber attached.
1280
+ const activeStreamSpec = activeItem.type !== "mcpApps" && activeItem.type !== "system"
1283
1281
  ? activeItem.streamSpec
1284
1282
  : undefined;
1285
1283
  const replay = payload.fromSeq !== undefined
1286
- ? await streamBuffer.replay(session.id, payload.fromSeq, activeStreamSpec)
1287
- : await streamBuffer.replay(session.id, 0, undefined);
1284
+ ? await streamBuffer.replay(stored.id, payload.fromSeq, activeStreamSpec)
1285
+ : await streamBuffer.replay(stored.id, 0, undefined);
1288
1286
  // Subscribe to the StreamFanout BEFORE constructing the Subscriber:
1289
1287
  // the seam returns an AsyncIterable whose iterator we hand off; the
1290
1288
  // pump loop in `register` consumes it. Eager registration on the
1291
1289
  // seam side means any concurrent `streamFanout.publish` from this
1292
1290
  // point onward queues into our iterator — paired with the
1293
1291
  // replayCompletedSeq cursor below, that's race-free.
1294
- const fanoutIter = streamFanout.subscribe(session.id)[Symbol.asyncIterator]();
1292
+ const fanoutIter = streamFanout.subscribe(stored.id)[Symbol.asyncIterator]();
1295
1293
  const sub = {
1296
1294
  ws,
1297
- sessionId: session.id,
1298
- appId: session.appId,
1295
+ renderId: stored.id,
1296
+ appId: stored.appId,
1299
1297
  identity: effectiveIdentity,
1300
1298
  connectedAt: Date.now(),
1301
1299
  replayCompletedSeq: snapshotSeq,
@@ -1306,9 +1304,9 @@ export function createSessionChannelServer(opts) {
1306
1304
  channelSubs: new Map(),
1307
1305
  };
1308
1306
  register(sub);
1309
- opts.logger.info('session_channel_subscribed', {
1310
- sessionId: session.id,
1311
- appId: session.appId,
1307
+ opts.logger.info("render_channel_subscribed", {
1308
+ renderId: stored.id,
1309
+ appId: stored.appId,
1312
1310
  identityKind: effectiveIdentity.identity.kind,
1313
1311
  fromSeq: payload.fromSeq,
1314
1312
  snapshotSeq,
@@ -1317,9 +1315,9 @@ export function createSessionChannelServer(opts) {
1317
1315
  bootstrap: mintedSessionToken !== undefined,
1318
1316
  });
1319
1317
  const ackPayload = {
1320
- sequence: session.eventSequence,
1318
+ sequence: stored.eventSequence,
1321
1319
  timestamp: Date.now(),
1322
- stack: session.stack,
1320
+ render: stored.render,
1323
1321
  streamSeq: snapshotSeq,
1324
1322
  // Advertise the server's protocol version on every successful
1325
1323
  // subscribe ack (SPEC §11.2.2). Clients whose
@@ -1328,15 +1326,56 @@ export function createSessionChannelServer(opts) {
1328
1326
  // the handshake ignore the field (legacy-pass-through).
1329
1327
  serverVersion: PROTOCOL_SCHEMA_VERSION,
1330
1328
  ...(replay?.truncated ? { replayTruncated: true } : {}),
1331
- ...(mintedSessionToken !== undefined
1332
- ? { sessionToken: mintedSessionToken }
1333
- : {}),
1329
+ ...(mintedSessionToken !== undefined ? { renderToken: mintedSessionToken } : {}),
1334
1330
  };
1335
1331
  send(ws, {
1336
- type: 'ack',
1332
+ type: "ack",
1337
1333
  payload: ackPayload,
1338
1334
  ...(message.requestId ? { requestId: message.requestId } : {}),
1339
1335
  });
1336
+ // R7 — RenderEvent ledger replay. When `payload.sinceSequence` is
1337
+ // present, fetch events with `seq > sinceSequence` from the per-
1338
+ // render ledger and emit each as a `render_event` wire frame
1339
+ // BEFORE the per-channel stream-buffer replay. Consumers dispatch
1340
+ // by `event.type` to fold the wire-frame-equivalent handler
1341
+ // (push/props_update/etc.) — same cursor model as the HTTP
1342
+ // `/api/renders/:id/events?sinceSequence=N` endpoint.
1343
+ //
1344
+ // Horizon gate: a cursor below the server's replay horizon OR
1345
+ // above `lastSequence` (stale from a different deployment) emits
1346
+ // an error frame with `code: 'REPLAY_HORIZON_PASSED'` and skips
1347
+ // the replay. Client recovery: re-mount from a fresh /state read.
1348
+ if (payload.sinceSequence !== undefined) {
1349
+ const sinceSeq = payload.sinceSequence;
1350
+ if (sinceSeq < 0 || !Number.isInteger(sinceSeq)) {
1351
+ sendError(ws, "INVALID_SINCE_SEQUENCE", "sinceSequence must be a non-negative integer", message.requestId);
1352
+ }
1353
+ else {
1354
+ const ledger = await opts.renderStore.listEventsSince(session.id, sinceSeq,
1355
+ // Server-side cap matches the HTTP route's default (100).
1356
+ // Stress + replay-from-zero workloads cap here.
1357
+ 100);
1358
+ if (ledger === null) {
1359
+ // Session disappeared between resolve and ledger read —
1360
+ // already handled by the broader error envelope path; nothing
1361
+ // to do here.
1362
+ }
1363
+ else if (sinceSeq > ledger.lastSequence || sinceSeq < ledger.horizonSeq) {
1364
+ sendError(ws, "REPLAY_HORIZON_PASSED", `cursor ${sinceSeq} is outside replayable range [${ledger.horizonSeq}, ${ledger.lastSequence}]`, message.requestId, { currentSequence: ledger.lastSequence });
1365
+ }
1366
+ else {
1367
+ for (const event of ledger.events) {
1368
+ // RenderEvent is now the wire-shape ledger primitive
1369
+ // (Wave 7 of flatten-render-identity, 2026-05-28); no
1370
+ // projection — emit the store's row directly.
1371
+ send(ws, {
1372
+ type: "render_event",
1373
+ payload: event,
1374
+ });
1375
+ }
1376
+ }
1377
+ }
1378
+ }
1340
1379
  // Send replay frames AFTER the ack. Ordering by `seq` ASC — the
1341
1380
  // buffer returns them pre-sorted. Client sees ack(streamSeq=N) →
1342
1381
  // up to N replay `data` frames → live tail (seq > N). No explicit
@@ -1344,7 +1383,7 @@ export function createSessionChannelServer(opts) {
1344
1383
  // the single source of truth for ordering.
1345
1384
  if (replay) {
1346
1385
  for (const env of replay.envelopes) {
1347
- send(ws, { type: 'data', payload: env });
1386
+ send(ws, { type: "data", payload: env });
1348
1387
  }
1349
1388
  }
1350
1389
  }
@@ -1355,21 +1394,21 @@ export function createSessionChannelServer(opts) {
1355
1394
  message = JSON.parse(raw);
1356
1395
  }
1357
1396
  catch {
1358
- sendError(ws, 'INVALID_JSON', 'Message is not valid JSON');
1397
+ sendError(ws, "INVALID_JSON", "Message is not valid JSON");
1359
1398
  return;
1360
1399
  }
1361
- if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
1362
- sendError(ws, 'INVALID_MESSAGE', 'Message is missing a `type` discriminator');
1400
+ if (!message || typeof message !== "object" || typeof message.type !== "string") {
1401
+ sendError(ws, "INVALID_MESSAGE", "Message is missing a `type` discriminator");
1363
1402
  return;
1364
1403
  }
1365
1404
  switch (message.type) {
1366
- case 'subscribe': {
1405
+ case "subscribe": {
1367
1406
  // `subscribe` is the only message allowed before identity is
1368
1407
  // bound to a session. Identity was already resolved at upgrade
1369
1408
  // time; we just need to register the subscriber.
1370
1409
  const identity = pendingIdentity.get(ws);
1371
1410
  if (!identity) {
1372
- sendError(ws, 'UNAUTHENTICATED', 'No identity bound to this socket', message.requestId);
1411
+ sendError(ws, "UNAUTHENTICATED", "No identity bound to this socket", message.requestId);
1373
1412
  return;
1374
1413
  }
1375
1414
  // Cookie-scope enforcement: when the upgrade was authenticated
@@ -1378,12 +1417,12 @@ export function createSessionChannelServer(opts) {
1378
1417
  // session A can't be used to open session B.
1379
1418
  const cookieBound = pendingCookieBinding.get(ws);
1380
1419
  if (cookieBound) {
1381
- if (message.payload.sessionId !== cookieBound.sessionId) {
1382
- sendError(ws, 'DEVTOOL_COOKIE_SESSION_MISMATCH', `Embedded-ui cookie is bound to session '${cookieBound.sessionId}' but subscribe targets '${message.payload.sessionId}'`, message.requestId);
1420
+ if (message.payload.renderId !== cookieBound.renderId) {
1421
+ sendError(ws, "DEVTOOL_COOKIE_SESSION_MISMATCH", `Embedded-ui cookie is bound to session '${cookieBound.renderId}' but subscribe targets '${message.payload.renderId}'`, message.requestId);
1383
1422
  return;
1384
1423
  }
1385
1424
  if (message.payload.appId !== cookieBound.appId) {
1386
- sendError(ws, 'DEVTOOL_COOKIE_APP_MISMATCH', `Embedded-ui cookie is bound to app '${cookieBound.appId}' but subscribe targets '${message.payload.appId}'`, message.requestId);
1425
+ sendError(ws, "DEVTOOL_COOKIE_APP_MISMATCH", `Embedded-ui cookie is bound to app '${cookieBound.appId}' but subscribe targets '${message.payload.appId}'`, message.requestId);
1387
1426
  return;
1388
1427
  }
1389
1428
  }
@@ -1392,34 +1431,34 @@ export function createSessionChannelServer(opts) {
1392
1431
  pendingCookieBinding.delete(ws);
1393
1432
  return;
1394
1433
  }
1395
- case 'ping':
1434
+ case "ping":
1396
1435
  send(ws, {
1397
- type: 'pong',
1436
+ type: "pong",
1398
1437
  payload: {},
1399
1438
  ...(message.requestId ? { requestId: message.requestId } : {}),
1400
1439
  });
1401
1440
  return;
1402
- case 'close':
1441
+ case "close":
1403
1442
  // Explicit close from client — unregister + close the socket.
1404
1443
  if (sub)
1405
1444
  unregister(ws);
1406
- ws.close(1000, 'client_close');
1445
+ ws.close(1000, "client_close");
1407
1446
  return;
1408
- case 'action':
1447
+ case "action":
1409
1448
  if (!sub) {
1410
- sendError(ws, 'NOT_SUBSCRIBED', "Send a 'subscribe' message first before 'action'", message.requestId);
1449
+ sendError(ws, "NOT_SUBSCRIBED", "Send a 'subscribe' message first before 'action'", message.requestId);
1411
1450
  return;
1412
1451
  }
1413
1452
  await handleInboundAction(ws, sub, message);
1414
1453
  return;
1415
- case 'channel_subscribe':
1454
+ case "channel_subscribe":
1416
1455
  if (!sub) {
1417
- sendError(ws, 'NOT_SUBSCRIBED', "Send a 'subscribe' message first before 'channel_subscribe'", message.requestId);
1456
+ sendError(ws, "NOT_SUBSCRIBED", "Send a 'subscribe' message first before 'channel_subscribe'", message.requestId);
1418
1457
  return;
1419
1458
  }
1420
1459
  await handleChannelSubscribe(ws, sub, message);
1421
1460
  return;
1422
- case 'channel_unsubscribe':
1461
+ case "channel_unsubscribe":
1423
1462
  if (!sub) {
1424
1463
  // No subscriber → nothing was subscribed → no-op silently.
1425
1464
  // Returning an error would leak "is this socket subscribed"
@@ -1428,7 +1467,7 @@ export function createSessionChannelServer(opts) {
1428
1467
  }
1429
1468
  handleChannelUnsubscribe(ws, sub, message);
1430
1469
  return;
1431
- case 'host_context_observed':
1470
+ case "host_context_observed":
1432
1471
  // The iframe-runtime echoes its captured `McpUiHostContext`
1433
1472
  // after `ui/initialize` resolves and on every
1434
1473
  // `ui/notifications/host-context-changed` notification. Persist
@@ -1438,44 +1477,25 @@ export function createSessionChannelServer(opts) {
1438
1477
  if (!checkSubscriberTenancy(ws, sub, message.payload, message.type, message.requestId)) {
1439
1478
  return;
1440
1479
  }
1441
- await applySessionPatch(sub.sessionId, sub.appId, message.type, { hostContext: message.payload.hostContext, lastActivityAt: Date.now() });
1442
- return;
1443
- case 'canvas_navigated':
1444
- // The iframe-runtime's CanvasShell fires this when the user
1445
- // back-navigates in the canvas (popping a stack item from the
1446
- // local NavStackModel). The server updates
1447
- // `session.activeStackItemId` to the new top so
1448
- // `ggui_consume`'s active-pipe resolution stays in sync with
1449
- // what the user is looking at. AbortSignal wiring for in-
1450
- // flight cold-gen on the popped item is a follow-up — the
1451
- // server-side gen orchestrator (push.ts handler) doesn't yet
1452
- // expose a per-stack-item AbortController registry to this
1453
- // routing layer.
1454
- if (!checkSubscriberTenancy(ws, sub, message.payload, message.type, message.requestId)) {
1455
- return;
1456
- }
1457
- await applySessionPatch(sub.sessionId, sub.appId, message.type, {
1458
- activeStackItemId: message.payload.activeItemId,
1480
+ await applySessionPatch(sub.renderId, sub.appId, message.type, {
1481
+ hostContext: message.payload.hostContext,
1459
1482
  lastActivityAt: Date.now(),
1460
1483
  });
1461
1484
  return;
1462
- case 'pop':
1463
- case 'get_stack':
1464
- case 'generate':
1465
- case 'feedback':
1485
+ case "feedback":
1466
1486
  // Require an active subscription for operational messages.
1467
1487
  if (!sub) {
1468
- sendError(ws, 'NOT_SUBSCRIBED', `Send a 'subscribe' message first before '${message.type}'`, message.requestId);
1488
+ sendError(ws, "NOT_SUBSCRIBED", `Send a 'subscribe' message first before '${message.type}'`, message.requestId);
1469
1489
  return;
1470
1490
  }
1471
1491
  // These OSS channel handlers land incrementally once the
1472
1492
  // matching shared handlers exist in @ggui-ai/mcp-server-handlers.
1473
1493
  // For now the ingress point is documented but rejected with a
1474
1494
  // clear code so clients don't assume silent success.
1475
- sendError(ws, 'NOT_IMPLEMENTED', `'${message.type}' not yet handled on the OSS channel server`, message.requestId);
1495
+ sendError(ws, "NOT_IMPLEMENTED", `'${message.type}' not yet handled on the OSS channel server`, message.requestId);
1476
1496
  return;
1477
1497
  default:
1478
- sendError(ws, 'UNSUPPORTED_MESSAGE', `Unsupported message type: ${String(message.type)}`, message.requestId);
1498
+ sendError(ws, "UNSUPPORTED_MESSAGE", `Unsupported message type: ${String(message.type)}`, message.requestId);
1479
1499
  }
1480
1500
  }
1481
1501
  /**
@@ -1487,13 +1507,13 @@ export function createSessionChannelServer(opts) {
1487
1507
  const pendingIdentity = new WeakMap();
1488
1508
  /**
1489
1509
  * Embedded-ui cookie binding established at upgrade. When present,
1490
- * `handleSubscribe` enforces `subscribe.sessionId === bound.sessionId`
1510
+ * `handleSubscribe` enforces `subscribe.renderId === bound.renderId`
1491
1511
  * so a valid cookie can't be used to open a session it wasn't
1492
1512
  * issued for. Parallel to {@link pendingIdentity} — same lifetime,
1493
1513
  * same WeakMap rationale.
1494
1514
  */
1495
1515
  const pendingCookieBinding = new WeakMap();
1496
- wss.on('connection', (ws, req) => {
1516
+ wss.on("connection", (ws, req) => {
1497
1517
  // Bind the resolved identity from the upgrade phase. It was
1498
1518
  // attached to the request object in handleUpgrade.
1499
1519
  const identity = req.__gguiIdentity;
@@ -1503,22 +1523,22 @@ export function createSessionChannelServer(opts) {
1503
1523
  const cookieBound = req.__gguiCookieBound;
1504
1524
  if (cookieBound)
1505
1525
  pendingCookieBinding.set(ws, cookieBound);
1506
- ws.on('message', (raw) => {
1526
+ ws.on("message", (raw) => {
1507
1527
  // `ws.on('message')` delivers Buffer/ArrayBuffer/Buffer[] depending
1508
1528
  // on frame type; normalize to string.
1509
- const text = typeof raw === 'string' ? raw : raw.toString('utf8');
1529
+ const text = typeof raw === "string" ? raw : raw.toString("utf8");
1510
1530
  onMessage(ws, text).catch((err) => {
1511
- opts.logger.error('session_channel_message_failed', {
1531
+ opts.logger.error("session_channel_message_failed", {
1512
1532
  error: String(err),
1513
1533
  });
1514
1534
  });
1515
1535
  });
1516
- ws.on('close', () => {
1536
+ ws.on("close", () => {
1517
1537
  unregister(ws);
1518
1538
  pendingIdentity.delete(ws);
1519
1539
  });
1520
- ws.on('error', (err) => {
1521
- opts.logger.warn('session_channel_socket_error', { error: String(err) });
1540
+ ws.on("error", (err) => {
1541
+ opts.logger.warn("session_channel_socket_error", { error: String(err) });
1522
1542
  });
1523
1543
  });
1524
1544
  return {
@@ -1532,27 +1552,26 @@ export function createSessionChannelServer(opts) {
1532
1552
  req.__gguiIdentity = identity;
1533
1553
  wss.handleUpgrade(req, socket, head, (ws) => {
1534
1554
  // Expose `upgradeReq` for the connection handler.
1535
- wss.emit('connection', ws, req);
1555
+ wss.emit("connection", ws, req);
1536
1556
  });
1537
1557
  })
1538
1558
  .catch((err) => {
1539
1559
  if (err instanceof UnauthenticatedError) {
1540
- opts.logger.warn('session_channel_auth_failed', {
1560
+ opts.logger.warn("session_channel_auth_failed", {
1541
1561
  reason: err.message,
1542
1562
  });
1543
- socket.write('HTTP/1.1 401 Unauthorized\r\n' +
1544
- 'Connection: close\r\n' +
1545
- 'Content-Type: text/plain\r\n\r\n' +
1546
- 'Unauthorized: ' +
1563
+ socket.write("HTTP/1.1 401 Unauthorized\r\n" +
1564
+ "Connection: close\r\n" +
1565
+ "Content-Type: text/plain\r\n\r\n" +
1566
+ "Unauthorized: " +
1547
1567
  err.message +
1548
- '\r\n');
1568
+ "\r\n");
1549
1569
  }
1550
1570
  else {
1551
- opts.logger.error('session_channel_upgrade_failed', {
1571
+ opts.logger.error("session_channel_upgrade_failed", {
1552
1572
  error: String(err),
1553
1573
  });
1554
- socket.write('HTTP/1.1 500 Internal Server Error\r\n' +
1555
- 'Connection: close\r\n\r\n');
1574
+ socket.write("HTTP/1.1 500 Internal Server Error\r\n" + "Connection: close\r\n\r\n");
1556
1575
  }
1557
1576
  socket.destroy();
1558
1577
  });
@@ -1560,60 +1579,48 @@ export function createSessionChannelServer(opts) {
1560
1579
  async sendToSession(delivery) {
1561
1580
  // Outbound fan-out enforcement (defense-in-depth parity with
1562
1581
  // hosted `handle-data.ts`). Re-validates the delivery's payload
1563
- // against the active stack item's streamSpec BEFORE delivery —
1564
- // so a future OSS mutation handler that bypasses the emit-side
1565
- // check can't fan out malformed data to subscribers. Throws
1582
+ // against the render's streamSpec BEFORE delivery — so a future
1583
+ // OSS mutation handler that bypasses the emit-side check can't
1584
+ // fan out malformed data to subscribers. Throws
1566
1585
  // ContractViolationError{tool:'ggui_emit'} on violation;
1567
1586
  // caller decides what to do (log, rethrow, wrap).
1568
- const session = await opts.sessionStore.get(delivery.sessionId);
1569
- const activeIndex = session?.currentStackIndex ?? -1;
1570
- const activeEntry = session && activeIndex >= 0 && activeIndex < session.stack.length
1571
- ? session.stack[activeIndex]
1572
- : undefined;
1573
- const streamSpec = activeEntry !== undefined &&
1574
- activeEntry.type !== 'mcpApps' &&
1575
- activeEntry.type !== 'system'
1587
+ const stored = await opts.renderStore.get(delivery.renderId);
1588
+ const activeEntry = stored?.render;
1589
+ const streamSpec = activeEntry !== undefined && activeEntry.type !== "mcpApps" && activeEntry.type !== "system"
1576
1590
  ? activeEntry.streamSpec
1577
1591
  : undefined;
1578
1592
  assertStreamContract(streamSpec, delivery.channel, delivery.payload, opts.extraReservedValidators);
1579
1593
  return fanOut(delivery, streamSpec);
1580
1594
  },
1581
- notifyStackPush(sessionId, stackItem, matchType) {
1595
+ notifyRenderPush(renderId, render, matchType) {
1582
1596
  // Best-effort fan-out to every live subscriber bound to this
1583
- // session. NOT routed through the replay buffer — see the
1584
- // `notifyStackPush` JSDoc on the interface for why fresh
1585
- // subscribers rely on `ack.stack` instead of a replay frame.
1586
- // NOT routed through StreamFanout either — `type: 'push'` is a
1587
- // distinct WebSocket message type, not a stream envelope, so it
1588
- // sits outside the seam's contract. Filter the flat WS-subscriber
1589
- // set by sessionId; N is typically 1-2 (multi-tab session sharing).
1590
- const payload = matchType !== undefined ? { stackItem, matchType } : { stackItem };
1597
+ // render. NOT routed through the replay buffer — see the
1598
+ // `notifyRenderPush` JSDoc on the interface for why fresh
1599
+ // subscribers rely on `ack.render` instead of a replay frame.
1600
+ // NOT routed through StreamFanout either — `type: 'render'` is a
1601
+ // distinct WebSocket message type. Filter the flat WS-subscriber
1602
+ // set by renderId; N is typically 1-2 (multi-tab render sharing).
1603
+ const payload = matchType !== undefined ? { render, matchType } : { render };
1591
1604
  for (const sub of wsSubscribers) {
1592
- if (sub.sessionId !== sessionId)
1605
+ if (sub.renderId !== renderId)
1593
1606
  continue;
1594
- // `send()` already silently skips closed sockets and logs
1595
- // (but doesn't throw on) per-subscriber send failures, so the
1596
- // caller's `appendStackItem` path can't be made to fail by a
1597
- // dead WebSocket.
1598
- send(sub.ws, { type: 'push', payload });
1607
+ send(sub.ws, { type: "render", payload });
1599
1608
  }
1600
1609
  },
1601
- async primeStreams(sessionId, stackItem) {
1610
+ async primeStreams(renderId, render) {
1602
1611
  const router = opts.wiredActionRouter;
1603
- const streamSpec = 'streamSpec' in stackItem ? stackItem.streamSpec : undefined;
1612
+ const streamSpec = "streamSpec" in render ? render.streamSpec : undefined;
1604
1613
  if (!router || !streamSpec)
1605
1614
  return;
1606
1615
  const timeoutMs = opts.wiredActionTimeoutMs ?? DEFAULT_WIRED_TOOL_TIMEOUT_MS;
1607
1616
  // Build the same wired-action ctx the dispatcher uses.
1608
- // Prime-time invocations reuse the seam so a refresh
1609
- // tool that fires `sendPropsUpdate` on cold-start works the same
1610
- // way as one fired post-action. `stackItemId` is the primed stack
1611
- // item's id; closures lock to the caller-supplied sessionId.
1617
+ // Prime-time invocations reuse the seam so a refresh tool that
1618
+ // fires `sendPropsUpdate` on cold-start works the same way as
1619
+ // one fired post-action.
1612
1620
  const wiredCtx = {
1613
- sessionId,
1614
- stackItemId: stackItem.id,
1615
- sendPropsUpdate(targetStackItemId, props) {
1616
- void sendPropsUpdateImpl(sessionId, targetStackItemId, props);
1621
+ renderId,
1622
+ sendPropsUpdate(props) {
1623
+ void sendPropsUpdateImpl(renderId, props);
1617
1624
  },
1618
1625
  };
1619
1626
  for (const [channelName, channelEntry] of Object.entries(streamSpec)) {
@@ -1621,8 +1628,8 @@ export function createSessionChannelServer(opts) {
1621
1628
  if (!refreshTool)
1622
1629
  continue;
1623
1630
  if (!router.has(refreshTool)) {
1624
- opts.logger.warn('session_channel_prime_tool_not_found', {
1625
- sessionId,
1631
+ opts.logger.warn("render_channel_prime_tool_not_found", {
1632
+ renderId,
1626
1633
  toolName: refreshTool,
1627
1634
  channel: channelName,
1628
1635
  });
@@ -1633,8 +1640,8 @@ export function createSessionChannelServer(opts) {
1633
1640
  output = await invokeWithTimeout(router, refreshTool, EMPTY_REFRESH_INPUT, wiredCtx, timeoutMs);
1634
1641
  }
1635
1642
  catch (err) {
1636
- opts.logger.warn('session_channel_prime_tool_failed', {
1637
- sessionId,
1643
+ opts.logger.warn("render_channel_prime_tool_failed", {
1644
+ renderId,
1638
1645
  toolName: refreshTool,
1639
1646
  channel: channelName,
1640
1647
  error: String(err),
@@ -1645,8 +1652,8 @@ export function createSessionChannelServer(opts) {
1645
1652
  assertStreamContract(streamSpec, channelName, output, opts.extraReservedValidators);
1646
1653
  }
1647
1654
  catch (err) {
1648
- opts.logger.warn('session_channel_prime_schema_violation', {
1649
- sessionId,
1655
+ opts.logger.warn("render_channel_prime_schema_violation", {
1656
+ renderId,
1650
1657
  toolName: refreshTool,
1651
1658
  channel: channelName,
1652
1659
  error: String(err),
@@ -1655,15 +1662,15 @@ export function createSessionChannelServer(opts) {
1655
1662
  }
1656
1663
  try {
1657
1664
  await fanOut({
1658
- sessionId,
1665
+ renderId,
1659
1666
  channel: channelName,
1660
- mode: channelEntry?.mode ?? 'append',
1667
+ mode: channelEntry?.mode ?? "append",
1661
1668
  payload: output,
1662
1669
  }, streamSpec);
1663
1670
  }
1664
1671
  catch (err) {
1665
- opts.logger.error('session_channel_prime_emit_failed', {
1666
- sessionId,
1672
+ opts.logger.error("render_channel_prime_emit_failed", {
1673
+ renderId,
1667
1674
  toolName: refreshTool,
1668
1675
  channel: channelName,
1669
1676
  error: String(err),
@@ -1671,41 +1678,53 @@ export function createSessionChannelServer(opts) {
1671
1678
  }
1672
1679
  }
1673
1680
  },
1674
- sendPropsUpdate(sessionId, stackItemId, props) {
1681
+ sendPropsUpdate(renderId, props) {
1675
1682
  // Public entry point — delegates to the closure-level impl that
1676
1683
  // the wired-action dispatcher's `WiredActionContext.sendPropsUpdate`
1677
1684
  // also calls. Returns the impl's promise so the caller can await
1678
- // session-store lookup completion if desired (the wiredCtx call
1679
- // site fire-and-forgets via `void`).
1680
- return sendPropsUpdateImpl(sessionId, stackItemId, props);
1685
+ // store-lookup completion if desired (the wiredCtx call site
1686
+ // fire-and-forgets via `void`).
1687
+ return sendPropsUpdateImpl(renderId, props);
1681
1688
  },
1682
- sendDrainAck({ sessionId, appId, stackItemId, eventId, drainedAt }) {
1689
+ sendDrainAck({ renderId, appId, eventId, drainedAt }) {
1683
1690
  // Server-side fan-out for the action-drain ack.
1684
- // Filter the flat WS-subscriber set by sessionId (same posture
1691
+ // Filter the flat WS-subscriber set by renderId (same posture
1685
1692
  // as `sendPropsUpdate`). No persistence; subscribers that
1686
1693
  // missed the frame fall back to their 10s claim timer, which
1687
- // the atomic pop resolves cleanly. `send()` already silently
1688
- // skips closed sockets and absorbs per-subscriber send
1689
- // failures.
1694
+ // the atomic pop resolves cleanly.
1690
1695
  for (const sub of wsSubscribers) {
1691
- if (sub.sessionId !== sessionId)
1696
+ if (sub.renderId !== renderId)
1692
1697
  continue;
1693
1698
  send(sub.ws, {
1694
- type: 'drain_ack',
1695
- payload: { sessionId, appId, stackItemId, eventId, drainedAt },
1699
+ type: "drain_ack",
1700
+ payload: { renderId, appId, eventId, drainedAt },
1696
1701
  });
1697
1702
  }
1698
1703
  },
1704
+ externalBroadcast(renderId, frame) {
1705
+ // Walk the flat subscriber set; filter to matching renderId.
1706
+ // `send()` already guards closed sockets and logs (but doesn't
1707
+ // throw on) per-subscriber failures, so the caller (a cloud
1708
+ // pubsub on-message handler) can't be made to fail by a dead
1709
+ // WebSocket. No RenderStore lookup — the publisher already
1710
+ // validated; this seam is the cross-pod delivery path, not the
1711
+ // re-validation point.
1712
+ for (const sub of wsSubscribers) {
1713
+ if (sub.renderId !== renderId)
1714
+ continue;
1715
+ send(sub.ws, frame);
1716
+ }
1717
+ },
1699
1718
  get subscriberCount() {
1700
1719
  return wsSubscribers.size;
1701
1720
  },
1702
1721
  get sessionCount() {
1703
1722
  // Distinct session count across live WS subscribers. With
1704
- // multi-tab sessions, two subscribers may share a sessionId —
1723
+ // multi-tab sessions, two subscribers may share a renderId —
1705
1724
  // dedupe before counting.
1706
1725
  const sessions = new Set();
1707
1726
  for (const sub of wsSubscribers)
1708
- sessions.add(sub.sessionId);
1727
+ sessions.add(sub.renderId);
1709
1728
  return sessions.size;
1710
1729
  },
1711
1730
  async close() {
@@ -1726,9 +1745,9 @@ export function createSessionChannelServer(opts) {
1726
1745
  // case and the wrong signal for client reconnect logic.
1727
1746
  const sessions = new Set();
1728
1747
  for (const sub of wsSubscribers) {
1729
- sessions.add(sub.sessionId);
1748
+ sessions.add(sub.renderId);
1730
1749
  try {
1731
- sub.ws.close(1012, 'service_restart');
1750
+ sub.ws.close(1012, "service_restart");
1732
1751
  }
1733
1752
  catch {
1734
1753
  /* best-effort */
@@ -1741,7 +1760,7 @@ export function createSessionChannelServer(opts) {
1741
1760
  // unregister race). For InProcessStreamFanout this is a no-op
1742
1761
  // when there are no subscribers; for hosted bindings it ensures
1743
1762
  // the per-session pub/sub channel teardown fires.
1744
- await Promise.all(Array.from(sessions, (sessionId) => streamFanout.close(sessionId).catch(() => {
1763
+ await Promise.all(Array.from(sessions, (renderId) => streamFanout.close(renderId).catch(() => {
1745
1764
  /* best-effort */
1746
1765
  })));
1747
1766
  await new Promise((resolve) => {