@agent-native/core 0.116.0 → 0.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +15 -0
  3. package/corpus/core/package.json +4 -1
  4. package/corpus/core/src/client/analytics.ts +7 -0
  5. package/corpus/core/src/client/use-db-sync.ts +354 -15
  6. package/corpus/core/src/collab/client.ts +26 -2
  7. package/corpus/core/src/db/ddl-guard.ts +49 -10
  8. package/corpus/core/src/deploy/build.ts +25 -2
  9. package/corpus/core/src/realtime-protocol.ts +92 -0
  10. package/corpus/core/src/server/core-routes-plugin.ts +7 -0
  11. package/corpus/core/src/server/poll-events.ts +29 -8
  12. package/corpus/core/src/server/poll.ts +1123 -865
  13. package/corpus/core/src/server/realtime-token.ts +106 -0
  14. package/corpus/core/src/server/sentry-config.ts +40 -0
  15. package/corpus/core/src/server/short-lived-token.ts +156 -0
  16. package/corpus/core/src/server/sse.ts +15 -0
  17. package/corpus/core/src/server/ssr-handler.ts +9 -3
  18. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +12 -0
  19. package/corpus/templates/clips/app/i18n/en-US.ts +1 -0
  20. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +566 -299
  21. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +14 -0
  22. package/corpus/templates/content/app/i18n-data.ts +90 -0
  23. package/dist/client/analytics.d.ts +10 -0
  24. package/dist/client/analytics.d.ts.map +1 -1
  25. package/dist/client/analytics.js.map +1 -1
  26. package/dist/client/use-db-sync.d.ts +1 -1
  27. package/dist/client/use-db-sync.d.ts.map +1 -1
  28. package/dist/client/use-db-sync.js +315 -15
  29. package/dist/client/use-db-sync.js.map +1 -1
  30. package/dist/collab/client.d.ts.map +1 -1
  31. package/dist/collab/client.js +26 -2
  32. package/dist/collab/client.js.map +1 -1
  33. package/dist/db/ddl-guard.d.ts +7 -2
  34. package/dist/db/ddl-guard.d.ts.map +1 -1
  35. package/dist/db/ddl-guard.js +21 -9
  36. package/dist/db/ddl-guard.js.map +1 -1
  37. package/dist/deploy/build.d.ts.map +1 -1
  38. package/dist/deploy/build.js +25 -2
  39. package/dist/deploy/build.js.map +1 -1
  40. package/dist/notifications/routes.d.ts +3 -3
  41. package/dist/observability/routes.d.ts +5 -5
  42. package/dist/progress/routes.d.ts +1 -1
  43. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  44. package/dist/provider-api/actions/provider-api.d.ts +6 -6
  45. package/dist/realtime-protocol.d.ts +40 -0
  46. package/dist/realtime-protocol.d.ts.map +1 -0
  47. package/dist/realtime-protocol.js +69 -0
  48. package/dist/realtime-protocol.js.map +1 -0
  49. package/dist/resources/handlers.d.ts +1 -1
  50. package/dist/secrets/routes.d.ts +3 -3
  51. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  52. package/dist/server/core-routes-plugin.js +3 -0
  53. package/dist/server/core-routes-plugin.js.map +1 -1
  54. package/dist/server/poll-events.d.ts +7 -1
  55. package/dist/server/poll-events.d.ts.map +1 -1
  56. package/dist/server/poll-events.js +24 -7
  57. package/dist/server/poll-events.js.map +1 -1
  58. package/dist/server/poll.d.ts +223 -42
  59. package/dist/server/poll.d.ts.map +1 -1
  60. package/dist/server/poll.js +849 -737
  61. package/dist/server/poll.js.map +1 -1
  62. package/dist/server/realtime-token.d.ts +34 -0
  63. package/dist/server/realtime-token.d.ts.map +1 -0
  64. package/dist/server/realtime-token.js +85 -0
  65. package/dist/server/realtime-token.js.map +1 -0
  66. package/dist/server/sentry-config.d.ts +12 -0
  67. package/dist/server/sentry-config.d.ts.map +1 -1
  68. package/dist/server/sentry-config.js +34 -0
  69. package/dist/server/sentry-config.js.map +1 -1
  70. package/dist/server/short-lived-token.d.ts +46 -0
  71. package/dist/server/short-lived-token.d.ts.map +1 -1
  72. package/dist/server/short-lived-token.js +104 -0
  73. package/dist/server/short-lived-token.js.map +1 -1
  74. package/dist/server/sse.d.ts +1 -1
  75. package/dist/server/sse.d.ts.map +1 -1
  76. package/dist/server/sse.js +4 -0
  77. package/dist/server/sse.js.map +1 -1
  78. package/dist/server/ssr-handler.d.ts.map +1 -1
  79. package/dist/server/ssr-handler.js +5 -3
  80. package/dist/server/ssr-handler.js.map +1 -1
  81. package/dist/server/transcribe-voice.d.ts +1 -1
  82. package/package.json +4 -1
  83. package/src/client/analytics.ts +7 -0
  84. package/src/client/use-db-sync.ts +354 -15
  85. package/src/collab/client.ts +26 -2
  86. package/src/db/ddl-guard.ts +49 -10
  87. package/src/deploy/build.ts +25 -2
  88. package/src/realtime-protocol.ts +92 -0
  89. package/src/server/core-routes-plugin.ts +7 -0
  90. package/src/server/poll-events.ts +29 -8
  91. package/src/server/poll.ts +1123 -865
  92. package/src/server/realtime-token.ts +106 -0
  93. package/src/server/sentry-config.ts +40 -0
  94. package/src/server/short-lived-token.ts +156 -0
  95. package/src/server/sse.ts +15 -0
  96. package/src/server/ssr-handler.ts +9 -3
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import { ensureDemoModeFetchInterceptor } from "../demo/fetch-interceptor.js";
3
+ import { parseHandshakeFrame, parseTokenFrame, REALTIME_PROTOCOL_VERSION, REALTIME_SSE_HANDSHAKE_EVENT, REALTIME_SSE_TOKEN_EVENT, } from "../realtime-protocol.js";
3
4
  import { agentNativePath } from "./api-path.js";
4
5
  import { getBrowserTabId } from "./browser-tab-id.js";
5
6
  import { ensureEmbedAuthFetchInterceptor, isEmbedAuthActive, } from "./embed-auth.js";
@@ -39,6 +40,51 @@ function resolveSseUrl(sseUrl) {
39
40
  return false;
40
41
  return agentNativePath(sseUrl ?? "/_agent-native/events");
41
42
  }
43
+ // --- Hosted Realtime Gateway binding ----------------------------------------
44
+ //
45
+ // When the app is configured for the hosted gateway, the transport connects to
46
+ // the gateway (cross-origin) instead of the Netlify app, carrying a short-lived
47
+ // subscribe token minted from the app's own same-origin endpoint. All of this
48
+ // is gated on a non-null binding — apps without hosted config keep the exact
49
+ // local behavior below.
50
+ const REALTIME_GATEWAY_SSE_PATH = "/stream";
51
+ const REALTIME_GATEWAY_POLL_PATH = "/poll";
52
+ const REALTIME_TOKEN_MINT_PATH = "/_agent-native/realtime-token";
53
+ /** Consecutive gateway failures before health-gating back to the local app. */
54
+ const HOSTED_UNHEALTHY_THRESHOLD = 3;
55
+ function getRealtimeConfig() {
56
+ if (typeof window === "undefined")
57
+ return undefined;
58
+ return window.__AGENT_NATIVE_CONFIG__?.realtime;
59
+ }
60
+ /**
61
+ * Resolve the hosted-gateway binding, or null to stay on the local app. Gated
62
+ * on: SSE enabled (the gateway is push-first), not embed auth (needs the
63
+ * same-origin session to mint), and `transport: "hosted"` with a base URL in
64
+ * the impersonal SSR config.
65
+ */
66
+ function resolveGatewayBinding(localSseUrl) {
67
+ if (localSseUrl === false)
68
+ return null;
69
+ if (isEmbedAuthActive())
70
+ return null;
71
+ const config = getRealtimeConfig();
72
+ if (config?.transport !== "hosted")
73
+ return null;
74
+ const base = config.gatewayBaseUrl?.replace(/\/+$/, "");
75
+ if (!base)
76
+ return null;
77
+ return {
78
+ sseUrl: `${base}${REALTIME_GATEWAY_SSE_PATH}`,
79
+ pollUrl: `${base}${REALTIME_GATEWAY_POLL_PATH}`,
80
+ tokenMintUrl: agentNativePath(REALTIME_TOKEN_MINT_PATH),
81
+ };
82
+ }
83
+ /** ±20% jitter so gateway timeout/deploy-driven reconnects don't stampede. */
84
+ function applyReconnectJitter(delay) {
85
+ const jitter = delay * 0.2 * (Math.random() * 2 - 1);
86
+ return Math.max(0, Math.round(delay + jitter));
87
+ }
42
88
  function normalizeEventPayload(payload) {
43
89
  if (!payload || typeof payload !== "object")
44
90
  return [];
@@ -91,13 +137,19 @@ export function isInteractionCriticalSyncEvent(event) {
91
137
  INTERACTION_CRITICAL_APP_STATE_KEYS.some((key) => event.key === key ||
92
138
  (typeof event.key === "string" && event.key.startsWith(`${key}:`)))));
93
139
  }
94
- async function fetchPollJson(pollUrl, since, interval) {
140
+ async function fetchPollJson(pollUrl, since, interval, token) {
95
141
  const controller = typeof AbortController === "undefined" ? null : new AbortController();
96
142
  const timeout = controller
97
143
  ? setTimeout(() => controller.abort(), getPollAbortMs(interval))
98
144
  : null;
145
+ // Local path stays exactly `?since=N`; the hosted gateway also carries the
146
+ // subscribe token on the query string (a cross-origin fetch can't set the
147
+ // Authorization header for the SSE sibling either, so both use the query).
148
+ const url = token
149
+ ? `${pollUrl}${pollUrl.includes("?") ? "&" : "?"}since=${since}&token=${encodeURIComponent(token)}`
150
+ : `${pollUrl}?since=${since}`;
99
151
  try {
100
- const res = await fetch(`${pollUrl}?since=${since}`, controller ? { signal: controller.signal } : undefined);
152
+ const res = await fetch(url, controller ? { signal: controller.signal } : undefined);
101
153
  if (!res.ok)
102
154
  throw new HttpStatusError(res.status);
103
155
  // Await the json before the finally so a body-stream abort doesn't
@@ -112,6 +164,7 @@ async function fetchPollJson(pollUrl, since, interval) {
112
164
  class SyncTransport {
113
165
  pollUrl;
114
166
  sseUrl;
167
+ gateway;
115
168
  subscribers = new Map();
116
169
  versionRef = 0;
117
170
  timer = null;
@@ -122,9 +175,145 @@ class SyncTransport {
122
175
  authFailureUntil = 0;
123
176
  consecutiveFailures = 0;
124
177
  activeChatIds = new Set();
125
- constructor(pollUrl, sseUrl) {
178
+ // Hosted-gateway state. `mode` starts "hosted" when a binding is present and
179
+ // flips to "local" on health-gate revert; `token` is the current subscribe
180
+ // token (minted from the app, rotated over the stream), never part of any
181
+ // registry key.
182
+ mode;
183
+ token = null;
184
+ tokenMintInFlight = null;
185
+ gatewayReconnectTimer = null;
186
+ capabilities = [];
187
+ constructor(pollUrl, sseUrl, gateway = null) {
126
188
  this.pollUrl = pollUrl;
127
189
  this.sseUrl = sseUrl;
190
+ this.gateway = gateway;
191
+ this.mode = gateway ? "hosted" : "local";
192
+ }
193
+ /** Capabilities advertised by the gateway handshake (e.g. `no-awareness`). */
194
+ getCapabilities() {
195
+ return this.capabilities;
196
+ }
197
+ get activeSseUrl() {
198
+ if (this.mode === "hosted" && this.gateway) {
199
+ return this.token
200
+ ? `${this.gateway.sseUrl}?token=${encodeURIComponent(this.token)}`
201
+ : this.gateway.sseUrl;
202
+ }
203
+ return this.sseUrl;
204
+ }
205
+ get activePollUrl() {
206
+ return this.mode === "hosted" && this.gateway
207
+ ? this.gateway.pollUrl
208
+ : this.pollUrl;
209
+ }
210
+ /**
211
+ * Mint a subscribe token from the app's same-origin endpoint.
212
+ *
213
+ * Only TERMINAL outcomes health-gate to local: 404 (gateway not provisioned)
214
+ * and 401/403 (not authorized) — retrying those for this tab is pointless.
215
+ * TRANSIENT failures (5xx/429 from a cold Netlify function, network errors)
216
+ * keep the hosted intent and ride the jittered reconnect + unhealthy-threshold
217
+ * path, so a deploy / scale-to-zero blip doesn't permanently abandon the
218
+ * gateway for the tab.
219
+ */
220
+ mintToken() {
221
+ if (!this.gateway || this.mode !== "hosted")
222
+ return Promise.resolve(false);
223
+ if (this.tokenMintInFlight)
224
+ return this.tokenMintInFlight;
225
+ const mintUrl = this.gateway.tokenMintUrl;
226
+ this.tokenMintInFlight = (async () => {
227
+ // Bound the mint like fetchPollJson bounds polls: a black-holed request
228
+ // must resolve as a transient failure, not hang tokenMintInFlight forever
229
+ // (poll() awaits this while holding inFlight, so a hung mint would stall
230
+ // the whole transport with no timer pending).
231
+ const controller = typeof AbortController === "undefined" ? null : new AbortController();
232
+ const timeout = controller
233
+ ? setTimeout(() => controller.abort(), POLL_ABORT_MIN_MS)
234
+ : null;
235
+ try {
236
+ const res = await fetch(mintUrl, {
237
+ credentials: "same-origin",
238
+ ...(controller ? { signal: controller.signal } : {}),
239
+ });
240
+ if (res.ok) {
241
+ const data = (await res.json());
242
+ if (typeof data?.token === "string" && data.token) {
243
+ this.token = data.token;
244
+ // Deliberately NOT resetting consecutiveFailures here: minting
245
+ // succeeds via the app origin even when the GATEWAY is down, so a
246
+ // reset would let a mint-ok -> stream-fail loop run forever below
247
+ // the unhealthy threshold. Only real gateway connectivity (stream
248
+ // onopen / poll success) clears the count.
249
+ return true;
250
+ }
251
+ // 2xx without a token is a terminal misconfiguration.
252
+ this.revertToLocal();
253
+ return false;
254
+ }
255
+ if (res.status === 404 || res.status === 401 || res.status === 403) {
256
+ this.revertToLocal();
257
+ return false;
258
+ }
259
+ this.onGatewayTransientFailure();
260
+ return false;
261
+ }
262
+ catch {
263
+ this.onGatewayTransientFailure();
264
+ return false;
265
+ }
266
+ finally {
267
+ if (timeout)
268
+ clearTimeout(timeout);
269
+ this.tokenMintInFlight = null;
270
+ }
271
+ })();
272
+ return this.tokenMintInFlight;
273
+ }
274
+ /**
275
+ * A transient gateway failure (mint 5xx/429, network). Keep hosted intent but
276
+ * count toward the unhealthy threshold; revert to local only once it trips.
277
+ */
278
+ onGatewayTransientFailure() {
279
+ this.consecutiveFailures++;
280
+ if (this.consecutiveFailures >= HOSTED_UNHEALTHY_THRESHOLD) {
281
+ this.revertToLocal();
282
+ }
283
+ }
284
+ /**
285
+ * Health-gate back to the app's own /poll + /events with the cursor intact
286
+ * (versionRef is untouched), so delivery stays poll-equivalent — never a
287
+ * silent stall.
288
+ */
289
+ revertToLocal() {
290
+ if (this.mode === "local")
291
+ return;
292
+ this.mode = "local";
293
+ this.token = null;
294
+ // The local in-process SSE path sends no handshake, so hosted capabilities
295
+ // (e.g. no-awareness) must not survive the fallback — stale caps would keep
296
+ // collab on its fast presence cadence against the local stream. Subscribers
297
+ // are re-notified via the close/connect cycle below.
298
+ this.capabilities = [];
299
+ if (this.gatewayReconnectTimer) {
300
+ clearTimeout(this.gatewayReconnectTimer);
301
+ this.gatewayReconnectTimer = null;
302
+ }
303
+ this.closeEvents();
304
+ if (!this.stopped) {
305
+ this.connectEvents();
306
+ this.schedulePoll();
307
+ }
308
+ }
309
+ scheduleGatewayReconnect() {
310
+ if (this.stopped || this.gatewayReconnectTimer)
311
+ return;
312
+ this.gatewayReconnectTimer = setTimeout(() => {
313
+ this.gatewayReconnectTimer = null;
314
+ if (!this.stopped && !this.eventSource)
315
+ this.connectEvents();
316
+ }, applyReconnectJitter(1000));
128
317
  }
129
318
  // -------------------------------------------------------------------------
130
319
  // Subscriber management
@@ -145,7 +334,7 @@ class SyncTransport {
145
334
  else {
146
335
  this.reschedule();
147
336
  }
148
- sub.onSseStateChange?.(this.sseConnected);
337
+ sub.onSseStateChange?.(this.sseConnected, this.capabilities);
149
338
  }
150
339
  remove(id) {
151
340
  this.subscribers.delete(id);
@@ -208,8 +397,17 @@ class SyncTransport {
208
397
  if (this.sseConnected === connected)
209
398
  return;
210
399
  this.sseConnected = connected;
400
+ this.notifySseState();
401
+ }
402
+ /**
403
+ * Notify subscribers of the current SSE state AND the negotiated gateway
404
+ * capabilities. Called on connect/disconnect and again once the handshake
405
+ * arrives, so a consumer (e.g. collab) can decide — for instance — not to
406
+ * relax its presence cadence on a `no-awareness` hosted stream.
407
+ */
408
+ notifySseState() {
211
409
  for (const sub of this.subscribers.values()) {
212
- sub.onSseStateChange?.(connected);
410
+ sub.onSseStateChange?.(this.sseConnected, this.capabilities);
213
411
  }
214
412
  }
215
413
  // -------------------------------------------------------------------------
@@ -245,9 +443,13 @@ class SyncTransport {
245
443
  // DNS blips, a struggling DB). Auth failures have their own cooldown
246
444
  // above; this covers everything else so a down server isn't hammered at
247
445
  // full cadence. Resets on the first successful poll.
248
- const delay = this.consecutiveFailures > 0
446
+ const backoff = this.consecutiveFailures > 0
249
447
  ? Math.min(base * 2 ** Math.min(this.consecutiveFailures, 5), 300_000)
250
448
  : base;
449
+ // Jitter only for gateway-capable transports so reconnect/poll retries
450
+ // don't stampede a gateway deploy; apps with no gateway config keep the
451
+ // exact deterministic cadence.
452
+ const delay = this.gateway ? applyReconnectJitter(backoff) : backoff;
251
453
  this.timer = setTimeout(() => {
252
454
  this.timer = null;
253
455
  void this.poll();
@@ -271,16 +473,44 @@ class SyncTransport {
271
473
  }
272
474
  connectEvents() {
273
475
  if (this.stopped ||
274
- !this.sseUrl ||
275
476
  this.eventSource ||
276
477
  typeof EventSource === "undefined" ||
277
478
  (this.effectivePauseWhenHidden && isDocumentHidden())) {
278
479
  return;
279
480
  }
280
- const source = new EventSource(this.sseUrl);
481
+ // Hosted gateway needs a subscribe token before the stream can open.
482
+ // EventSource can't set headers, so the token rides the connect query
483
+ // string (see activeSseUrl). Mint first, then connect.
484
+ if (this.mode === "hosted" && this.gateway && !this.token) {
485
+ void this.mintToken().then((ok) => {
486
+ if (this.stopped)
487
+ return;
488
+ if (ok && !this.eventSource) {
489
+ this.connectEvents();
490
+ }
491
+ else if (!ok && this.mode === "hosted") {
492
+ // Transient mint failure (terminal ones already reverted to local,
493
+ // flipping mode). Without a retry timer nothing would ever reopen
494
+ // SSE — connectEvents is only reachable from focus/visibility/run
495
+ // events — leaving the tab poll-only at the idle cadence.
496
+ this.scheduleGatewayReconnect();
497
+ }
498
+ });
499
+ return;
500
+ }
501
+ const url = this.activeSseUrl;
502
+ if (!url)
503
+ return;
504
+ const source = new EventSource(url);
281
505
  this.eventSource = source;
282
506
  source.onopen = () => {
283
507
  this.setSseConnected(true);
508
+ if (this.mode === "hosted") {
509
+ // A live gateway stream is the real health signal: clear failure
510
+ // counts accumulated by mint/stream retries. Local mode keeps main's
511
+ // semantics (only a successful poll resets the poll backoff).
512
+ this.consecutiveFailures = 0;
513
+ }
284
514
  this.schedulePoll();
285
515
  };
286
516
  source.onerror = () => {
@@ -292,6 +522,20 @@ class SyncTransport {
292
522
  // we'd be stuck on polling-only forever.
293
523
  if (source.readyState === EventSource.CLOSED) {
294
524
  this.eventSource = null;
525
+ if (this.mode === "hosted" && this.gateway) {
526
+ // A closed gateway stream is most likely an expired token or a
527
+ // request-timeout/deploy cycle. Re-mint and reconnect with jitter;
528
+ // this is NOT the poll-401 cooldown path. Each closed stream counts
529
+ // toward the unhealthy threshold so a hard-down gateway (or one
530
+ // rejecting our tokens) health-gates to local instead of looping
531
+ // mint+connect forever; a successful reconnect resets the count in
532
+ // onopen above.
533
+ this.token = null;
534
+ this.onGatewayTransientFailure();
535
+ if (this.mode === "hosted")
536
+ this.scheduleGatewayReconnect();
537
+ return;
538
+ }
295
539
  }
296
540
  this.schedulePoll();
297
541
  };
@@ -307,6 +551,37 @@ class SyncTransport {
307
551
  // Ignore malformed SSE frames; polling is the safety net.
308
552
  }
309
553
  };
554
+ if (this.mode === "hosted" && this.gateway) {
555
+ // Control frames ride NAMED SSE events so they never reach onmessage /
556
+ // normalizeEventPayload as spurious data events.
557
+ source.addEventListener(REALTIME_SSE_HANDSHAKE_EVENT, (e) => {
558
+ const hs = parseHandshakeFrame(e.data);
559
+ if (!hs)
560
+ return;
561
+ if (hs.protocol !== REALTIME_PROTOCOL_VERSION) {
562
+ // Surface an unexpected protocol rather than silently adopting its
563
+ // capabilities; keep the conservative (no advertised capabilities)
564
+ // stance so downstream (collab) does not relax on assumptions.
565
+ console.warn(`[agent-native] unsupported realtime protocol ${hs.protocol} (expected ${REALTIME_PROTOCOL_VERSION})`);
566
+ return;
567
+ }
568
+ this.capabilities = hs.capabilities;
569
+ // Re-notify subscribers now that capabilities are known — the initial
570
+ // connected notification fired before the handshake arrived.
571
+ this.notifySseState();
572
+ });
573
+ source.addEventListener(REALTIME_SSE_TOKEN_EVENT, (e) => {
574
+ const frame = parseTokenFrame(e.data);
575
+ if (!frame?.token)
576
+ return;
577
+ this.token = frame.token;
578
+ // EventSource can't change a live stream's URL, and its auto-reconnect
579
+ // reuses the original (old-token) URL. Close and reconnect (jittered) so
580
+ // the rotated token is actually used on the next connect.
581
+ this.closeEvents();
582
+ this.scheduleGatewayReconnect();
583
+ });
584
+ }
310
585
  }
311
586
  /**
312
587
  * Advance the transport's shared version cursor. Subscribers receive the
@@ -329,7 +604,14 @@ class SyncTransport {
329
604
  return;
330
605
  this.inFlight = true;
331
606
  try {
332
- const data = await fetchPollJson(this.pollUrl, this.versionRef, this.effectiveInterval);
607
+ if (this.mode === "hosted" && this.gateway && !this.token) {
608
+ // No token yet — mint before polling the gateway. A failed mint has
609
+ // already reverted us to local; a scheduled poll will pick it up.
610
+ const ok = await this.mintToken();
611
+ if (!ok || this.stopped)
612
+ return;
613
+ }
614
+ const data = await fetchPollJson(this.activePollUrl, this.versionRef, this.effectiveInterval, this.mode === "hosted" ? (this.token ?? undefined) : undefined);
333
615
  if (this.stopped)
334
616
  return;
335
617
  this.consecutiveFailures = 0;
@@ -341,7 +623,19 @@ class SyncTransport {
341
623
  if (this.stopped)
342
624
  return;
343
625
  this.consecutiveFailures++;
344
- if (isAuthFailure(err)) {
626
+ if (this.mode === "hosted" && this.gateway) {
627
+ // Gateway auth failure → re-mint (expired/rotated token), WITHOUT
628
+ // tripping the poll-401 cooldown. Persistent failures of any kind
629
+ // health-gate back to the local app.
630
+ if (isAuthFailure(err)) {
631
+ this.token = null;
632
+ void this.mintToken();
633
+ }
634
+ if (this.consecutiveFailures >= HOSTED_UNHEALTHY_THRESHOLD) {
635
+ this.revertToLocal();
636
+ }
637
+ }
638
+ else if (isAuthFailure(err)) {
345
639
  this.authFailureUntil = Date.now() + POLL_AUTH_FAILURE_COOLDOWN_MS;
346
640
  this.closeEvents();
347
641
  }
@@ -437,6 +731,10 @@ class SyncTransport {
437
731
  clearTimeout(this.timer);
438
732
  this.timer = null;
439
733
  }
734
+ if (this.gatewayReconnectTimer) {
735
+ clearTimeout(this.gatewayReconnectTimer);
736
+ this.gatewayReconnectTimer = null;
737
+ }
440
738
  window.removeEventListener("focus", this.handleFocus);
441
739
  window.removeEventListener("agentNative.chatRunning", this.handleChatRunning);
442
740
  document.removeEventListener("visibilitychange", this.handleVisibilityChange);
@@ -448,11 +746,13 @@ class SyncTransport {
448
746
  * hook instances in the same browser tab.
449
747
  */
450
748
  const transportRegistry = new Map();
451
- function getOrCreateTransport(pollUrl, sseUrl) {
749
+ function getOrCreateTransport(pollUrl, sseUrl, gateway = null) {
750
+ // Key on the LOCAL urls only — a transport may flip hosted→local at runtime,
751
+ // and the token must never fragment the registry, so neither is in the key.
452
752
  const key = `${pollUrl}\0${String(sseUrl)}`;
453
753
  let transport = transportRegistry.get(key);
454
754
  if (!transport) {
455
- transport = new SyncTransport(pollUrl, sseUrl);
755
+ transport = new SyncTransport(pollUrl, sseUrl, gateway);
456
756
  transportRegistry.set(key, transport);
457
757
  }
458
758
  return transport;
@@ -485,7 +785,7 @@ export function _resetSyncTransportRegistryForTests() {
485
785
  export function subscribeSyncEvents(options) {
486
786
  const pollUrl = agentNativePath(options.pollUrl ?? "/_agent-native/poll");
487
787
  const sseUrl = resolveSseUrl(options.sseUrl);
488
- const transport = getOrCreateTransport(pollUrl, sseUrl);
788
+ const transport = getOrCreateTransport(pollUrl, sseUrl, resolveGatewayBinding(sseUrl));
489
789
  const id = Symbol("subscribeSyncEvents");
490
790
  transport.add(id, {
491
791
  onEvents: options.onEvents,
@@ -741,7 +1041,7 @@ export function useDbSync(options = {}) {
741
1041
  const maxEventVersion = freshEvents.reduce((max, event) => Math.max(max, typeof event.version === "number" ? event.version : 0), 0);
742
1042
  subscriberVersion = Math.max(subscriberVersion, version ?? 0, maxEventVersion);
743
1043
  }
744
- const transport = getOrCreateTransport(pollUrl, sseUrl);
1044
+ const transport = getOrCreateTransport(pollUrl, sseUrl, resolveGatewayBinding(sseUrl));
745
1045
  transport.add(id, {
746
1046
  onEvents,
747
1047
  pauseWhenHidden,
@@ -815,7 +1115,7 @@ export function useScreenRefreshKey(options = {}) {
815
1115
  const maxEventVersion = freshEvents.reduce((max, event) => Math.max(max, typeof event.version === "number" ? event.version : 0), 0);
816
1116
  subscriberVersion = Math.max(subscriberVersion, version ?? 0, maxEventVersion);
817
1117
  }
818
- const transport = getOrCreateTransport(pollUrl, sseUrl);
1118
+ const transport = getOrCreateTransport(pollUrl, sseUrl, resolveGatewayBinding(sseUrl));
819
1119
  transport.add(id, {
820
1120
  onEvents,
821
1121
  pauseWhenHidden,