@guuey/mcp-apps-host 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/action.d.ts CHANGED
@@ -137,8 +137,28 @@ export interface CreateMcpUiActionRelayDeps {
137
137
  * surfaces a visible "this session ended — start a new chat" state and drops
138
138
  * the stale thread, so a tripped circuit is never a silent frozen card.
139
139
  * Optional: a host that only wants the storm bounded omits it.
140
+ *
141
+ * Once per trip: it fires again only after {@link onSessionRestored} has
142
+ * cleared an earlier verdict for the same locator and the circuit trips anew.
140
143
  */
141
144
  onSessionUnrestorable?: (resourceUri: string) => void;
145
+ /**
146
+ * Fired when a locator whose pull circuit OPENED is seen live again on the
147
+ * pull rung itself, so the host can withdraw the "session ended" state it
148
+ * showed. Wiring it is what lets an open circuit recover in place at all:
149
+ * without it the circuit stays open for the mount (recovery is a fresh
150
+ * mount), exactly as a host that wires only {@link onSessionUnrestorable}
151
+ * expects.
152
+ *
153
+ * The recovery is evidence on the channel that tripped: a successful
154
+ * `ggui_runtime_refresh_ws_token` for the locator HALF-OPENS the circuit,
155
+ * which lets the view's next pull through as a single probe. A probe that
156
+ * the session answers closes the circuit and fires this, once per close; a
157
+ * probe that fails leaves it open, and the host hears nothing new (it
158
+ * already holds the verdict). A token refresh alone never reverses it: the
159
+ * token channel can work while pulls still fail.
160
+ */
161
+ onSessionRestored?: (resourceUri: string) => void;
142
162
  }
143
163
  /** The request shape a mounted card's `onCallTool` bridge produces. */
144
164
  export interface UiActionRequest {
@@ -165,6 +185,20 @@ export declare const PULL_CIRCUIT_THRESHOLD = 3;
165
185
  * and nothing retries unattended.
166
186
  */
167
187
  export declare const UI_ACTION_PULL_CIRCUIT_OPEN = "[guuey] card updates stopped \u2014 the session behind this card could not be restored; the widget reports it, and a new conversation starts fresh";
188
+ /**
189
+ * Logged when an open circuit closes again: the probe pull a successful token
190
+ * refresh let through was answered, and the host was told
191
+ * (`onSessionRestored`). It can only happen for a host that wires that signal.
192
+ */
193
+ export declare const UI_ACTION_PULL_CIRCUIT_CLOSED = "[guuey] card updates resumed \u2014 the session behind this card answered again";
194
+ /**
195
+ * Logged when a host's `onSessionUnrestorable` or `onSessionRestored`
196
+ * callback throws. The relay never rejects into the card, so it goes on; this
197
+ * line is the page's only trace that its own "session ended" state may now be
198
+ * out of step with the card. It names the callback and the error's `name`,
199
+ * never the error's text, which is the host's and can carry anything.
200
+ */
201
+ export declare const UI_ACTION_HOST_CALLBACK_THREW = "[guuey] the page's card-session handler threw \u2014 the card went on, but the page may not show its current state";
168
202
  /**
169
203
  * Assemble the sandbox-facing action relay from a host transport. The
170
204
  * returned function is shaped for an `onCallTool` bridge: it always
@@ -184,6 +218,11 @@ export declare const UI_ACTION_PULL_CIRCUIT_OPEN = "[guuey] card updates stopped
184
218
  * The complement — telling the USER the session is unrestorable so a tripped
185
219
  * circuit is not a silent freeze — is the `onSessionUnrestorable` signal
186
220
  * (guuey#1249 item 4); this leg only bounds the hammer.
221
+ *
222
+ * An OPEN circuit is terminal for the mount unless the host wires
223
+ * `onSessionRestored`. Then a successful `ggui_runtime_refresh_ws_token` for
224
+ * the locator HALF-OPENS it: the next pull goes through as one probe, and only
225
+ * a probe the session answers closes it (see `onSessionRestored`).
187
226
  */
188
227
  export declare function createMcpUiActionRelay(deps: CreateMcpUiActionRelayDeps): (request: UiActionRequest) => Promise<McpToolCallResult>;
189
228
  //# sourceMappingURL=action.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"action.d.ts","sourceRoot":"","sources":["../src/action.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,oFAAoF;AACpF,MAAM,MAAM,kBAAkB,GAC1B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACjD;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,CAC3C;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAChB;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CACnB,CAAC;CACH,CAAC;AAEN;;;;GAIG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACrC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAC9B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,iBAAiB,CAAC,EAAE,wBAAwB,CAAC;CAC9C,CAAC;AAEF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,eAAe,EAAE,WAAW,CAAC,MAAM,CAK9C,CAAC;AAEH,4DAA4D;AAC5D,eAAO,MAAM,iBAAiB,2BAA2B,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,MAAM,qBAAqB,GAAG,MAAM,GAAG,WAAW,GAAG,WAAW,GAAG,mBAAmB,CAAC;AAE7F;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,EAAE,WAAW,CAAC,MAAM,EAAE,qBAAqB,CAYxE,CAAC;AAEH,gDAAgD;AAChD,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAmE1C;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,IAAI,EAAE,wBAAwB,GAAG,SAAS,GACzC,wBAAwB,GAAG,SAAS,CAatC;AAOD;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,EAAE,WAAW,CAAC,MAAM,CAEvD,CAAC;AAEH,yEAAyE;AACzE,eAAO,MAAM,0BAA0B,2CACG,CAAC;AAE3C;;;;GAIG;AACH,wBAAgB,yBAAyB,IAAI,iBAAiB,CAK7D;AAkCD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,iBAAiB,GAAG,SAAS,CAgB9E;AAED,iFAAiF;AACjF,MAAM,WAAW,0BAA0B;IACzC;;;;;OAKG;IACH,QAAQ,EAAE,CACR,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,wBAAwB,GAAG,SAAS,KACvC,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB;;;;;;;OAOG;IACH,qBAAqB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;CACvD;AAED,uEAAuE;AACvE,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,wBAAwB,CAAC;CACtC;AAKD;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,uJACyG,CAAC;AAElJ;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,0BAA0B,GAC/B,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,CA4D1D"}
1
+ {"version":3,"file":"action.d.ts","sourceRoot":"","sources":["../src/action.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,oFAAoF;AACpF,MAAM,MAAM,kBAAkB,GAC1B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACjD;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,QAAQ,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,CAC3C;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,GAChB;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CACnB,CAAC;CACH,CAAC;AAEN;;;;GAIG;AACH,MAAM,MAAM,wBAAwB,GAAG;IACrC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB,CAAC;AAEF;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,EAAE,kBAAkB,EAAE,CAAC;IAC9B,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,iBAAiB,CAAC,EAAE,wBAAwB,CAAC;CAC9C,CAAC;AAEF;;;;;;;;;;;;;;;;;;;GAmBG;AACH,eAAO,MAAM,eAAe,EAAE,WAAW,CAAC,MAAM,CAK9C,CAAC;AAEH,4DAA4D;AAC5D,eAAO,MAAM,iBAAiB,2BAA2B,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,MAAM,qBAAqB,GAAG,MAAM,GAAG,WAAW,GAAG,WAAW,GAAG,mBAAmB,CAAC;AAE7F;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,EAAE,WAAW,CAAC,MAAM,EAAE,qBAAqB,CAYxE,CAAC;AAEH,gDAAgD;AAChD,eAAO,MAAM,uBAAuB,KAAK,CAAC;AAmE1C;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,IAAI,EAAE,wBAAwB,GAAG,SAAS,GACzC,wBAAwB,GAAG,SAAS,CAatC;AAOD;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB,EAAE,WAAW,CAAC,MAAM,CAEvD,CAAC;AAEH,yEAAyE;AACzE,eAAO,MAAM,0BAA0B,2CACG,CAAC;AAE3C;;;;GAIG;AACH,wBAAgB,yBAAyB,IAAI,iBAAiB,CAK7D;AAkCD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,iBAAiB,GAAG,SAAS,CAgB9E;AAED,iFAAiF;AACjF,MAAM,WAAW,0BAA0B;IACzC;;;;;OAKG;IACH,QAAQ,EAAE,CACR,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,wBAAwB,GAAG,SAAS,KACvC,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB;;;;;;;;;;OAUG;IACH,qBAAqB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IACtD;;;;;;;;;;;;;;;OAeG;IACH,iBAAiB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;CACnD;AAED,uEAAuE;AACvE,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,wBAAwB,CAAC;CACtC;AAQD;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,uJACyG,CAAC;AAElJ;;;;GAIG;AACH,eAAO,MAAM,6BAA6B,oFACoC,CAAC;AAE/E;;;;;;GAMG;AACH,eAAO,MAAM,6BAA6B,uHACuE,CAAC;AAmBlH;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,0BAA0B,GAC/B,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,iBAAiB,CAAC,CAgF1D"}
package/dist/action.js CHANGED
@@ -229,6 +229,8 @@ export function asToolCallResult(value) {
229
229
  }
230
230
  /** The host-relayed auto-poll rung (guuey#1235). A dead session's pull is the storm. */
231
231
  const PULL_TOOL = "ggui_runtime_pull";
232
+ /** The live-channel token refresh: a success half-opens a tripped locator for a host that can hear a restore. */
233
+ const REFRESH_WS_TOKEN_TOOL = "ggui_runtime_refresh_ws_token";
232
234
  /**
233
235
  * Consecutive `unavailable` pull results (per card locator) that OPEN the
234
236
  * circuit (guuey#1235 leg 1). At the #1233 storm's ~12-18 pulls/min a 3-strike
@@ -247,6 +249,33 @@ export const PULL_CIRCUIT_THRESHOLD = 3;
247
249
  * and nothing retries unattended.
248
250
  */
249
251
  export const UI_ACTION_PULL_CIRCUIT_OPEN = "[guuey] card updates stopped — the session behind this card could not be restored; the widget reports it, and a new conversation starts fresh";
252
+ /**
253
+ * Logged when an open circuit closes again: the probe pull a successful token
254
+ * refresh let through was answered, and the host was told
255
+ * (`onSessionRestored`). It can only happen for a host that wires that signal.
256
+ */
257
+ export const UI_ACTION_PULL_CIRCUIT_CLOSED = "[guuey] card updates resumed — the session behind this card answered again";
258
+ /**
259
+ * Logged when a host's `onSessionUnrestorable` or `onSessionRestored`
260
+ * callback throws. The relay never rejects into the card, so it goes on; this
261
+ * line is the page's only trace that its own "session ended" state may now be
262
+ * out of step with the card. It names the callback and the error's `name`,
263
+ * never the error's text, which is the host's and can carry anything.
264
+ */
265
+ export const UI_ACTION_HOST_CALLBACK_THREW = "[guuey] the page's card-session handler threw — the card went on, but the page may not show its current state";
266
+ /** Call a host's session callback; a throw is logged by name and never escapes the relay. */
267
+ function callHostCallback(callback, fn, resourceUri) {
268
+ try {
269
+ fn?.(resourceUri);
270
+ }
271
+ catch (err) {
272
+ console.warn(UI_ACTION_HOST_CALLBACK_THREW, {
273
+ callback,
274
+ resourceUri,
275
+ name: err instanceof Error ? err.name : typeof err,
276
+ });
277
+ }
278
+ }
250
279
  /**
251
280
  * Assemble the sandbox-facing action relay from a host transport. The
252
281
  * returned function is shaped for an `onCallTool` bridge: it always
@@ -266,14 +295,29 @@ export const UI_ACTION_PULL_CIRCUIT_OPEN = "[guuey] card updates stopped — the
266
295
  * The complement — telling the USER the session is unrestorable so a tripped
267
296
  * circuit is not a silent freeze — is the `onSessionUnrestorable` signal
268
297
  * (guuey#1249 item 4); this leg only bounds the hammer.
298
+ *
299
+ * An OPEN circuit is terminal for the mount unless the host wires
300
+ * `onSessionRestored`. Then a successful `ggui_runtime_refresh_ws_token` for
301
+ * the locator HALF-OPENS it: the next pull goes through as one probe, and only
302
+ * a probe the session answers closes it (see `onSessionRestored`).
269
303
  */
270
304
  export function createMcpUiActionRelay(deps) {
271
305
  // Per-relay-instance (one card mount). A recovered/absent locator is deleted,
272
306
  // so this stays as small as the mounted cards; the mount tears it down.
273
307
  const pullFailures = new Map();
308
+ // Open locators a successful token refresh has half-opened: the next pull is
309
+ // let through as the probe. Only ever filled when the host wires the restore.
310
+ const halfOpen = new Set();
311
+ const isOpen = (uri) => (pullFailures.get(uri) ?? 0) >= PULL_CIRCUIT_THRESHOLD;
274
312
  const recordPull = (uri, unavailable) => {
275
313
  if (!unavailable) {
314
+ const wasOpen = isOpen(uri);
276
315
  pullFailures.delete(uri); // the session answered → close the circuit
316
+ if (wasOpen) {
317
+ console.warn(UI_ACTION_PULL_CIRCUIT_CLOSED, { resourceUri: uri });
318
+ // The circuit is closed either way: a throwing host callback is logged, never rethrown.
319
+ callHostCallback("onSessionRestored", deps.onSessionRestored, uri);
320
+ }
277
321
  return;
278
322
  }
279
323
  const next = (pullFailures.get(uri) ?? 0) + 1;
@@ -287,13 +331,8 @@ export function createMcpUiActionRelay(deps) {
287
331
  // guuey#1249 item 4: tell the host the session is unrestorable so the
288
332
  // bounded circuit isn't a silent freeze. A throw from the host callback
289
333
  // must never break the relay's never-reject contract.
290
- try {
291
- deps.onSessionUnrestorable?.(uri);
292
- }
293
- catch {
294
- // A host-supplied callback that throws is the host's bug, not the
295
- // relay's — the storm is already bounded either way.
296
- }
334
+ // The storm is bounded either way: a throwing host callback is logged, never rethrown.
335
+ callHostCallback("onSessionUnrestorable", deps.onSessionUnrestorable, uri);
297
336
  }
298
337
  };
299
338
  return async (request) => {
@@ -311,9 +350,11 @@ export function createMcpUiActionRelay(deps) {
311
350
  return telemetryNothingToRelayResult();
312
351
  }
313
352
  const isPull = request.name === PULL_TOOL;
314
- // Circuit OPEN for this locator's pull → fail-fast, never touch the door.
315
- if (isPull && (pullFailures.get(request.resourceUri) ?? 0) >= PULL_CIRCUIT_THRESHOLD) {
316
- return unavailableToolCallResult();
353
+ // Circuit OPEN for this locator's pull → fail-fast, never touch the door,
354
+ // unless a token refresh half-opened it: then this one pull is the probe.
355
+ if (isPull && isOpen(request.resourceUri)) {
356
+ if (!halfOpen.delete(request.resourceUri))
357
+ return unavailableToolCallResult();
317
358
  }
318
359
  let raw;
319
360
  try {
@@ -327,6 +368,16 @@ export function createMcpUiActionRelay(deps) {
327
368
  const result = raw === undefined ? undefined : asToolCallResult(raw);
328
369
  if (isPull)
329
370
  recordPull(request.resourceUri, result === undefined);
371
+ // A refresh the session granted is fresh evidence it is live, but on the
372
+ // token channel, not the pull channel that tripped: it half-opens, and the
373
+ // probe pull decides. Only for a host that can withdraw its verdict.
374
+ if (request.name === REFRESH_WS_TOKEN_TOOL &&
375
+ deps.onSessionRestored !== undefined &&
376
+ result !== undefined &&
377
+ result.isError !== true &&
378
+ isOpen(request.resourceUri)) {
379
+ halfOpen.add(request.resourceUri);
380
+ }
330
381
  return result ?? unavailableToolCallResult();
331
382
  };
332
383
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guuey/mcp-apps-host",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "description": "The MCP Apps (SEP-1865) Host role for guuey's chat surfaces — view-mount narrowing across UI channels, ui:// locator rehydration by resources/read, and the sandbox-trust channel contract. Vendor-neutral: any spec-following MCP App mounts through it.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,9 @@
12
12
  "!src/**/*.test.ts",
13
13
  "README.md"
14
14
  ],
15
+ "engines": {
16
+ "node": ">=22.12.0"
17
+ },
15
18
  "exports": {
16
19
  ".": {
17
20
  "react-native": "./src/index.ts",
@@ -35,7 +38,7 @@
35
38
  "dependencies": {
36
39
  "@ggui-ai/protocol": "0.24.0",
37
40
  "@modelcontextprotocol/ext-apps": "^1.7.5",
38
- "@silverprotocol/core": "0.11.0"
41
+ "@silverprotocol/core": "0.12.2"
39
42
  },
40
43
  "peerDependencies": {
41
44
  "react": ">=18"
package/src/action.ts CHANGED
@@ -305,8 +305,28 @@ export interface CreateMcpUiActionRelayDeps {
305
305
  * surfaces a visible "this session ended — start a new chat" state and drops
306
306
  * the stale thread, so a tripped circuit is never a silent frozen card.
307
307
  * Optional: a host that only wants the storm bounded omits it.
308
+ *
309
+ * Once per trip: it fires again only after {@link onSessionRestored} has
310
+ * cleared an earlier verdict for the same locator and the circuit trips anew.
308
311
  */
309
312
  onSessionUnrestorable?: (resourceUri: string) => void;
313
+ /**
314
+ * Fired when a locator whose pull circuit OPENED is seen live again on the
315
+ * pull rung itself, so the host can withdraw the "session ended" state it
316
+ * showed. Wiring it is what lets an open circuit recover in place at all:
317
+ * without it the circuit stays open for the mount (recovery is a fresh
318
+ * mount), exactly as a host that wires only {@link onSessionUnrestorable}
319
+ * expects.
320
+ *
321
+ * The recovery is evidence on the channel that tripped: a successful
322
+ * `ggui_runtime_refresh_ws_token` for the locator HALF-OPENS the circuit,
323
+ * which lets the view's next pull through as a single probe. A probe that
324
+ * the session answers closes the circuit and fires this, once per close; a
325
+ * probe that fails leaves it open, and the host hears nothing new (it
326
+ * already holds the verdict). A token refresh alone never reverses it: the
327
+ * token channel can work while pulls still fail.
328
+ */
329
+ onSessionRestored?: (resourceUri: string) => void;
310
330
  }
311
331
 
312
332
  /** The request shape a mounted card's `onCallTool` bridge produces. */
@@ -320,6 +340,9 @@ export interface UiActionRequest {
320
340
  /** The host-relayed auto-poll rung (guuey#1235). A dead session's pull is the storm. */
321
341
  const PULL_TOOL = "ggui_runtime_pull";
322
342
 
343
+ /** The live-channel token refresh: a success half-opens a tripped locator for a host that can hear a restore. */
344
+ const REFRESH_WS_TOKEN_TOOL = "ggui_runtime_refresh_ws_token";
345
+
323
346
  /**
324
347
  * Consecutive `unavailable` pull results (per card locator) that OPEN the
325
348
  * circuit (guuey#1235 leg 1). At the #1233 storm's ~12-18 pulls/min a 3-strike
@@ -341,6 +364,41 @@ export const PULL_CIRCUIT_THRESHOLD = 3;
341
364
  export const UI_ACTION_PULL_CIRCUIT_OPEN =
342
365
  "[guuey] card updates stopped — the session behind this card could not be restored; the widget reports it, and a new conversation starts fresh";
343
366
 
367
+ /**
368
+ * Logged when an open circuit closes again: the probe pull a successful token
369
+ * refresh let through was answered, and the host was told
370
+ * (`onSessionRestored`). It can only happen for a host that wires that signal.
371
+ */
372
+ export const UI_ACTION_PULL_CIRCUIT_CLOSED =
373
+ "[guuey] card updates resumed — the session behind this card answered again";
374
+
375
+ /**
376
+ * Logged when a host's `onSessionUnrestorable` or `onSessionRestored`
377
+ * callback throws. The relay never rejects into the card, so it goes on; this
378
+ * line is the page's only trace that its own "session ended" state may now be
379
+ * out of step with the card. It names the callback and the error's `name`,
380
+ * never the error's text, which is the host's and can carry anything.
381
+ */
382
+ export const UI_ACTION_HOST_CALLBACK_THREW =
383
+ "[guuey] the page's card-session handler threw — the card went on, but the page may not show its current state";
384
+
385
+ /** Call a host's session callback; a throw is logged by name and never escapes the relay. */
386
+ function callHostCallback(
387
+ callback: "onSessionUnrestorable" | "onSessionRestored",
388
+ fn: ((resourceUri: string) => void) | undefined,
389
+ resourceUri: string,
390
+ ): void {
391
+ try {
392
+ fn?.(resourceUri);
393
+ } catch (err) {
394
+ console.warn(UI_ACTION_HOST_CALLBACK_THREW, {
395
+ callback,
396
+ resourceUri,
397
+ name: err instanceof Error ? err.name : typeof err,
398
+ });
399
+ }
400
+ }
401
+
344
402
  /**
345
403
  * Assemble the sandbox-facing action relay from a host transport. The
346
404
  * returned function is shaped for an `onCallTool` bridge: it always
@@ -360,6 +418,11 @@ export const UI_ACTION_PULL_CIRCUIT_OPEN =
360
418
  * The complement — telling the USER the session is unrestorable so a tripped
361
419
  * circuit is not a silent freeze — is the `onSessionUnrestorable` signal
362
420
  * (guuey#1249 item 4); this leg only bounds the hammer.
421
+ *
422
+ * An OPEN circuit is terminal for the mount unless the host wires
423
+ * `onSessionRestored`. Then a successful `ggui_runtime_refresh_ws_token` for
424
+ * the locator HALF-OPENS it: the next pull goes through as one probe, and only
425
+ * a probe the session answers closes it (see `onSessionRestored`).
363
426
  */
364
427
  export function createMcpUiActionRelay(
365
428
  deps: CreateMcpUiActionRelayDeps,
@@ -367,10 +430,21 @@ export function createMcpUiActionRelay(
367
430
  // Per-relay-instance (one card mount). A recovered/absent locator is deleted,
368
431
  // so this stays as small as the mounted cards; the mount tears it down.
369
432
  const pullFailures = new Map<string, number>();
433
+ // Open locators a successful token refresh has half-opened: the next pull is
434
+ // let through as the probe. Only ever filled when the host wires the restore.
435
+ const halfOpen = new Set<string>();
436
+
437
+ const isOpen = (uri: string): boolean => (pullFailures.get(uri) ?? 0) >= PULL_CIRCUIT_THRESHOLD;
370
438
 
371
439
  const recordPull = (uri: string, unavailable: boolean): void => {
372
440
  if (!unavailable) {
441
+ const wasOpen = isOpen(uri);
373
442
  pullFailures.delete(uri); // the session answered → close the circuit
443
+ if (wasOpen) {
444
+ console.warn(UI_ACTION_PULL_CIRCUIT_CLOSED, { resourceUri: uri });
445
+ // The circuit is closed either way: a throwing host callback is logged, never rethrown.
446
+ callHostCallback("onSessionRestored", deps.onSessionRestored, uri);
447
+ }
374
448
  return;
375
449
  }
376
450
  const next = (pullFailures.get(uri) ?? 0) + 1;
@@ -384,12 +458,8 @@ export function createMcpUiActionRelay(
384
458
  // guuey#1249 item 4: tell the host the session is unrestorable so the
385
459
  // bounded circuit isn't a silent freeze. A throw from the host callback
386
460
  // must never break the relay's never-reject contract.
387
- try {
388
- deps.onSessionUnrestorable?.(uri);
389
- } catch {
390
- // A host-supplied callback that throws is the host's bug, not the
391
- // relay's — the storm is already bounded either way.
392
- }
461
+ // The storm is bounded either way: a throwing host callback is logged, never rethrown.
462
+ callHostCallback("onSessionUnrestorable", deps.onSessionUnrestorable, uri);
393
463
  }
394
464
  };
395
465
 
@@ -407,9 +477,10 @@ export function createMcpUiActionRelay(
407
477
  }
408
478
 
409
479
  const isPull = request.name === PULL_TOOL;
410
- // Circuit OPEN for this locator's pull → fail-fast, never touch the door.
411
- if (isPull && (pullFailures.get(request.resourceUri) ?? 0) >= PULL_CIRCUIT_THRESHOLD) {
412
- return unavailableToolCallResult();
480
+ // Circuit OPEN for this locator's pull → fail-fast, never touch the door,
481
+ // unless a token refresh half-opened it: then this one pull is the probe.
482
+ if (isPull && isOpen(request.resourceUri)) {
483
+ if (!halfOpen.delete(request.resourceUri)) return unavailableToolCallResult();
413
484
  }
414
485
 
415
486
  let raw: unknown;
@@ -421,6 +492,18 @@ export function createMcpUiActionRelay(
421
492
  }
422
493
  const result = raw === undefined ? undefined : asToolCallResult(raw);
423
494
  if (isPull) recordPull(request.resourceUri, result === undefined);
495
+ // A refresh the session granted is fresh evidence it is live, but on the
496
+ // token channel, not the pull channel that tripped: it half-opens, and the
497
+ // probe pull decides. Only for a host that can withdraw its verdict.
498
+ if (
499
+ request.name === REFRESH_WS_TOKEN_TOOL &&
500
+ deps.onSessionRestored !== undefined &&
501
+ result !== undefined &&
502
+ result.isError !== true &&
503
+ isOpen(request.resourceUri)
504
+ ) {
505
+ halfOpen.add(request.resourceUri);
506
+ }
424
507
  return result ?? unavailableToolCallResult();
425
508
  };
426
509
  }