@agent-native/core 0.45.0 → 0.46.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 (157) hide show
  1. package/README.md +1 -0
  2. package/dist/action.d.ts +8 -1
  3. package/dist/action.d.ts.map +1 -1
  4. package/dist/action.js +20 -10
  5. package/dist/action.js.map +1 -1
  6. package/dist/cli/app-skill.d.ts +3 -1
  7. package/dist/cli/app-skill.d.ts.map +1 -1
  8. package/dist/cli/app-skill.js +50 -8
  9. package/dist/cli/app-skill.js.map +1 -1
  10. package/dist/cli/connect.d.ts.map +1 -1
  11. package/dist/cli/connect.js +39 -5
  12. package/dist/cli/connect.js.map +1 -1
  13. package/dist/cli/create.d.ts.map +1 -1
  14. package/dist/cli/create.js +9 -7
  15. package/dist/cli/create.js.map +1 -1
  16. package/dist/cli/index.js +42 -10
  17. package/dist/cli/index.js.map +1 -1
  18. package/dist/cli/mcp-config-writers.d.ts +10 -0
  19. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  20. package/dist/cli/mcp-config-writers.js +60 -6
  21. package/dist/cli/mcp-config-writers.js.map +1 -1
  22. package/dist/cli/mcp.d.ts.map +1 -1
  23. package/dist/cli/mcp.js +4 -6
  24. package/dist/cli/mcp.js.map +1 -1
  25. package/dist/cli/plan-local.d.ts.map +1 -1
  26. package/dist/cli/plan-local.js +15 -2
  27. package/dist/cli/plan-local.js.map +1 -1
  28. package/dist/cli/plan-publish-store.d.ts +17 -7
  29. package/dist/cli/plan-publish-store.d.ts.map +1 -1
  30. package/dist/cli/plan-publish-store.js +33 -8
  31. package/dist/cli/plan-publish-store.js.map +1 -1
  32. package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
  33. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  34. package/dist/cli/pr-visual-recap-workflow.js +1 -1
  35. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  36. package/dist/cli/recap.d.ts +63 -5
  37. package/dist/cli/recap.d.ts.map +1 -1
  38. package/dist/cli/recap.js +641 -48
  39. package/dist/cli/recap.js.map +1 -1
  40. package/dist/cli/skills.d.ts +26 -11
  41. package/dist/cli/skills.d.ts.map +1 -1
  42. package/dist/cli/skills.js +644 -972
  43. package/dist/cli/skills.js.map +1 -1
  44. package/dist/cli/templates-meta.d.ts.map +1 -1
  45. package/dist/cli/templates-meta.js +3 -2
  46. package/dist/cli/templates-meta.js.map +1 -1
  47. package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -1
  48. package/dist/client/blocks/library/AnnotatedCodeBlock.js +37 -9
  49. package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -1
  50. package/dist/client/blocks/library/DiffBlock.d.ts.map +1 -1
  51. package/dist/client/blocks/library/DiffBlock.js +44 -12
  52. package/dist/client/blocks/library/DiffBlock.js.map +1 -1
  53. package/dist/client/blocks/library/annotation-rail.d.ts +12 -3
  54. package/dist/client/blocks/library/annotation-rail.d.ts.map +1 -1
  55. package/dist/client/blocks/library/annotation-rail.js +29 -3
  56. package/dist/client/blocks/library/annotation-rail.js.map +1 -1
  57. package/dist/client/blocks/library/html.d.ts.map +1 -1
  58. package/dist/client/blocks/library/html.js +3 -1
  59. package/dist/client/blocks/library/html.js.map +1 -1
  60. package/dist/client/blocks/library/question-form.d.ts.map +1 -1
  61. package/dist/client/blocks/library/question-form.js +4 -1
  62. package/dist/client/blocks/library/question-form.js.map +1 -1
  63. package/dist/client/components/LiveCursorOverlay.d.ts +46 -0
  64. package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -0
  65. package/dist/client/components/LiveCursorOverlay.js +137 -0
  66. package/dist/client/components/LiveCursorOverlay.js.map +1 -0
  67. package/dist/client/components/PresenceBar.d.ts +11 -1
  68. package/dist/client/components/PresenceBar.d.ts.map +1 -1
  69. package/dist/client/components/PresenceBar.js +39 -7
  70. package/dist/client/components/PresenceBar.js.map +1 -1
  71. package/dist/client/components/RemoteSelectionRings.d.ts +43 -0
  72. package/dist/client/components/RemoteSelectionRings.d.ts.map +1 -0
  73. package/dist/client/components/RemoteSelectionRings.js +116 -0
  74. package/dist/client/components/RemoteSelectionRings.js.map +1 -0
  75. package/dist/client/index.d.ts +4 -0
  76. package/dist/client/index.d.ts.map +1 -1
  77. package/dist/client/index.js +5 -0
  78. package/dist/client/index.js.map +1 -1
  79. package/dist/collab/awareness.d.ts +25 -0
  80. package/dist/collab/awareness.d.ts.map +1 -1
  81. package/dist/collab/awareness.js +42 -5
  82. package/dist/collab/awareness.js.map +1 -1
  83. package/dist/collab/client.d.ts +19 -1
  84. package/dist/collab/client.d.ts.map +1 -1
  85. package/dist/collab/client.js +362 -57
  86. package/dist/collab/client.js.map +1 -1
  87. package/dist/collab/follow-mode.d.ts +56 -0
  88. package/dist/collab/follow-mode.d.ts.map +1 -0
  89. package/dist/collab/follow-mode.js +54 -0
  90. package/dist/collab/follow-mode.js.map +1 -0
  91. package/dist/collab/index.d.ts +3 -1
  92. package/dist/collab/index.d.ts.map +1 -1
  93. package/dist/collab/index.js +5 -1
  94. package/dist/collab/index.js.map +1 -1
  95. package/dist/collab/presence.d.ts +56 -0
  96. package/dist/collab/presence.d.ts.map +1 -0
  97. package/dist/collab/presence.js +98 -0
  98. package/dist/collab/presence.js.map +1 -0
  99. package/dist/collab/routes.d.ts.map +1 -1
  100. package/dist/collab/routes.js +33 -6
  101. package/dist/collab/routes.js.map +1 -1
  102. package/dist/collab/struct-routes.d.ts.map +1 -1
  103. package/dist/collab/struct-routes.js +24 -4
  104. package/dist/collab/struct-routes.js.map +1 -1
  105. package/dist/collab/ydoc-manager.d.ts +13 -0
  106. package/dist/collab/ydoc-manager.d.ts.map +1 -1
  107. package/dist/collab/ydoc-manager.js +51 -15
  108. package/dist/collab/ydoc-manager.js.map +1 -1
  109. package/dist/db/migrations.d.ts.map +1 -1
  110. package/dist/db/migrations.js +2 -1
  111. package/dist/db/migrations.js.map +1 -1
  112. package/dist/extensions/routes.d.ts +18 -0
  113. package/dist/extensions/routes.d.ts.map +1 -1
  114. package/dist/extensions/routes.js +30 -8
  115. package/dist/extensions/routes.js.map +1 -1
  116. package/dist/oauth-tokens/store.d.ts.map +1 -1
  117. package/dist/oauth-tokens/store.js +42 -5
  118. package/dist/oauth-tokens/store.js.map +1 -1
  119. package/dist/scripts/db/index.d.ts.map +1 -1
  120. package/dist/scripts/db/index.js +1 -0
  121. package/dist/scripts/db/index.js.map +1 -1
  122. package/dist/scripts/db/migrate-encrypt-oauth-tokens.d.ts +28 -0
  123. package/dist/scripts/db/migrate-encrypt-oauth-tokens.d.ts.map +1 -0
  124. package/dist/scripts/db/migrate-encrypt-oauth-tokens.js +164 -0
  125. package/dist/scripts/db/migrate-encrypt-oauth-tokens.js.map +1 -0
  126. package/dist/scripts/db/scoping.d.ts.map +1 -1
  127. package/dist/scripts/db/scoping.js +7 -5
  128. package/dist/scripts/db/scoping.js.map +1 -1
  129. package/dist/secrets/index.d.ts +1 -0
  130. package/dist/secrets/index.d.ts.map +1 -1
  131. package/dist/secrets/index.js +4 -0
  132. package/dist/secrets/index.js.map +1 -1
  133. package/dist/server/collab-plugin.d.ts +6 -0
  134. package/dist/server/collab-plugin.d.ts.map +1 -1
  135. package/dist/server/collab-plugin.js +105 -5
  136. package/dist/server/collab-plugin.js.map +1 -1
  137. package/dist/server/poll-events.d.ts +5 -0
  138. package/dist/server/poll-events.d.ts.map +1 -1
  139. package/dist/server/poll-events.js +27 -4
  140. package/dist/server/poll-events.js.map +1 -1
  141. package/dist/sharing/actions/set-resource-visibility.d.ts.map +1 -1
  142. package/dist/sharing/actions/set-resource-visibility.js +4 -1
  143. package/dist/sharing/actions/set-resource-visibility.js.map +1 -1
  144. package/dist/templates/default/.agents/skills/real-time-collab/SKILL.md +185 -37
  145. package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +12 -2
  146. package/dist/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +185 -37
  147. package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +12 -2
  148. package/docs/content/plan-plugin.md +21 -6
  149. package/docs/content/pr-visual-recap.md +52 -3
  150. package/docs/content/real-time-collaboration.md +481 -97
  151. package/docs/content/skills-guide.md +13 -0
  152. package/docs/content/template-plan.md +18 -7
  153. package/package.json +5 -1
  154. package/src/templates/default/.agents/skills/real-time-collab/SKILL.md +185 -37
  155. package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +12 -2
  156. package/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +185 -37
  157. package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +12 -2
@@ -7,6 +7,22 @@
7
7
  *
8
8
  * Also manages Yjs Awareness for cursor positions and user presence,
9
9
  * synced via polling to the server's awareness endpoint.
10
+ *
11
+ * Transport improvements (vs previous version):
12
+ * - Local update POSTs are debounced and coalesced with Y.mergeUpdates (~80ms)
13
+ * to avoid per-keystroke requests. The batch is flushed immediately on
14
+ * visibilitychange/pagehide and before each poll/awareness cycle.
15
+ * - GET state?stateVector= is NOT fetched on every poll cycle. It is fetched:
16
+ * (a) on (re)connect / initial load, (b) when a poll response indicates a
17
+ * gap (version jump > ring-buffer size), (c) after applying an update fails,
18
+ * and (d) as a low-frequency safety net every STATE_VECTOR_FETCH_INTERVAL
19
+ * poll cycles (~15×).
20
+ * - Network errors use exponential backoff with jitter (cap ~15s), reset on
21
+ * success.
22
+ * - SSE fast-path: collab events are received push-style from
23
+ * /_agent-native/poll-events (the existing SSE stream). While SSE is
24
+ * healthy the poll loop relaxes to a slow cadence (10–15s). If SSE is
25
+ * unavailable the 2s poll resumes automatically.
10
26
  */
11
27
  import { useEffect, useRef, useState, useMemo } from "react";
12
28
  import * as Y from "yjs";
@@ -151,8 +167,48 @@ function base64ToUint8Array(b64) {
151
167
  }
152
168
  return arr;
153
169
  }
170
+ /** Debounce delay for coalescing local Yjs update POSTs (ms). */
171
+ const UPDATE_DEBOUNCE_MS = 80;
172
+ /** Fetch state-vector every N poll cycles as a low-frequency safety net. */
173
+ const STATE_VECTOR_FETCH_INTERVAL = 15;
174
+ /** Poll ring-buffer size on the server (MAX_BUFFER in poll.ts). */
175
+ const POLL_RING_BUFFER_SIZE = 200;
176
+ /** Exponential backoff: base delay (ms), multiplier, cap (ms). */
177
+ const BACKOFF_BASE_MS = 500;
178
+ const BACKOFF_MAX_MS = 15_000;
179
+ function calcBackoff(consecutiveErrors) {
180
+ const exp = Math.min(consecutiveErrors, 10);
181
+ const delay = BACKOFF_BASE_MS * Math.pow(2, exp);
182
+ // Add jitter: ±25%
183
+ const jitter = delay * 0.25 * (Math.random() * 2 - 1);
184
+ return Math.min(delay + jitter, BACKOFF_MAX_MS);
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // Fast awareness helper — throttled per (docId, ydocId) pair so multiple
188
+ // setLocalStateField calls within a 150ms window are coalesced into one POST.
189
+ // ---------------------------------------------------------------------------
190
+ const _awarenessThrottleTimers = new Map();
191
+ function scheduleAwarenessPush(baseUrl, docId, clientId, getState) {
192
+ if (typeof window === "undefined")
193
+ return;
194
+ const key = `${docId}::${clientId}`;
195
+ if (_awarenessThrottleTimers.has(key))
196
+ return; // already scheduled
197
+ const timer = setTimeout(() => {
198
+ _awarenessThrottleTimers.delete(key);
199
+ const state = getState();
200
+ if (!state)
201
+ return;
202
+ fetch(`${baseUrl}/${docId}/awareness`, {
203
+ method: "POST",
204
+ headers: { "Content-Type": "application/json" },
205
+ body: JSON.stringify({ clientId, state: JSON.stringify(state) }),
206
+ }).catch(() => { }); // best-effort; poll cycle is the baseline fallback
207
+ }, 150);
208
+ _awarenessThrottleTimers.set(key, timer);
209
+ }
154
210
  export function useCollaborativeDoc(options) {
155
- const { docId, pollInterval = 2000, pauseWhenHidden = true, baseUrl = agentNativePath("/_agent-native/collab"), requestSource, user, } = options;
211
+ const { docId, pollInterval = 2000, pollIntervalWithSse = 12000, pauseWhenHidden = true, baseUrl = agentNativePath("/_agent-native/collab"), requestSource, user, } = options;
156
212
  // Stable Y.Doc per docId
157
213
  const ydoc = useMemo(() => {
158
214
  if (!docId)
@@ -190,6 +246,23 @@ export function useCollaborativeDoc(options) {
190
246
  });
191
247
  awareness.setLocalStateField("visible", !isDocumentHidden());
192
248
  }, [awareness, user?.name, user?.email, user?.color]);
249
+ // Fast awareness push: whenever local state changes (e.g. cursor moves,
250
+ // setPresence() calls), schedule a throttled POST so peers receive updates
251
+ // at ~150ms instead of waiting for the next 2s poll cycle. The poll cycle
252
+ // remains the authoritative baseline (cursors degrade gracefully without SSE).
253
+ useEffect(() => {
254
+ if (!awareness || !ydoc || !docId || !user)
255
+ return;
256
+ const clientId = ydoc.clientID;
257
+ const onLocalStateChange = () => {
258
+ scheduleAwarenessPush(baseUrl, docId, clientId, () => awareness.getLocalState());
259
+ };
260
+ // awareness emits "change" for local state changes too (when origin is "local").
261
+ awareness.on("change", onLocalStateChange);
262
+ return () => {
263
+ awareness.off("change", onLocalStateChange);
264
+ };
265
+ }, [awareness, ydoc, docId, baseUrl, user]);
193
266
  // Track active users from awareness changes
194
267
  useEffect(() => {
195
268
  if (!awareness)
@@ -262,56 +335,206 @@ export function useCollaborativeDoc(options) {
262
335
  cancelled = true;
263
336
  };
264
337
  }, [ydoc, docId, baseUrl]);
265
- // Send local updates to server
338
+ // Send local updates to server — debounced and coalesced with Y.mergeUpdates.
339
+ //
340
+ // Instead of firing one POST per Yjs update (one per keystroke), we accumulate
341
+ // updates in a buffer for UPDATE_DEBOUNCE_MS then merge them into a single
342
+ // request. The buffer is also flushed immediately on visibilitychange/pagehide
343
+ // and before each poll/awareness cycle so we don't hold stale local state.
266
344
  useEffect(() => {
267
345
  if (!ydoc || !docId || docMissing)
268
346
  return;
269
- const handler = (update, origin) => {
270
- if (origin === "remote")
347
+ let pendingUpdates = [];
348
+ let flushTimer = null;
349
+ const flushPendingUpdates = (keepalive = false) => {
350
+ if (flushTimer) {
351
+ clearTimeout(flushTimer);
352
+ flushTimer = null;
353
+ }
354
+ if (pendingUpdates.length === 0)
271
355
  return;
356
+ const toSend = pendingUpdates;
357
+ pendingUpdates = [];
358
+ const merged = toSend.length === 1 ? toSend[0] : Y.mergeUpdates(toSend);
272
359
  fetch(`${baseUrl}/${docId}/update`, {
273
360
  method: "POST",
274
361
  headers: { "Content-Type": "application/json" },
275
362
  body: JSON.stringify({
276
- update: uint8ArrayToBase64(update),
363
+ update: uint8ArrayToBase64(merged),
277
364
  requestSource,
278
365
  }),
279
- });
366
+ ...(keepalive ? { keepalive: true } : {}),
367
+ }).catch(() => { });
368
+ };
369
+ // Expose flush to the poll loop via a ref so it can flush before each cycle.
370
+ // We store the flusher in a closure-captured variable; the poll effect
371
+ // below reads it through the shared `pendingFlushRef`.
372
+ ydoc.__collabFlush = flushPendingUpdates;
373
+ const handler = (update, origin) => {
374
+ if (origin === "remote")
375
+ return;
376
+ pendingUpdates.push(update);
377
+ if (flushTimer)
378
+ clearTimeout(flushTimer);
379
+ flushTimer = setTimeout(flushPendingUpdates, UPDATE_DEBOUNCE_MS);
380
+ };
381
+ const handlePageHide = () => {
382
+ flushPendingUpdates(true /* keepalive */);
280
383
  };
281
384
  ydoc.on("update", handler);
385
+ if (typeof window !== "undefined") {
386
+ window.addEventListener("pagehide", handlePageHide);
387
+ }
282
388
  return () => {
283
389
  ydoc.off("update", handler);
390
+ delete ydoc.__collabFlush;
391
+ if (typeof window !== "undefined") {
392
+ window.removeEventListener("pagehide", handlePageHide);
393
+ }
394
+ // Flush any remaining updates on teardown
395
+ flushPendingUpdates(true);
284
396
  };
285
397
  }, [ydoc, docId, baseUrl, requestSource, docMissing]);
286
- // Poll for remote doc updates + awareness sync
398
+ // Poll for remote doc updates + awareness sync, with SSE fast-path.
287
399
  useEffect(() => {
288
400
  if (!ydoc || !docId || docMissing)
289
401
  return;
290
402
  let stopped = false;
291
403
  let timer = null;
404
+ let consecutiveErrors = 0;
405
+ let pollCycleCount = 0;
406
+ // Track the last version we successfully polled. Used to detect ring-buffer
407
+ // overflow (version gap larger than the ring buffer).
408
+ let lastPolledVersion = pollVersionRef.current;
409
+ // SSE connection state. When SSE is healthy, poll interval is relaxed.
410
+ let sseActive = false;
411
+ let sseEventSource = null;
412
+ // ── SSE fast-path ────────────────────────────────────────────────
413
+ // Wire into the existing /_agent-native/poll-events SSE stream.
414
+ // Collab update events arrive push-style; we apply them immediately,
415
+ // avoiding ~2s polling latency for peer edits.
416
+ //
417
+ // NOTE: SSE events are subject to the same server-side access scoping as
418
+ // polling — the server only pushes events that canSeeChangeForUser allows.
419
+ // The server tags collab events with owner/orgId (security commit).
420
+ function initSSE() {
421
+ if (typeof EventSource === "undefined")
422
+ return;
423
+ try {
424
+ const es = new EventSource(agentNativePath("/_agent-native/poll-events"));
425
+ sseEventSource = es;
426
+ es.onopen = () => {
427
+ sseActive = true;
428
+ consecutiveErrors = 0;
429
+ };
430
+ es.onmessage = (ev) => {
431
+ try {
432
+ const change = JSON.parse(ev.data);
433
+ if (change.source === "collab" &&
434
+ change.docId === docId &&
435
+ change.update) {
436
+ if (requestSource && change.requestSource === requestSource)
437
+ return;
438
+ try {
439
+ Y.applyUpdate(ydoc, base64ToUint8Array(change.update), "remote");
440
+ }
441
+ catch {
442
+ // Malformed update — trigger state-vector fetch on next poll
443
+ }
444
+ if (change.requestSource === "agent") {
445
+ setAgentActive(true);
446
+ if (agentTimerRef.current)
447
+ clearTimeout(agentTimerRef.current);
448
+ agentTimerRef.current = setTimeout(() => setAgentActive(false), 3000);
449
+ }
450
+ }
451
+ // Keep pollVersionRef updated from SSE events so the poll loop
452
+ // starts from the right version when SSE drops.
453
+ if (typeof change.version === "number") {
454
+ pollVersionRef.current = Math.max(pollVersionRef.current, change.version);
455
+ }
456
+ }
457
+ catch {
458
+ // Ignore malformed events
459
+ }
460
+ };
461
+ es.onerror = () => {
462
+ sseActive = false;
463
+ es.close();
464
+ sseEventSource = null;
465
+ // Retry SSE after a short delay
466
+ if (!stopped) {
467
+ setTimeout(initSSE, 5_000 + Math.random() * 5_000);
468
+ }
469
+ };
470
+ }
471
+ catch {
472
+ // SSE not available (edge runtime, etc.) — fall back to polling only
473
+ sseActive = false;
474
+ }
475
+ }
476
+ // Only set up SSE in browser environments that support it.
477
+ if (typeof EventSource !== "undefined") {
478
+ initSSE();
479
+ }
480
+ // ── Poll loop ───────────────────────────────────────────────────
481
+ function getActivePollInterval() {
482
+ return sseActive ? pollIntervalWithSse : pollInterval;
483
+ }
292
484
  function schedulePoll() {
293
485
  if (stopped)
294
486
  return;
295
487
  if (pauseWhenHidden && isDocumentHidden())
296
488
  return;
297
- timer = setTimeout(poll, pollInterval);
489
+ timer = setTimeout(poll, getActivePollInterval());
490
+ }
491
+ async function fetchStateVector() {
492
+ try {
493
+ const stateVector = uint8ArrayToBase64(Y.encodeStateVector(ydoc));
494
+ const stateRes = await fetch(`${baseUrl}/${docId}/state?stateVector=${encodeURIComponent(stateVector)}`);
495
+ if (stateRes.ok) {
496
+ const stateData = (await stateRes.json().catch(() => null));
497
+ if (stateData?.state) {
498
+ const binary = base64ToUint8Array(stateData.state);
499
+ if (binary.length > 2) {
500
+ Y.applyUpdate(ydoc, binary, "remote");
501
+ }
502
+ }
503
+ }
504
+ }
505
+ catch {
506
+ // Non-fatal; the next poll cycle will retry
507
+ }
298
508
  }
299
509
  async function poll() {
300
510
  if (stopped)
301
511
  return;
512
+ // Flush any pending local updates before polling so the server has the
513
+ // latest state before we read remote changes.
514
+ const flush = ydoc.__collabFlush;
515
+ flush?.();
302
516
  try {
303
- // Poll for document updates
304
517
  const res = await fetch(agentNativePath(`/_agent-native/poll?since=${pollVersionRef.current}`));
305
518
  if (!res.ok)
306
519
  throw new Error("HTTP " + res.status);
307
520
  const data = await res.json();
308
521
  const { version, events } = data;
522
+ // Detect ring-buffer overflow: if the version jumped by more than the
523
+ // ring buffer size, some events were evicted and we need a state-vector
524
+ // fetch to reconcile the gap.
525
+ const versionGap = version - lastPolledVersion;
526
+ const hadGap = versionGap > POLL_RING_BUFFER_SIZE;
309
527
  for (const evt of events) {
310
528
  if (evt.source === "collab" && evt.docId === docId && evt.update) {
311
529
  if (requestSource && evt.requestSource === requestSource)
312
530
  continue;
313
- Y.applyUpdate(ydoc, base64ToUint8Array(evt.update), "remote");
314
- // Show agent presence indicator briefly
531
+ try {
532
+ Y.applyUpdate(ydoc, base64ToUint8Array(evt.update), "remote");
533
+ }
534
+ catch {
535
+ // Failed to apply — fetch full state-vector below
536
+ await fetchStateVector();
537
+ }
315
538
  if (evt.requestSource === "agent") {
316
539
  setAgentActive(true);
317
540
  if (agentTimerRef.current)
@@ -321,64 +544,67 @@ export function useCollaborativeDoc(options) {
321
544
  }
322
545
  }
323
546
  pollVersionRef.current = version;
324
- try {
325
- // The poll ring buffer is process-local. Fetching a state-vector diff
326
- // makes collaboration durable across serverless invocations, process
327
- // restarts, or any missed poll event.
328
- const stateVector = uint8ArrayToBase64(Y.encodeStateVector(ydoc));
329
- const stateRes = await fetch(`${baseUrl}/${docId}/state?stateVector=${encodeURIComponent(stateVector)}`);
330
- if (stateRes.ok) {
331
- const stateData = (await stateRes.json().catch(() => null));
332
- if (stateData?.state) {
333
- const binary = base64ToUint8Array(stateData.state);
334
- if (binary.length > 2) {
335
- Y.applyUpdate(ydoc, binary, "remote");
336
- }
337
- }
338
- }
339
- }
340
- catch {
341
- // The next poll retries; awareness should still sync below.
547
+ lastPolledVersion = version;
548
+ pollCycleCount++;
549
+ consecutiveErrors = 0;
550
+ // Fetch state-vector only when needed:
551
+ // 1. Ring-buffer overflow detected (missed events).
552
+ // 2. Low-frequency safety net every STATE_VECTOR_FETCH_INTERVAL cycles.
553
+ // 3. NOT on every cycle (the previous behavior causing 3 requests/cycle).
554
+ const shouldFetchStateVector = hadGap || pollCycleCount % STATE_VECTOR_FETCH_INTERVAL === 0;
555
+ if (shouldFetchStateVector) {
556
+ await fetchStateVector();
342
557
  }
343
558
  // Sync awareness (cursor positions)
344
559
  if (awareness) {
345
560
  const localState = awareness.getLocalState();
346
561
  if (localState) {
347
- const awarenessRes = await fetch(`${baseUrl}/${docId}/awareness`, {
348
- method: "POST",
349
- headers: { "Content-Type": "application/json" },
350
- body: JSON.stringify({
351
- clientId: ydoc.clientID,
352
- state: JSON.stringify(localState),
353
- }),
354
- });
355
- if (awarenessRes.ok) {
356
- const awarenessData = await awarenessRes.json();
357
- const remoteStates = [];
358
- for (const remote of awarenessData.states || []) {
359
- try {
360
- const remoteState = JSON.parse(remote.state);
361
- remoteStates.push({
362
- clientId: Number(remote.clientId),
363
- state: remoteState,
364
- });
562
+ try {
563
+ const awarenessRes = await fetch(`${baseUrl}/${docId}/awareness`, {
564
+ method: "POST",
565
+ headers: { "Content-Type": "application/json" },
566
+ body: JSON.stringify({
567
+ clientId: ydoc.clientID,
568
+ state: JSON.stringify(localState),
569
+ }),
570
+ });
571
+ if (awarenessRes.ok) {
572
+ const awarenessData = await awarenessRes.json();
573
+ const remoteStates = [];
574
+ for (const remote of awarenessData.states || []) {
575
+ try {
576
+ const remoteState = JSON.parse(remote.state);
577
+ remoteStates.push({
578
+ clientId: Number(remote.clientId),
579
+ state: remoteState,
580
+ });
581
+ }
582
+ catch {
583
+ // Invalid state — skip
584
+ }
365
585
  }
366
- catch {
367
- // Invalid state — skip
586
+ const changes = reconcileRemoteAwarenessStates(awareness.getStates(), ydoc.clientID, remoteStates);
587
+ if (changes.added.length ||
588
+ changes.updated.length ||
589
+ changes.removed.length) {
590
+ awareness.emit("change", [changes, "remote"]);
368
591
  }
369
592
  }
370
- const changes = reconcileRemoteAwarenessStates(awareness.getStates(), ydoc.clientID, remoteStates);
371
- if (changes.added.length ||
372
- changes.updated.length ||
373
- changes.removed.length) {
374
- awareness.emit("change", [changes, "remote"]);
375
- }
593
+ }
594
+ catch {
595
+ // Awareness sync failure is non-fatal
376
596
  }
377
597
  }
378
598
  }
379
599
  }
380
600
  catch {
381
- // Network error — retry next interval
601
+ // Network error — exponential backoff
602
+ consecutiveErrors++;
603
+ const backoff = calcBackoff(consecutiveErrors);
604
+ if (!stopped) {
605
+ timer = setTimeout(poll, backoff);
606
+ return;
607
+ }
382
608
  }
383
609
  schedulePoll();
384
610
  }
@@ -394,7 +620,7 @@ export function useCollaborativeDoc(options) {
394
620
  // Publish this tab's visibility to peers. A hidden tab pauses its poll, so
395
621
  // we push the state immediately (keepalive) instead of waiting for the next
396
622
  // cycle — otherwise peers keep treating a backgrounded tab as the visible
397
- // lead and an agent edit never lands on the tab the user is looking at.
623
+ // lead and an agent edit never lands on the tab the user is actually viewing.
398
624
  function publishVisibility(visible) {
399
625
  if (!awareness)
400
626
  return;
@@ -416,6 +642,9 @@ export function useCollaborativeDoc(options) {
416
642
  const visible = document.visibilityState === "visible";
417
643
  publishVisibility(visible);
418
644
  if (visible) {
645
+ // Also flush any pending updates when coming back into view
646
+ const flush = ydoc.__collabFlush;
647
+ flush?.();
419
648
  pollNow();
420
649
  }
421
650
  else if (pauseWhenHidden && timer) {
@@ -432,6 +661,10 @@ export function useCollaborativeDoc(options) {
432
661
  stopped = true;
433
662
  if (timer)
434
663
  clearTimeout(timer);
664
+ if (sseEventSource) {
665
+ sseEventSource.close();
666
+ sseEventSource = null;
667
+ }
435
668
  window.removeEventListener("focus", pollNow);
436
669
  document.removeEventListener("visibilitychange", handleVisibilityChange);
437
670
  };
@@ -440,11 +673,83 @@ export function useCollaborativeDoc(options) {
440
673
  awareness,
441
674
  docId,
442
675
  pollInterval,
676
+ pollIntervalWithSse,
443
677
  pauseWhenHidden,
444
678
  requestSource,
445
679
  baseUrl,
446
680
  docMissing,
447
681
  ]);
682
+ // SSE fast-path for awareness: subscribe to the poll-events stream and
683
+ // apply any awareness-change events immediately so peers receive cursor
684
+ // moves push-style without waiting for the next poll cycle.
685
+ // Polling fallback keeps working when SSE is unavailable.
686
+ useEffect(() => {
687
+ if (!ydoc || !docId || !awareness || typeof EventSource === "undefined") {
688
+ return;
689
+ }
690
+ const sseUrl = agentNativePath("/_agent-native/poll-events");
691
+ let source = null;
692
+ let stopped = false;
693
+ function connect() {
694
+ if (stopped || source)
695
+ return;
696
+ source = new EventSource(sseUrl);
697
+ source.onmessage = (msg) => {
698
+ if (stopped)
699
+ return;
700
+ try {
701
+ const data = JSON.parse(msg.data);
702
+ if (data.source !== "awareness" ||
703
+ data.type !== "awareness-change" ||
704
+ data.docId !== docId) {
705
+ return;
706
+ }
707
+ const remoteStates = [];
708
+ for (const remote of data.states ?? []) {
709
+ try {
710
+ remoteStates.push({
711
+ clientId: Number(remote.clientId),
712
+ state: JSON.parse(remote.state),
713
+ });
714
+ }
715
+ catch {
716
+ // Invalid state entry — skip
717
+ }
718
+ }
719
+ const changes = reconcileRemoteAwarenessStates(awareness.getStates(), ydoc.clientID, remoteStates);
720
+ if (changes.added.length ||
721
+ changes.updated.length ||
722
+ changes.removed.length) {
723
+ awareness.emit("change", [changes, "remote"]);
724
+ }
725
+ }
726
+ catch {
727
+ // Ignore malformed SSE frames; poll cycle is the safety net.
728
+ }
729
+ };
730
+ source.onerror = () => {
731
+ // On permanent close let go of the ref so re-focus can reconnect.
732
+ if (source && source.readyState === EventSource.CLOSED) {
733
+ source = null;
734
+ }
735
+ };
736
+ }
737
+ connect();
738
+ function onFocus() {
739
+ if (!source || source.readyState === EventSource.CLOSED) {
740
+ source?.close();
741
+ source = null;
742
+ connect();
743
+ }
744
+ }
745
+ window.addEventListener("focus", onFocus);
746
+ return () => {
747
+ stopped = true;
748
+ source?.close();
749
+ source = null;
750
+ window.removeEventListener("focus", onFocus);
751
+ };
752
+ }, [ydoc, docId, awareness]);
448
753
  return {
449
754
  ydoc,
450
755
  awareness,