@ggui-ai/iframe-runtime 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -54,17 +54,30 @@
54
54
  */
55
55
  import type { JsonObject, JsonValue, StreamSpec } from '@ggui-ai/protocol';
56
56
  import type { WebSocketMessage } from '@ggui-ai/protocol/transport/websocket';
57
- import type { ChannelTransportFallbackEvent, ChannelTransportPickedEvent, ChannelTransportResubscribedEvent } from './observability.js';
57
+ import type { ChannelPollDegradedEvent, ChannelPollRecoveredEvent, ChannelTransportFallbackEvent, ChannelTransportPickedEvent, ChannelTransportResubscribedEvent } from './observability.js';
58
58
  import type { StreamBus } from './wire-config.js';
59
59
  /**
60
60
  * Default polling cadence for the iframe-polling transport (ms).
61
- * Mirrors the server-side default on
62
- * {@link DEFAULT_CHANNEL_POLL_DEFAULT_MS} in
63
- * `packages/mcp-server/src/render-channel.ts`. Conservative: 10s
64
- * matches a "data is fresh enough" bar without burning the parent
65
- * host's `tools/call` quota.
61
+ * Mirrors the server-side default (`DEFAULT_CHANNEL_POLL_DEFAULT_MS`
62
+ * in `mcp-server/src/ggui-session-channel/channel-subscriptions.ts`).
63
+ * Conservative: 10s matches a "data is fresh enough" bar without
64
+ * burning the parent host's `tools/call` quota.
66
65
  */
67
66
  export declare const DEFAULT_IFRAME_POLL_INTERVAL_MS = 10000;
67
+ /**
68
+ * Cadence (ms) a channel falls back to once its `tools/call` has
69
+ * failed STRUCTURALLY — a failure the router knows will repeat
70
+ * identically on the next tick (see {@link ToolsCallInvoker}).
71
+ *
72
+ * Deliberately a slower cadence and NOT a stop. A structural failure
73
+ * is a statement about the host's current capability, not a permanent
74
+ * property of the universe: the relay-incapability latch upstream
75
+ * clears the moment a real call succeeds, and the only thing that can
76
+ * observe that is an attempt. A stopped channel could never recover
77
+ * on its own; a slow one recovers within one probe interval, for
78
+ * ~1/3 the cost of a healthy channel at the 10s default.
79
+ */
80
+ export declare const DEFAULT_IFRAME_PROBE_POLL_INTERVAL_MS = 30000;
68
81
  /**
69
82
  * Sender for outbound WS frames. Any structurally-fitting send fn
70
83
  * works — post-B3b the registry wires the `@ggui-ai/live-channel`
@@ -74,8 +87,29 @@ export type WsSender = (msg: WebSocketMessage) => void;
74
87
  /**
75
88
  * Iframe → parent `tools/call` invoker. Resolves with the tool's
76
89
  * structured-content output (parsed `JsonValue`) or rejects on
77
- * transport-level failure. The router will swallow the failure +
78
- * keep the poll loop running (next tick may succeed).
90
+ * failure.
91
+ *
92
+ * **Rejection contract.** The router classifies every rejection into
93
+ * exactly two kinds, and the invoker chooses which by the TYPE of the
94
+ * value it throws:
95
+ *
96
+ * - **Structural** — reject with `RelayIncapableError` (see
97
+ * `relay-incapability.ts`). Says: this failed for a reason that
98
+ * will repeat identically on the next tick, so polling at full
99
+ * cadence buys nothing. The router drops the channel to
100
+ * {@link ChannelTransportRouterOptions.probePollIntervalMs} and
101
+ * emits `channel-poll-degraded` once.
102
+ * - **Transient** — reject with anything else. Says: the next tick
103
+ * may well succeed. The router stays quiet and keeps the current
104
+ * cadence; per-tick network jitter must not turn into bus noise
105
+ * or a cadence change.
106
+ *
107
+ * Classification is by type only. An invoker cannot signal structural
108
+ * failure through an error MESSAGE, and re-wording a message cannot
109
+ * change how a channel is treated.
110
+ *
111
+ * Neither kind ever stops the loop — see
112
+ * {@link DEFAULT_IFRAME_PROBE_POLL_INTERVAL_MS}.
79
113
  */
80
114
  export type ToolsCallInvoker = (args: {
81
115
  readonly toolName: string;
@@ -102,15 +136,38 @@ export interface ChannelTransportRouterOptions {
102
136
  /** StreamBus the router emits envelopes onto. */
103
137
  readonly streamBus: StreamBus;
104
138
  /**
105
- * Default poll cadence (ms) for the iframe-polling transport.
106
- * Overridden per-channel via `streamSpec[name].source.pollIntervalMs`
107
- * when authored. Falls back to {@link DEFAULT_IFRAME_POLL_INTERVAL_MS}
108
- * when neither the spec nor the option supplies one.
139
+ * Poll cadence (ms) shared by EVERY iframe-polled channel on this
140
+ * router. Uniform by construction: `StreamChannelEntry.source`
141
+ * declares `tool` + `args` only, so a contract author has no way to
142
+ * ask one channel to poll faster than another. Giving channels
143
+ * individual cadences would be a protocol change (a new contract
144
+ * field plus server-side clamping — placement, on the channel entry
145
+ * or on `source`, is still open), not a router option — the
146
+ * per-subscription `pollIntervalMs` that does exist belongs to the
147
+ * WS transport's `channel_subscribe` payload, which this polling
148
+ * fallback never sends.
149
+ *
150
+ * Falls back to {@link DEFAULT_IFRAME_POLL_INTERVAL_MS} when
151
+ * omitted. The only cadence that varies between channels at runtime
152
+ * is the degraded probe cadence ({@link
153
+ * ChannelTransportRouterOptions.probePollIntervalMs}), which follows
154
+ * observed poll health rather than authored config.
109
155
  */
110
156
  readonly defaultPollIntervalMs?: number;
111
157
  /**
112
- * Observability sink — fires once per transport pick + once per
113
- * disconnect/reconnect-fallback transition. Optional; absent ⇒
158
+ * Cadence (ms) a channel probes at after a structural poll failure.
159
+ * Falls back to {@link DEFAULT_IFRAME_PROBE_POLL_INTERVAL_MS}.
160
+ *
161
+ * Clamped UP to the channel's normal cadence: degrading must never
162
+ * make a channel poll MORE often than it did while healthy, which a
163
+ * naive value would do for any deployment whose normal cadence is
164
+ * already slower than the probe default.
165
+ */
166
+ readonly probePollIntervalMs?: number;
167
+ /**
168
+ * Observability sink — fires once per transport pick, once per
169
+ * disconnect/reconnect-fallback transition, and once per
170
+ * poll-health transition in each direction. Optional; absent ⇒
114
171
  * silent.
115
172
  */
116
173
  readonly onObserve?: (event: ChannelTransportEvent) => void;
@@ -118,12 +175,12 @@ export interface ChannelTransportRouterOptions {
118
175
  /**
119
176
  * Observability events the router emits. Hosts that wire `onObserve`
120
177
  * see these alongside the renderer's other observability events
121
- * (`subscribe-failed`, `auth-required`, …). The arm interfaces live in
122
- * `observability.ts` (they are members of the exported
123
- * `ObservabilityEvent` union); this alias is the router-scoped subset,
124
- * so the two surfaces cannot drift.
178
+ * (`subscribe-failed`, `schema-version-mismatch`, …). The arm
179
+ * interfaces live in `observability.ts` (they are members of the
180
+ * exported `ObservabilityEvent` union); this alias is the
181
+ * router-scoped subset, so the two surfaces cannot drift.
125
182
  */
126
- export type ChannelTransportEvent = ChannelTransportPickedEvent | ChannelTransportFallbackEvent | ChannelTransportResubscribedEvent;
183
+ export type ChannelTransportEvent = ChannelTransportPickedEvent | ChannelTransportFallbackEvent | ChannelTransportResubscribedEvent | ChannelPollDegradedEvent | ChannelPollRecoveredEvent;
127
184
  /**
128
185
  * Router handle returned by {@link createChannelTransportRouter}.
129
186
  */
@@ -1,12 +1,26 @@
1
+ import { isRelayIncapableError } from './relay-incapability.js';
1
2
  /**
2
3
  * Default polling cadence for the iframe-polling transport (ms).
3
- * Mirrors the server-side default on
4
- * {@link DEFAULT_CHANNEL_POLL_DEFAULT_MS} in
5
- * `packages/mcp-server/src/render-channel.ts`. Conservative: 10s
6
- * matches a "data is fresh enough" bar without burning the parent
7
- * host's `tools/call` quota.
4
+ * Mirrors the server-side default (`DEFAULT_CHANNEL_POLL_DEFAULT_MS`
5
+ * in `mcp-server/src/ggui-session-channel/channel-subscriptions.ts`).
6
+ * Conservative: 10s matches a "data is fresh enough" bar without
7
+ * burning the parent host's `tools/call` quota.
8
8
  */
9
9
  export const DEFAULT_IFRAME_POLL_INTERVAL_MS = 10_000;
10
+ /**
11
+ * Cadence (ms) a channel falls back to once its `tools/call` has
12
+ * failed STRUCTURALLY — a failure the router knows will repeat
13
+ * identically on the next tick (see {@link ToolsCallInvoker}).
14
+ *
15
+ * Deliberately a slower cadence and NOT a stop. A structural failure
16
+ * is a statement about the host's current capability, not a permanent
17
+ * property of the universe: the relay-incapability latch upstream
18
+ * clears the moment a real call succeeds, and the only thing that can
19
+ * observe that is an attempt. A stopped channel could never recover
20
+ * on its own; a slow one recovers within one probe interval, for
21
+ * ~1/3 the cost of a healthy channel at the 10s default.
22
+ */
23
+ export const DEFAULT_IFRAME_PROBE_POLL_INTERVAL_MS = 30_000;
10
24
  /**
11
25
  * Factory.
12
26
  *
@@ -23,6 +37,9 @@ export function createChannelTransportRouter(opts) {
23
37
  const channels = new Map();
24
38
  const allowlist = new Set(opts.streamWebSocketLocalTools ?? []);
25
39
  const defaultPollMs = opts.defaultPollIntervalMs ?? DEFAULT_IFRAME_POLL_INTERVAL_MS;
40
+ // Clamped up — a degraded channel must never poll more eagerly than
41
+ // a healthy one. See `probePollIntervalMs`.
42
+ const probePollMs = Math.max(defaultPollMs, opts.probePollIntervalMs ?? DEFAULT_IFRAME_PROBE_POLL_INTERVAL_MS);
26
43
  /**
27
44
  * WS lifecycle flag. We start in `'connected'` because the router
28
45
  * is created AFTER the subscribe ack — the runtime's bootSequence
@@ -53,40 +70,130 @@ export function createChannelTransportRouter(opts) {
53
70
  };
54
71
  opts.streamBus.emit(envelope);
55
72
  }
73
+ /**
74
+ * (Re-)arm a channel's recurring timer at `intervalMs`. Only ever
75
+ * called for a channel that is already polling — never resurrects a
76
+ * stopped loop.
77
+ */
78
+ function armTimer(state, intervalMs) {
79
+ if (state.pollTimer !== null)
80
+ clearInterval(state.pollTimer);
81
+ state.pollTimer = setInterval(() => {
82
+ void tick(state);
83
+ }, intervalMs);
84
+ }
85
+ /**
86
+ * Healthy → degraded. Slows the channel to the probe cadence and
87
+ * announces the transition ONCE.
88
+ *
89
+ * No-ops when the channel isn't polling: an in-flight call can
90
+ * resolve after `stopPolling` (WS payload arrived, channel removed,
91
+ * router disposed), and a degraded channel with no loop to slow
92
+ * down is not a transition worth announcing.
93
+ */
94
+ function degradePolling(state) {
95
+ if (state.pollDegraded)
96
+ return;
97
+ if (state.pollTimer === null)
98
+ return;
99
+ state.pollDegraded = true;
100
+ armTimer(state, probePollMs);
101
+ observe({
102
+ kind: 'channel-poll-degraded',
103
+ sessionId: state.sessionId,
104
+ channelName: state.channelName,
105
+ reason: 'relay-incapable',
106
+ probeIntervalMs: probePollMs,
107
+ });
108
+ }
109
+ /**
110
+ * Degraded → healthy. Any successful poll proves the structural
111
+ * condition has lifted, so the channel returns to its normal
112
+ * cadence. Announced ONCE per recovery, not per subsequent success.
113
+ */
114
+ function restorePolling(state) {
115
+ if (!state.pollDegraded)
116
+ return;
117
+ state.pollDegraded = false;
118
+ if (state.pollTimer !== null)
119
+ armTimer(state, defaultPollMs);
120
+ observe({
121
+ kind: 'channel-poll-recovered',
122
+ sessionId: state.sessionId,
123
+ channelName: state.channelName,
124
+ pollIntervalMs: defaultPollMs,
125
+ });
126
+ }
127
+ /**
128
+ * One poll of one channel: call the tool, classify the outcome,
129
+ * emit the payload.
130
+ *
131
+ * The two failure kinds are the `ToolsCallInvoker` rejection
132
+ * contract, classified by TYPE (never by message text):
133
+ *
134
+ * - Structural (`RelayIncapableError`) — the next tick would fail
135
+ * identically, so slow to the probe cadence and say so once.
136
+ * - Transient (anything else) — stay quiet and keep the cadence.
137
+ * The renderer's observability path already surfaces transport
138
+ * failures via the WS error route; per-tool poll failures do
139
+ * not also become bus noise during a passing outage.
140
+ *
141
+ * Neither kind stops the loop. That is the fail-safe: a channel
142
+ * whose host regains the ability to relay recovers on its own,
143
+ * within one probe interval, with no external nudge.
144
+ */
145
+ const tick = async (state) => {
146
+ if (disposed)
147
+ return;
148
+ try {
149
+ const payload = await opts.toolsCall({
150
+ toolName: state.toolName,
151
+ args: { ...(state.args ?? {}) },
152
+ });
153
+ if (disposed)
154
+ return;
155
+ // Restore BEFORE emitting: a throwing bus subscriber must not
156
+ // be mistaken for a failed poll (it would land in the catch
157
+ // below and read as transient), and it must not cost this
158
+ // channel its recovery.
159
+ restorePolling(state);
160
+ emitToBus(state, payload);
161
+ }
162
+ catch (err) {
163
+ if (disposed)
164
+ return;
165
+ if (isRelayIncapableError(err))
166
+ degradePolling(state);
167
+ }
168
+ };
56
169
  /**
57
170
  * Start an iframe-polling loop for one channel. Idempotent — if a
58
171
  * timer is already running we leave it alone. Fires the first poll
59
172
  * IMMEDIATELY (no `setInterval` lead-in delay) so the first
60
173
  * `useStream` render isn't blocked on a 10-second wait.
174
+ *
175
+ * A channel that was already degraded when its loop last stopped
176
+ * re-arms at the probe cadence — the condition that degraded it
177
+ * belongs to the host, and a WS reconnect cycle is no evidence it
178
+ * has lifted. Only a successful poll is.
61
179
  */
62
180
  function startPolling(state) {
63
181
  if (state.pollTimer !== null)
64
182
  return;
65
- const tick = async () => {
66
- if (disposed)
67
- return;
68
- try {
69
- const payload = await opts.toolsCall({
70
- toolName: state.toolName,
71
- args: { ...(state.args ?? {}) },
72
- });
73
- if (disposed)
74
- return;
75
- emitToBus(state, payload);
76
- }
77
- catch {
78
- // Swallow — next tick may succeed. The renderer's
79
- // observability path already surfaces transport failures
80
- // via the WS error route; per-tool poll failures stay quiet
81
- // to avoid a noisy bus during transient outages.
82
- }
83
- };
84
- // Fire-and-forget the leading tick + schedule the recurring
85
- // ticks. Both are wrapped in the `disposed` guard above.
86
- void tick();
183
+ // Arm BEFORE firing the leading tick. `ToolsCallInvoker` is
184
+ // declared to return a promise, but a non-async implementation
185
+ // that throws satisfies that type, and its failure reaches the
186
+ // tick's catch during THIS function's own synchronous execution.
187
+ // Arming second would leave `pollTimer` null at that moment, and
188
+ // `degradePolling`'s stopped-channel guard would (correctly, for
189
+ // what it can see) ignore it — costing the channel a full degrade
190
+ // cycle. Order here is the only thing that closes that window.
87
191
  state.pollTimer = setInterval(() => {
88
- void tick();
89
- }, defaultPollMs);
192
+ void tick(state);
193
+ }, state.pollDegraded ? probePollMs : defaultPollMs);
194
+ // Fire-and-forget the leading tick. Wrapped in the `disposed`
195
+ // guard above.
196
+ void tick(state);
90
197
  }
91
198
  /**
92
199
  * Send a `channel_subscribe` WS frame. Server-side bookkeeping is
@@ -204,6 +311,7 @@ export function createChannelTransportRouter(opts) {
204
311
  pollTimer: null,
205
312
  hasReceivedWsPayload: false,
206
313
  permanentPollFallback: false,
314
+ pollDegraded: false,
207
315
  };
208
316
  channels.set(k, state);
209
317
  activate(state);
@@ -3,4 +3,3 @@ export { createDrainAckHandler, type DrainAckHandlerDeps, } from './drain-ack.js
3
3
  export { createRenderHandler, type RenderHandlerDeps } from './render-frame.js';
4
4
  export { createDataHandler, type DataHandlerDeps } from './data.js';
5
5
  export { createChannelPayloadHandler, createChannelErrorHandler, type ChannelRouterHandlerDeps, } from './channel-payload.js';
6
- export { createSystemHandler, type SystemHandlerDeps } from './system.js';
@@ -3,4 +3,3 @@ export { createDrainAckHandler, } from './drain-ack.js';
3
3
  export { createRenderHandler } from './render-frame.js';
4
4
  export { createDataHandler } from './data.js';
5
5
  export { createChannelPayloadHandler, createChannelErrorHandler, } from './channel-payload.js';
6
- export { createSystemHandler } from './system.js';
@@ -0,0 +1,52 @@
1
+ /**
2
+ * What can the host we are embedded in actually do? (ggui#440)
3
+ *
4
+ * MCP Apps hosts advertise their capabilities on the `ui/initialize`
5
+ * result. The `App` class captures them; this module is where the
6
+ * runtime keeps that capture so the two decision points that care —
7
+ * the gesture-failure explanation and the `ui/message` doorbell — can
8
+ * consult one source instead of re-deriving it.
9
+ *
10
+ * FAIL-SAFE BY CONTRACT: absence of a capability MUST NOT stop the
11
+ * runtime from attempting the operation. Hosts under-advertise in
12
+ * practice — ggui's own embed host (`./mcp-app-iframe-host.ts`) proxies
13
+ * `tools/call` while advertising nothing — so treating silence as
14
+ * "cannot" would break working paths. These accessors exist to explain
15
+ * a failure that already happened, never to pre-empt an attempt.
16
+ *
17
+ * PRESENCE, NOT VALUE: on `McpUiHostCapabilities` both fields are
18
+ * optional OBJECTS (`serverTools?: { listChanged?: boolean }`,
19
+ * `message?: McpUiSupportedContentBlockModalities`). An empty object is
20
+ * a positive advertisement; `=== true` is never correct here.
21
+ */
22
+ import type { McpUiHostCapabilities } from "@modelcontextprotocol/ext-apps";
23
+ /**
24
+ * Record the host's advertised capabilities. Called once at boot, from
25
+ * `bootSequence`, immediately after `connectApp` resolves — before that
26
+ * point `App.getHostCapabilities()` returns `undefined`.
27
+ */
28
+ export declare function setHostCapabilities(caps: McpUiHostCapabilities | undefined): void;
29
+ /**
30
+ * Did the host advertise that it can proxy a guest `tools/call` to the
31
+ * MCP server? `false` also covers "we have not connected yet" and "the
32
+ * host advertised nothing" — callers must treat it as *unconfirmed*,
33
+ * not as *impossible*.
34
+ */
35
+ export declare function hostCanRelayToolCalls(): boolean;
36
+ /**
37
+ * Did the host advertise that it accepts `ui/message` from the view?
38
+ * This is the only view→host method that can start an agent turn, so a
39
+ * host without it cannot be woken by the doorbell — the user has to
40
+ * send a message themselves.
41
+ */
42
+ export declare function hostCanReceiveMessages(): boolean;
43
+ /**
44
+ * Has the handshake resolved capabilities at all yet? See {@link captured}.
45
+ * Callers that treat capability absence as confirmed incapability (as
46
+ * opposed to merely unconfirmed) must gate on this first — otherwise
47
+ * the pre-handshake window (where absence is just "not asked yet") is
48
+ * indistinguishable from a host that answered and advertised nothing.
49
+ */
50
+ export declare function hostCapabilitiesCaptured(): boolean;
51
+ /** @internal — exported for unit tests to reset module state. */
52
+ export declare function __resetHostCapabilitiesForTest(): void;
@@ -0,0 +1,54 @@
1
+ let hostCapabilities;
2
+ /**
3
+ * Has {@link setHostCapabilities} run yet? Distinct from "the host
4
+ * advertised nothing" — before boot's `connectApp` resolves, this is
5
+ * `false` AND `hostCanRelayToolCalls()` is `false`, which look
6
+ * identical to a silent host unless a caller checks this too. A
7
+ * gesture that fails in that mount-to-handshake window is a timing
8
+ * artifact, not evidence about the host, so callers that latch on
9
+ * confirmed incapability (ggui#440) MUST require this before trusting
10
+ * capability absence.
11
+ */
12
+ let captured = false;
13
+ /**
14
+ * Record the host's advertised capabilities. Called once at boot, from
15
+ * `bootSequence`, immediately after `connectApp` resolves — before that
16
+ * point `App.getHostCapabilities()` returns `undefined`.
17
+ */
18
+ export function setHostCapabilities(caps) {
19
+ hostCapabilities = caps;
20
+ captured = true;
21
+ }
22
+ /**
23
+ * Did the host advertise that it can proxy a guest `tools/call` to the
24
+ * MCP server? `false` also covers "we have not connected yet" and "the
25
+ * host advertised nothing" — callers must treat it as *unconfirmed*,
26
+ * not as *impossible*.
27
+ */
28
+ export function hostCanRelayToolCalls() {
29
+ return hostCapabilities?.serverTools !== undefined;
30
+ }
31
+ /**
32
+ * Did the host advertise that it accepts `ui/message` from the view?
33
+ * This is the only view→host method that can start an agent turn, so a
34
+ * host without it cannot be woken by the doorbell — the user has to
35
+ * send a message themselves.
36
+ */
37
+ export function hostCanReceiveMessages() {
38
+ return hostCapabilities?.message !== undefined;
39
+ }
40
+ /**
41
+ * Has the handshake resolved capabilities at all yet? See {@link captured}.
42
+ * Callers that treat capability absence as confirmed incapability (as
43
+ * opposed to merely unconfirmed) must gate on this first — otherwise
44
+ * the pre-handshake window (where absence is just "not asked yet") is
45
+ * indistinguishable from a host that answered and advertised nothing.
46
+ */
47
+ export function hostCapabilitiesCaptured() {
48
+ return captured;
49
+ }
50
+ /** @internal — exported for unit tests to reset module state. */
51
+ export function __resetHostCapabilitiesForTest() {
52
+ hostCapabilities = undefined;
53
+ captured = false;
54
+ }