@agent-native/core 0.161.10 → 0.161.12

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 (27) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/content/actions/export-document.ts +68 -3
  3. package/corpus/templates/content/changelog/2026-08-06-database-exports-now-include-their-pages.md +6 -0
  4. package/corpus/templates/content/shared/document-export.ts +25 -0
  5. package/corpus/templates/design/.agents/skills/creative-context/SKILL.md +3 -0
  6. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +66 -0
  7. package/corpus/templates/design/app/pages/Index.tsx +45 -1
  8. package/corpus/templates/design/app/pages/design-editor/creative-context-precedent.ts +233 -0
  9. package/corpus/templates/design/changelog/2026-08-11-new-designs-now-skip-the-intake-questions-and-follow-your-ex.md +6 -0
  10. package/corpus/templates/slides/app/components/layout/AgentWorkIndicator.tsx +56 -9
  11. package/dist/action.js +19 -2
  12. package/dist/agent/chat-health-alert.js +10 -1
  13. package/dist/client/conversation/use-near-bottom-autoscroll.js +34 -4
  14. package/dist/client/embed-auth.js +40 -3
  15. package/dist/client/route-state.js +42 -5
  16. package/dist/client/use-change-version.js +71 -25
  17. package/dist/client/use-db-sync.d.ts +1 -0
  18. package/dist/client/use-db-sync.js +223 -45
  19. package/dist/collab/awareness.d.ts +2 -2
  20. package/dist/collab/awareness.js +37 -4
  21. package/dist/collab/client.js +17 -1
  22. package/dist/collab/struct-routes.d.ts +1 -1
  23. package/dist/notifications/routes.d.ts +1 -1
  24. package/dist/progress/routes.d.ts +1 -1
  25. package/dist/server/poll.d.ts +22 -14
  26. package/dist/server/poll.js +243 -67
  27. package/package.json +1 -1
@@ -15,6 +15,8 @@ const HIDDEN_POLL_INTERVAL_MS = 10_000;
15
15
  const POLL_AUTH_FAILURE_COOLDOWN_MS = 60_000;
16
16
  const LOCAL_SSE_RECONNECT_BASE_MS = 1_000;
17
17
  const LOCAL_SSE_RECONNECT_MAX_MS = 30_000;
18
+ const ACTIVE_CHAT_TTL_MS = 5 * 60 * 1_000;
19
+ const ACTIVE_CHAT_MAX = 1_000;
18
20
  /**
19
21
  * Max cadence for SSE/poll-driven query invalidation in `useDbSync`. Events
20
22
  * that arrive within this window of the first one in a burst are merged into
@@ -42,6 +44,61 @@ class HttpStatusError extends Error {
42
44
  this.status = status;
43
45
  }
44
46
  }
47
+ const INITIAL_SYNC_CURSOR = { version: 0, id: "" };
48
+ function encodeSyncCursor(cursor) {
49
+ return `${cursor.version}.${cursor.id}`;
50
+ }
51
+ function decodeSyncCursor(value) {
52
+ if (typeof value !== "string")
53
+ return undefined;
54
+ const separator = value.indexOf(".");
55
+ if (separator < 1)
56
+ return undefined;
57
+ const version = Number(value.slice(0, separator));
58
+ const id = value.slice(separator + 1);
59
+ if (!Number.isSafeInteger(version) || version < 0)
60
+ return undefined;
61
+ return { version, id };
62
+ }
63
+ function compareSyncCursors(a, b) {
64
+ if (a.version !== b.version)
65
+ return a.version - b.version;
66
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
67
+ }
68
+ function syncEventCursor(event) {
69
+ if (typeof event.version !== "number" ||
70
+ !Number.isSafeInteger(event.version) ||
71
+ typeof event.cursorId !== "string") {
72
+ return undefined;
73
+ }
74
+ return { version: event.version, id: event.cursorId };
75
+ }
76
+ function maxSyncCursor(a, b) {
77
+ return b && compareSyncCursors(b, a) > 0 ? b : a;
78
+ }
79
+ function cursorForEvents(events) {
80
+ let cursor;
81
+ for (const event of events) {
82
+ const eventCursor = syncEventCursor(event) ??
83
+ (typeof event.version === "number" &&
84
+ Number.isSafeInteger(event.version) &&
85
+ event.version >= 0
86
+ ? { version: event.version, id: "" }
87
+ : undefined);
88
+ if (eventCursor &&
89
+ (!cursor || compareSyncCursors(eventCursor, cursor) > 0)) {
90
+ cursor = eventCursor;
91
+ }
92
+ }
93
+ return cursor;
94
+ }
95
+ function isSyncEventAfterCursor(event, cursor, subscriberVersion) {
96
+ const eventCursor = syncEventCursor(event);
97
+ if (eventCursor)
98
+ return compareSyncCursors(eventCursor, cursor) > 0;
99
+ const version = typeof event.version === "number" ? event.version : 0;
100
+ return version === 0 || version > subscriberVersion;
101
+ }
45
102
  function getPollAbortMs(interval) {
46
103
  return Math.max(POLL_ABORT_MIN_MS, interval * 4);
47
104
  }
@@ -163,17 +220,35 @@ export function isInteractionCriticalSyncEvent(event) {
163
220
  INTERACTION_CRITICAL_APP_STATE_KEYS.some((key) => event.key === key ||
164
221
  (typeof event.key === "string" && event.key.startsWith(`${key}:`)))));
165
222
  }
166
- async function fetchPollJson(pollUrl, since, interval, token) {
223
+ function appStateQueryMatchesKeys(query, changedKeys) {
224
+ if (query.queryKey[0] !== "app-state")
225
+ return false;
226
+ // The aggregate query is the fallback for callers that read all app state.
227
+ if (query.queryKey.length === 1)
228
+ return true;
229
+ const queryStateKey = query.queryKey[1];
230
+ if (typeof queryStateKey !== "string")
231
+ return false;
232
+ return changedKeys.some((changedKey) => changedKey === "*" ||
233
+ changedKey === queryStateKey ||
234
+ changedKey.startsWith(`${queryStateKey}:`) ||
235
+ queryStateKey.startsWith(`${changedKey}:`));
236
+ }
237
+ async function fetchPollJson(pollUrl, cursor, interval, token) {
167
238
  const controller = typeof AbortController === "undefined" ? null : new AbortController();
168
239
  const timeout = controller
169
240
  ? setTimeout(() => controller.abort(), getPollAbortMs(interval))
170
241
  : null;
171
- // Local path stays exactly `?since=N`; the hosted gateway also carries the
172
- // subscribe token on the query string (a cross-origin fetch can't set the
173
- // Authorization header for the SSE sibling either, so both use the query).
174
- const url = token
175
- ? `${pollUrl}${pollUrl.includes("?") ? "&" : "?"}since=${since}&token=${encodeURIComponent(token)}`
176
- : `${pollUrl}?since=${since}`;
242
+ // The numeric `since` remains for backward-compatible servers. The
243
+ // composite cursor closes the same-millisecond cross-instance gap by
244
+ // ordering durable rows on `(version,id)`.
245
+ const separator = pollUrl.includes("?") ? "&" : "?";
246
+ const cursorQuery = `since=${cursor.version}`;
247
+ const compositeCursorQuery = cursor.version > 0 || cursor.id
248
+ ? `&cursor=${encodeURIComponent(encodeSyncCursor(cursor))}`
249
+ : "";
250
+ const tokenQuery = token ? `&token=${encodeURIComponent(token)}` : "";
251
+ const url = `${pollUrl}${separator}${cursorQuery}${compositeCursorQuery}${tokenQuery}`;
177
252
  try {
178
253
  const res = await fetch(url, controller ? { signal: controller.signal } : undefined);
179
254
  if (!res.ok)
@@ -192,7 +267,7 @@ class SyncTransport {
192
267
  sseUrl;
193
268
  gateway;
194
269
  subscribers = new Map();
195
- versionRef = 0;
270
+ cursorRef = { ...INITIAL_SYNC_CURSOR };
196
271
  timer = null;
197
272
  stopped = false;
198
273
  inFlight = false;
@@ -202,7 +277,7 @@ class SyncTransport {
202
277
  sseConnected = false;
203
278
  authFailureUntil = 0;
204
279
  consecutiveFailures = 0;
205
- activeChatIds = new Set();
280
+ activeChatIds = new Map();
206
281
  // Hosted-gateway state. `mode` starts "hosted" when a binding is present and
207
282
  // flips to "local" on health-gate revert; `token` is the current subscribe
208
283
  // token (minted from the app, rotated over the stream), never part of any
@@ -237,7 +312,9 @@ class SyncTransport {
237
312
  const base = `${this.gateway.sseUrl}?token=${encodeURIComponent(this.token)}`;
238
313
  // Cursor lets the gateway replay the reconnect gap on connect instead of
239
314
  // deferring it to the next poll; 0 on first connect means nothing to replay.
240
- return this.versionRef > 0 ? `${base}&since=${this.versionRef}` : base;
315
+ return this.cursorRef.version > 0 || this.cursorRef.id
316
+ ? `${base}&since=${this.cursorRef.version}&cursor=${encodeURIComponent(encodeSyncCursor(this.cursorRef))}`
317
+ : base;
241
318
  }
242
319
  return this.sseUrl;
243
320
  }
@@ -322,7 +399,7 @@ class SyncTransport {
322
399
  }
323
400
  /**
324
401
  * Health-gate back to the app's own /poll + /events with the cursor intact
325
- * (versionRef is untouched), so delivery stays poll-equivalent — never a
402
+ * (cursorRef is untouched), so delivery stays poll-equivalent - never a
326
403
  * silent stall.
327
404
  */
328
405
  revertToLocal() {
@@ -418,6 +495,12 @@ class SyncTransport {
418
495
  return isFinite(min) ? min : IDLE_POLL_INTERVAL_MS;
419
496
  }
420
497
  get isActive() {
498
+ const now = Date.now();
499
+ for (const [id, lastSeen] of this.activeChatIds) {
500
+ if (now - lastSeen > ACTIVE_CHAT_TTL_MS) {
501
+ this.activeChatIds.delete(id);
502
+ }
503
+ }
421
504
  return this.activeChatIds.size > 0;
422
505
  }
423
506
  get effectiveFallbackInterval() {
@@ -431,9 +514,9 @@ class SyncTransport {
431
514
  // -------------------------------------------------------------------------
432
515
  // Event fan-out
433
516
  // -------------------------------------------------------------------------
434
- fan(events, version) {
517
+ fan(events, version, cursor = this.cursorRef) {
435
518
  for (const sub of this.subscribers.values()) {
436
- sub.onEvents(events, version);
519
+ sub.onEvents(events, version, cursor);
437
520
  }
438
521
  }
439
522
  setSseConnected(connected) {
@@ -536,7 +619,7 @@ class SyncTransport {
536
619
  // already origin-scoped, and the dev gateway serves every workspace app
537
620
  // from one origin — so an origin key would elect a single leader across
538
621
  // different apps. A follower applies whatever the leader sends into its own
539
- // `versionRef` (see openChannel), and that cursor is the `?since=` for its
622
+ // `cursorRef` (see openChannel), and that cursor is the `?since=` for its
540
623
  // own poll, so it would skip its own app's events from then on. The poll
541
624
  // URL is app-scoped, which is exactly the granularity a stream serves.
542
625
  return `${SSE_LEADER_LOCK_PREFIX}${this.pollUrl}`;
@@ -612,8 +695,9 @@ class SyncTransport {
612
695
  if (this.leaderState === "leader")
613
696
  return;
614
697
  if (frame?.type === "events") {
615
- this.applyVersion(frame.events, frame.version);
616
- this.fan(frame.events, frame.version);
698
+ this.applyVersion(frame.events, frame.cursor ? undefined : frame.version);
699
+ this.cursorRef = maxSyncCursor(this.cursorRef, frame.cursor);
700
+ this.fan(frame.events, frame.version, this.cursorRef);
617
701
  }
618
702
  else if (frame?.type === "sse-state") {
619
703
  this.capabilities = frame.capabilities;
@@ -719,7 +803,7 @@ class SyncTransport {
719
803
  if (this.mode === "hosted" && this.gateway) {
720
804
  // Browser auto-reconnect reuses the URL frozen at construction, so it
721
805
  // would replay from a stale `since`. Own the reconnect so the next
722
- // connect rebuilds activeSseUrl from the current versionRef. CLOSED also
806
+ // connect rebuilds activeSseUrl from the current cursorRef. CLOSED also
723
807
  // refreshes the token (expired/rotated/deploy); CONNECTING keeps it. A
724
808
  // successful reconnect resets the count in onopen; a hard-down gateway
725
809
  // trips the threshold and health-gates to local.
@@ -746,8 +830,18 @@ class SyncTransport {
746
830
  const events = normalizeEventPayload(payload);
747
831
  const version = typeof payload?.version === "number" ? payload.version : undefined;
748
832
  this.applyVersion(events, version);
749
- this.fan(events, version);
750
- this.broadcast({ type: "events", events, version });
833
+ this.fan(events, version, this.cursorRef);
834
+ const eventCursor = cursorForEvents(events);
835
+ this.broadcast({
836
+ type: "events",
837
+ events,
838
+ version,
839
+ // A follower may only adopt a cursor for events it received. The
840
+ // transport cursor can be ahead after a poll response skipped
841
+ // filtered/unread durable rows, which would make the follower lose
842
+ // those rows on its own safety poll.
843
+ cursor: eventCursor,
844
+ });
751
845
  }
752
846
  catch {
753
847
  // Ignore malformed SSE frames; polling is the safety net.
@@ -789,17 +883,15 @@ class SyncTransport {
789
883
  * Advance the transport's shared version cursor. Subscribers receive the
790
884
  * raw events and decide independently which ones are "fresh" relative to
791
885
  * their own cursor, but the transport-level cursor ensures the poll
792
- * `?since=` parameter always advances.
886
+ * `?since=&cursor=` parameters always advance.
793
887
  */
794
888
  applyVersion(events, version) {
795
- let max = typeof version === "number" ? version : 0;
889
+ if (typeof version === "number" && version > this.cursorRef.version) {
890
+ this.cursorRef = { version, id: "" };
891
+ }
796
892
  for (const evt of events) {
797
- const v = typeof evt.version === "number" ? evt.version : 0;
798
- if (v > max)
799
- max = v;
893
+ this.cursorRef = maxSyncCursor(this.cursorRef, syncEventCursor(evt));
800
894
  }
801
- if (max > this.versionRef)
802
- this.versionRef = max;
803
895
  }
804
896
  async poll() {
805
897
  if (this.stopped || this.inFlight)
@@ -813,13 +905,19 @@ class SyncTransport {
813
905
  if (!ok || this.stopped)
814
906
  return;
815
907
  }
816
- const data = await fetchPollJson(this.activePollUrl, this.versionRef, this.effectiveInterval, this.mode === "hosted" ? (this.token ?? undefined) : undefined);
908
+ const data = await fetchPollJson(this.activePollUrl, this.cursorRef, this.effectiveInterval, this.mode === "hosted" ? (this.token ?? undefined) : undefined);
817
909
  if (this.stopped)
818
910
  return;
819
911
  this.consecutiveFailures = 0;
820
912
  const events = data.events ?? [];
821
- this.applyVersion(events, data.version);
822
- this.fan(events, data.version);
913
+ const responseCursor = decodeSyncCursor(data.cursor);
914
+ // A paged durable response's numeric version is the high-water mark,
915
+ // not necessarily the last row in this page. When a composite cursor is
916
+ // present, advance from event ids/that cursor only - applying the high
917
+ // water first would skip the remainder of a same-version page.
918
+ this.applyVersion(events, responseCursor ? undefined : data.version);
919
+ this.cursorRef = maxSyncCursor(this.cursorRef, responseCursor);
920
+ this.fan(events, data.version, this.cursorRef);
823
921
  }
824
922
  catch (err) {
825
923
  if (this.stopped)
@@ -901,10 +999,20 @@ class SyncTransport {
901
999
  ? detail.tabId
902
1000
  : "__default__";
903
1001
  const wasActive = this.isActive;
904
- if (running)
905
- this.activeChatIds.add(id);
906
- else
1002
+ if (running) {
1003
+ // Reinsert to refresh both the TTL and insertion order for the cap.
1004
+ this.activeChatIds.delete(id);
1005
+ this.activeChatIds.set(id, Date.now());
1006
+ while (this.activeChatIds.size > ACTIVE_CHAT_MAX) {
1007
+ const oldestId = this.activeChatIds.keys().next().value;
1008
+ if (typeof oldestId !== "string")
1009
+ break;
1010
+ this.activeChatIds.delete(oldestId);
1011
+ }
1012
+ }
1013
+ else {
907
1014
  this.activeChatIds.delete(id);
1015
+ }
908
1016
  if (wasActive === this.isActive)
909
1017
  return;
910
1018
  if (this.isActive) {
@@ -932,6 +1040,7 @@ class SyncTransport {
932
1040
  }
933
1041
  teardown() {
934
1042
  this.stopped = true;
1043
+ this.activeChatIds.clear();
935
1044
  this.closeEvents();
936
1045
  // Hand this app's stream slot to a waiting tab before dropping the
937
1046
  // channel, so the next leader is elected without waiting on a poll.
@@ -1071,6 +1180,7 @@ export function useDbSync(options = {}) {
1071
1180
  // Per-subscriber version cursor: tracks which events have already been
1072
1181
  // processed by THIS subscriber so stale poll re-deliveries are ignored.
1073
1182
  let subscriberVersion = 0;
1183
+ let subscriberCursor = { ...INITIAL_SYNC_CURSOR };
1074
1184
  // Coalesce bursts of SSE-driven invalidation into at most one flush per
1075
1185
  // INVALIDATE_COALESCE_MS. A chatty doc (many small agent edits, several
1076
1186
  // peers editing at once) can otherwise deliver a handful of `action`/
@@ -1091,6 +1201,8 @@ export function useDbSync(options = {}) {
1091
1201
  // coalesced behavior.
1092
1202
  let pendingInvalidateEvents = [];
1093
1203
  let invalidateTimer = null;
1204
+ let disposed = false;
1205
+ const pendingTrailingRefreshes = [];
1094
1206
  function flushInvalidateBatch() {
1095
1207
  if (invalidateTimer) {
1096
1208
  clearTimeout(invalidateTimer);
@@ -1158,23 +1270,60 @@ export function useDbSync(options = {}) {
1158
1270
  // in flight, let it finish instead of aborting and immediately
1159
1271
  // launching the same request again. Repeated action events otherwise
1160
1272
  // turn a slow endpoint into a cancel/restart loop that never settles.
1273
+ const hasPendingTrailingRefresh = (filters, predicateIdentity) => pendingTrailingRefreshes.some((pending) => {
1274
+ if (pending.filters?.dedupeKey !== filters?.dedupeKey)
1275
+ return false;
1276
+ if (filters?.dedupeKey)
1277
+ return true;
1278
+ const pendingKey = pending.filters?.queryKey;
1279
+ const filterKey = filters?.queryKey;
1280
+ if (pendingKey || filterKey) {
1281
+ if (!pendingKey ||
1282
+ !filterKey ||
1283
+ pendingKey.length !== filterKey.length) {
1284
+ return false;
1285
+ }
1286
+ return pendingKey.every((key, index) => key === filterKey[index]);
1287
+ }
1288
+ return pending.predicateIdentity === predicateIdentity;
1289
+ });
1161
1290
  const invalidateWithoutCancel = (requested) => {
1162
1291
  const callerPredicate = requested?.predicate;
1163
- const filters = {
1292
+ const normalizedFilters = {
1164
1293
  ...requested,
1165
1294
  predicate: (query) => !hasTerminalAuthFailure(query) &&
1166
1295
  (callerPredicate?.(query) ?? true),
1167
1296
  };
1168
- const needsTrailingRefresh = (queryClient.isFetching?.(filters) ?? 0) > 0;
1169
- const completion = queryClient.invalidateQueries(filters, {
1297
+ const needsTrailingRefresh = (queryClient.isFetching?.(normalizedFilters) ?? 0) > 0;
1298
+ const completion = queryClient.invalidateQueries(normalizedFilters, {
1170
1299
  cancelRefetch: false,
1171
1300
  });
1172
1301
  // TanStack Query deliberately leaves an in-flight fetch alone when
1173
1302
  // cancelRefetch is false. Queue one post-settlement invalidation so
1174
1303
  // a write that landed after that read began cannot be cleared as
1175
1304
  // fresh by the older response.
1176
- if (needsTrailingRefresh && completion instanceof Promise) {
1177
- void completion.then(() => queryClient.invalidateQueries(filters), () => { });
1305
+ if (!disposed &&
1306
+ needsTrailingRefresh &&
1307
+ completion instanceof Promise &&
1308
+ !hasPendingTrailingRefresh(normalizedFilters, callerPredicate)) {
1309
+ const pending = {
1310
+ filters: normalizedFilters,
1311
+ completion,
1312
+ predicateIdentity: callerPredicate,
1313
+ };
1314
+ pendingTrailingRefreshes.push(pending);
1315
+ void completion.then(() => {
1316
+ const index = pendingTrailingRefreshes.indexOf(pending);
1317
+ if (index < 0)
1318
+ return;
1319
+ pendingTrailingRefreshes.splice(index, 1);
1320
+ if (!disposed)
1321
+ queryClient.invalidateQueries(normalizedFilters);
1322
+ }, () => {
1323
+ const index = pendingTrailingRefreshes.indexOf(pending);
1324
+ if (index >= 0)
1325
+ pendingTrailingRefreshes.splice(index, 1);
1326
+ });
1178
1327
  }
1179
1328
  };
1180
1329
  const hasActionEvent = invalidating.some((evt) => evt.source === "action" && !isSuppressedActionEvent(evt));
@@ -1240,8 +1389,26 @@ export function useDbSync(options = {}) {
1240
1389
  invalidateWithoutCancel({ queryKey: ["tools"] });
1241
1390
  }
1242
1391
  }
1243
- if (invalidating.some((evt) => evt.source === "app-state")) {
1244
- invalidateWithoutCancel({ queryKey: ["app-state"] });
1392
+ const appStateKeys = invalidating
1393
+ .filter((evt) => evt.source === "app-state")
1394
+ .map((evt) => evt.key)
1395
+ .map((key) => (typeof key === "string" && key ? key : "*"));
1396
+ if (appStateKeys.length > 0) {
1397
+ const appStateDedupeKey = Array.from(new Set(appStateKeys))
1398
+ .sort()
1399
+ .map((key) => `${key.length}:${key}`)
1400
+ .join("|");
1401
+ const needsBroadAppStateRefresh = appStateKeys.some((key) => key === "*" ||
1402
+ isInteractionCriticalSyncEvent({
1403
+ source: "app-state",
1404
+ key,
1405
+ }));
1406
+ invalidateWithoutCancel(needsBroadAppStateRefresh
1407
+ ? { queryKey: ["app-state"] }
1408
+ : {
1409
+ dedupeKey: `app-state:${appStateDedupeKey}`,
1410
+ predicate: (query) => appStateQueryMatchesKeys(query, appStateKeys),
1411
+ });
1245
1412
  }
1246
1413
  if (hasAppStateEvent(invalidating, "navigate")) {
1247
1414
  invalidateWithoutCancel({ queryKey: ["navigate-command"] });
@@ -1261,16 +1428,20 @@ export function useDbSync(options = {}) {
1261
1428
  onEventRef.current?.(evt);
1262
1429
  }
1263
1430
  }
1264
- function onEvents(events, version) {
1431
+ function onEvents(events, version, cursor) {
1265
1432
  const freshEvents = events.filter((event) => {
1266
- const v = typeof event.version === "number" ? event.version : 0;
1267
- return v === 0 || v > subscriberVersion;
1433
+ return isSyncEventAfterCursor(event, subscriberCursor, subscriberVersion);
1268
1434
  });
1269
1435
  if (freshEvents.length > 0) {
1270
1436
  queueInvalidateBatch(freshEvents);
1271
1437
  }
1272
1438
  const maxEventVersion = freshEvents.reduce((max, event) => Math.max(max, typeof event.version === "number" ? event.version : 0), 0);
1273
1439
  subscriberVersion = Math.max(subscriberVersion, version ?? 0, maxEventVersion);
1440
+ for (const event of freshEvents) {
1441
+ subscriberCursor = maxSyncCursor(subscriberCursor, syncEventCursor(event));
1442
+ }
1443
+ if (cursor)
1444
+ subscriberCursor = maxSyncCursor(subscriberCursor, cursor);
1274
1445
  }
1275
1446
  const transport = getOrCreateTransport(pollUrl, sseUrl, resolveGatewayBinding(sseUrl));
1276
1447
  transport.add(id, {
@@ -1281,12 +1452,14 @@ export function useDbSync(options = {}) {
1281
1452
  fallbackInterval,
1282
1453
  });
1283
1454
  return () => {
1455
+ disposed = true;
1284
1456
  if (invalidateTimer) {
1285
1457
  clearTimeout(invalidateTimer);
1286
1458
  // Flush synchronously on unmount so a pending batch isn't silently
1287
1459
  // dropped (e.g. a route change right after an agent edit lands).
1288
1460
  flushInvalidateBatch();
1289
1461
  }
1462
+ pendingTrailingRefreshes.length = 0;
1290
1463
  transport.remove(id);
1291
1464
  // If the registry still holds this transport, and the transport is now
1292
1465
  // empty, evict it so the next mount gets a fresh instance rather than a
@@ -1335,16 +1508,21 @@ export function useScreenRefreshKey(options = {}) {
1335
1508
  const id = Symbol("useScreenRefreshKey");
1336
1509
  // Per-subscriber version cursor (same freshness logic as useDbSync).
1337
1510
  let subscriberVersion = 0;
1338
- function onEvents(events, version) {
1511
+ let subscriberCursor = { ...INITIAL_SYNC_CURSOR };
1512
+ function onEvents(events, version, cursor) {
1339
1513
  const freshEvents = events.filter((event) => {
1340
- const v = typeof event.version === "number" ? event.version : 0;
1341
- return v === 0 || v > subscriberVersion;
1514
+ return isSyncEventAfterCursor(event, subscriberCursor, subscriberVersion);
1342
1515
  });
1343
1516
  if (freshEvents.some((e) => e.source === "screen-refresh")) {
1344
1517
  setKey((k) => k + 1);
1345
1518
  }
1346
1519
  const maxEventVersion = freshEvents.reduce((max, event) => Math.max(max, typeof event.version === "number" ? event.version : 0), 0);
1347
1520
  subscriberVersion = Math.max(subscriberVersion, version ?? 0, maxEventVersion);
1521
+ for (const event of freshEvents) {
1522
+ subscriberCursor = maxSyncCursor(subscriberCursor, syncEventCursor(event));
1523
+ }
1524
+ if (cursor)
1525
+ subscriberCursor = maxSyncCursor(subscriberCursor, cursor);
1348
1526
  }
1349
1527
  const transport = getOrCreateTransport(pollUrl, sseUrl, resolveGatewayBinding(sseUrl));
1350
1528
  transport.add(id, {
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
62
62
  error: string;
63
63
  states?: undefined;
64
64
  } | {
65
+ error?: undefined;
65
66
  states: {
66
67
  clientId: number;
67
68
  state: string;
68
69
  }[];
69
- error?: undefined;
70
70
  }>>;
71
71
  /**
72
72
  * GET /_agent-native/collab/:docId/users
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
77
77
  error: string;
78
78
  users?: undefined;
79
79
  } | {
80
+ error?: undefined;
80
81
  users: {
81
82
  clientId: number;
82
83
  lastSeen: number;
83
84
  }[];
84
- error?: undefined;
85
85
  }>>;
@@ -16,6 +16,8 @@ import { readBody } from "../server/h3-helpers.js";
16
16
  import { deleteAwarenessRow, loadAwarenessRows, upsertAwarenessRow, } from "./awareness-store.js";
17
17
  const AWARENESS_TIMEOUT = 30_000; // 30 seconds
18
18
  const CLEAR_TOMBSTONE_TTL = AWARENESS_TIMEOUT + 5_000;
19
+ const AWARENESS_SCOPE_TIMEOUT = AWARENESS_TIMEOUT + 5_000;
20
+ const MAX_AWARENESS_SCOPES = 10_000;
19
21
  // ---------------------------------------------------------------------------
20
22
  // Awareness event emitter — fast-path for push delivery to SSE-connected peers.
21
23
  // The SSE handler (poll-events) subscribes and forwards events to its stream.
@@ -24,21 +26,52 @@ export const AWARENESS_CHANGE_EVENT = "awareness-change";
24
26
  const _awarenessEmitter = new EventEmitter();
25
27
  _awarenessEmitter.setMaxListeners(0);
26
28
  const _awarenessScopes = new Map();
29
+ function pruneAwarenessScopes(now) {
30
+ for (const [docId, entry] of _awarenessScopes) {
31
+ if (now - entry.lastSeen > AWARENESS_SCOPE_TIMEOUT) {
32
+ _awarenessScopes.delete(docId);
33
+ }
34
+ }
35
+ while (_awarenessScopes.size > MAX_AWARENESS_SCOPES) {
36
+ const oldest = _awarenessScopes.keys().next().value;
37
+ if (!oldest)
38
+ break;
39
+ _awarenessScopes.delete(oldest);
40
+ }
41
+ }
27
42
  export function getAwarenessEmitter() {
28
43
  return _awarenessEmitter;
29
44
  }
30
45
  export function rememberAwarenessScope(docId, scope) {
31
- if (!scope)
46
+ const now = Date.now();
47
+ pruneAwarenessScopes(now);
48
+ const existing = _awarenessScopes.get(docId);
49
+ if (!scope) {
50
+ // Agent-presence heartbeats emit scope-less awareness changes. They still
51
+ // prove the document is active and must keep the access scope alive.
52
+ if (existing) {
53
+ _awarenessScopes.delete(docId);
54
+ _awarenessScopes.set(docId, { ...existing, lastSeen: now });
55
+ }
32
56
  return;
33
- const next = { ...(_awarenessScopes.get(docId) ?? {}), ...scope };
57
+ }
58
+ const next = {
59
+ ...(existing?.scope ?? {}),
60
+ ...scope,
61
+ };
34
62
  if (!next.owner && !next.orgId && !next.resourceType && !next.resourceId) {
35
63
  return;
36
64
  }
37
- _awarenessScopes.set(docId, next);
65
+ _awarenessScopes.delete(docId);
66
+ _awarenessScopes.set(docId, { scope: next, lastSeen: now });
67
+ // Enforce the cap after insertion too. Pruning only before insertion lets
68
+ // the map temporarily exceed its advertised bound on every new document.
69
+ pruneAwarenessScopes(now);
38
70
  }
39
71
  export function emitAwarenessChange(docId, states, scope) {
40
72
  rememberAwarenessScope(docId, scope);
41
- const resolvedScope = _awarenessScopes.get(docId) ?? {};
73
+ pruneAwarenessScopes(Date.now());
74
+ const resolvedScope = _awarenessScopes.get(docId)?.scope ?? {};
42
75
  const event = {
43
76
  source: "awareness",
44
77
  type: "awareness-change",
@@ -131,10 +131,21 @@ function calcBackoff(consecutiveErrors) {
131
131
  // setLocalStateField calls within a 150ms window are coalesced into one POST.
132
132
  // ---------------------------------------------------------------------------
133
133
  const _awarenessThrottleTimers = new Map();
134
+ function awarenessThrottleKey(baseUrl, docId, clientId) {
135
+ return `${baseUrl}::${docId}::${clientId}`;
136
+ }
137
+ function cancelAwarenessPush(baseUrl, docId, clientId) {
138
+ const key = awarenessThrottleKey(baseUrl, docId, clientId);
139
+ const timer = _awarenessThrottleTimers.get(key);
140
+ if (timer === undefined)
141
+ return;
142
+ clearTimeout(timer);
143
+ _awarenessThrottleTimers.delete(key);
144
+ }
134
145
  function scheduleAwarenessPush(baseUrl, docId, clientId, getState) {
135
146
  if (typeof window === "undefined")
136
147
  return;
137
- const key = `${docId}::${clientId}`;
148
+ const key = awarenessThrottleKey(baseUrl, docId, clientId);
138
149
  if (_awarenessThrottleTimers.has(key))
139
150
  return; // already scheduled
140
151
  const timer = setTimeout(() => {
@@ -264,6 +275,10 @@ class CollabDocConnection {
264
275
  remove(id) {
265
276
  this.subscribers.delete(id);
266
277
  if (this.subscribers.size === 0) {
278
+ // A lingered connection may be remounted for StrictMode, but it has no
279
+ // live consumer while empty. Do not let a queued presence push escape
280
+ // after the last subscriber has gone away.
281
+ cancelAwarenessPush(this.baseUrl, this.docId, this.ydoc.clientID);
267
282
  this.scheduleDispose();
268
283
  }
269
284
  else {
@@ -305,6 +320,7 @@ class CollabDocConnection {
305
320
  // `setLocalState(null)` does not schedule a push (matches the previous
306
321
  // per-hook effect cleanup ordering).
307
322
  this.awareness.off("change", this.handleAwarenessChange);
323
+ cancelAwarenessPush(this.baseUrl, this.docId, this.ydoc.clientID);
308
324
  this.awareness.destroy();
309
325
  this.ydoc.destroy();
310
326
  if (collabConnectionRegistry.get(this.registryKey) === this) {
@@ -13,8 +13,8 @@
13
13
  * Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
14
14
  */
15
15
  export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
16
- ok?: undefined;
17
16
  error: string;
17
+ ok?: undefined;
18
18
  } | {
19
19
  error?: undefined;
20
20
  ok: boolean;
@@ -28,6 +28,6 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
28
28
  } | {
29
29
  count?: undefined;
30
30
  updated?: undefined;
31
- error?: undefined;
32
31
  ok: boolean;
32
+ error?: undefined;
33
33
  }>>;
@@ -15,6 +15,6 @@ export declare function createProgressHandler(): import("h3").EventHandlerWithFe
15
15
  error: string;
16
16
  ok?: undefined;
17
17
  } | {
18
- error?: undefined;
19
18
  ok: boolean;
19
+ error?: undefined;
20
20
  }>>;