@agent-native/core 0.90.4 → 0.90.7

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 (189) hide show
  1. package/README.md +34 -7
  2. package/corpus/README.md +1 -1
  3. package/corpus/core/CHANGELOG.md +21 -0
  4. package/corpus/core/docs/content/agent-native-toolkit.mdx +1 -1
  5. package/corpus/core/docs/content/locales/ar-SA/agent-native-toolkit.mdx +1 -1
  6. package/corpus/core/docs/content/locales/de-DE/agent-native-toolkit.mdx +1 -1
  7. package/corpus/core/docs/content/locales/es-ES/agent-native-toolkit.mdx +1 -1
  8. package/corpus/core/docs/content/locales/fr-FR/agent-native-toolkit.mdx +1 -1
  9. package/corpus/core/docs/content/locales/hi-IN/agent-native-toolkit.mdx +1 -1
  10. package/corpus/core/docs/content/locales/ja-JP/agent-native-toolkit.mdx +1 -1
  11. package/corpus/core/docs/content/locales/ko-KR/agent-native-toolkit.mdx +1 -1
  12. package/corpus/core/docs/content/locales/pt-BR/agent-native-toolkit.mdx +1 -1
  13. package/corpus/core/docs/content/locales/zh-CN/agent-native-toolkit.mdx +1 -1
  14. package/corpus/core/docs/content/locales/zh-TW/agent-native-toolkit.mdx +1 -1
  15. package/corpus/core/docs/content/toolkit-agent-ux.mdx +1 -1
  16. package/corpus/core/docs/content/toolkit-app-adapters.mdx +1 -1
  17. package/corpus/core/docs/content/toolkit-collaboration-ui.mdx +1 -1
  18. package/corpus/core/docs/content/toolkit-collaboration.mdx +1 -1
  19. package/corpus/core/docs/content/toolkit-command-navigation.mdx +2 -2
  20. package/corpus/core/docs/content/toolkit-comments-review.mdx +1 -1
  21. package/corpus/core/docs/content/toolkit-history.mdx +1 -1
  22. package/corpus/core/docs/content/toolkit-observability.mdx +1 -1
  23. package/corpus/core/docs/content/toolkit-org-team.mdx +1 -1
  24. package/corpus/core/docs/content/toolkit-resources.mdx +1 -1
  25. package/corpus/core/docs/content/toolkit-settings.mdx +1 -1
  26. package/corpus/core/docs/content/toolkit-setup-connections.mdx +1 -1
  27. package/corpus/core/docs/content/toolkit-sharing-ui.mdx +1 -1
  28. package/corpus/core/docs/content/toolkit-sharing.mdx +1 -1
  29. package/corpus/core/docs/content/toolkit-shell-hooks.mdx +1 -1
  30. package/corpus/core/docs/content/toolkit-ui.mdx +1 -1
  31. package/corpus/core/package.json +1 -1
  32. package/corpus/core/src/agent/durable-background.ts +18 -18
  33. package/corpus/core/src/agent/engine/ai-sdk-engine.ts +8 -2
  34. package/corpus/core/src/agent/engine/anthropic-engine.ts +11 -1
  35. package/corpus/core/src/agent/production-agent.ts +22 -11
  36. package/corpus/core/src/agent/run-manager.ts +17 -6
  37. package/corpus/core/src/agent/run-store.ts +16 -11
  38. package/corpus/core/src/cli/design-connect.ts +16 -2
  39. package/corpus/core/src/client/KeepTabOpenNotice.tsx +20 -15
  40. package/corpus/core/src/client/RunStuckBanner.tsx +14 -13
  41. package/corpus/core/src/client/agent-chat-adapter.ts +28 -17
  42. package/corpus/core/src/client/use-db-sync.ts +99 -1
  43. package/corpus/core/src/client/use-run-stuck-detection.ts +8 -5
  44. package/corpus/core/src/collab/client.ts +49 -4
  45. package/corpus/core/src/deploy/build.ts +103 -0
  46. package/corpus/core/src/server/core-routes-plugin.ts +137 -8
  47. package/corpus/templates/clips/changelog/2026-07-07-faster-local-whisper-dictation-start.md +6 -0
  48. package/corpus/templates/clips/desktop/src-tauri/src/native_speech.rs +311 -31
  49. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +2 -0
  50. package/corpus/templates/content/AGENTS.md +1 -0
  51. package/corpus/templates/content/actions/_database-source-utils.ts +73 -8
  52. package/corpus/templates/content/actions/_database-utils.ts +2 -1
  53. package/corpus/templates/content/actions/prepare-builder-source-review.ts +20 -1
  54. package/corpus/templates/content/actions/stage-builder-source-bulk-update.ts +663 -0
  55. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +33 -7
  56. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +53 -4
  57. package/corpus/templates/content/app/components/editor/database/settings.tsx +24 -0
  58. package/corpus/templates/content/app/hooks/use-content-database.ts +70 -1
  59. package/corpus/templates/content/app/hooks/use-document-properties.ts +63 -5
  60. package/corpus/templates/content/app/i18n/zh-TW.ts +3 -0
  61. package/corpus/templates/content/app/i18n-data.ts +31 -0
  62. package/corpus/templates/content/changelog/2026-07-01-builder-source-rows-can-be-staged-in-bulk-with-a-reviewable.md +6 -0
  63. package/corpus/templates/content/changelog/2026-07-07-builder-backed-database-fields-now-edit-like-normal-content-fields.md +6 -0
  64. package/corpus/templates/content/parity/matrix.md +24 -24
  65. package/corpus/templates/content/parity/matrix.ts +2 -0
  66. package/corpus/templates/content/shared/api.ts +59 -4
  67. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +9 -0
  68. package/corpus/templates/design/actions/connect-localhost.ts +26 -3
  69. package/corpus/templates/design/actions/open-visual-edit.ts +4 -37
  70. package/corpus/templates/design/actions/read-source-file.ts +28 -0
  71. package/corpus/templates/design/actions/update-file.ts +76 -7
  72. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +207 -11
  73. package/corpus/templates/design/app/components/design/EditPanel.tsx +4 -0
  74. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +387 -25
  75. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +1110 -88
  76. package/corpus/templates/design/app/components/design/bridge/hit-test.bridge.ts +368 -16
  77. package/corpus/templates/design/app/components/design/edit-panel/fill-gradient-helpers.ts +24 -0
  78. package/corpus/templates/design/app/components/design/edit-panel/panel-primitives.tsx +27 -6
  79. package/corpus/templates/design/app/components/design/edit-panel/position-helpers.ts +18 -0
  80. package/corpus/templates/design/app/components/design/edit-panel/stroke-properties.tsx +5 -4
  81. package/corpus/templates/design/app/components/design/multi-screen/handle-hit-zones.ts +124 -0
  82. package/corpus/templates/design/app/components/design/multi-screen/types.ts +23 -0
  83. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  84. package/corpus/templates/design/app/i18n-data.ts +19 -0
  85. package/corpus/templates/design/changelog/2026-07-07-dragging-a-free-floating-element-into-a-layout-column-or-row.md +6 -0
  86. package/corpus/templates/design/changelog/2026-07-07-dragging-a-repeated-list-item-like-a-to-do-card-generated-fr.md +6 -0
  87. package/corpus/templates/design/changelog/2026-07-07-dragging-an-element-onto-a-button-or-chip-s-middle-no-longer.md +6 -0
  88. package/corpus/templates/design/changelog/2026-07-07-elements-dropped-into-another-board-shape-now-stay-exactly-w.md +6 -0
  89. package/corpus/templates/design/changelog/2026-07-07-fixed-drag-reordering-elements-within-a-screen-no-longer-sil.md +6 -0
  90. package/corpus/templates/design/changelog/2026-07-07-fixed-dragging-an-element-from-a-screen-onto-the-canvas-now-.md +6 -0
  91. package/corpus/templates/design/changelog/2026-07-07-fixed-dragging-elements-between-screens-into-ai-generated-co.md +6 -0
  92. package/corpus/templates/design/changelog/2026-07-07-fixed-moving-elements-between-screens-no-longer-bakes-intern.md +6 -0
  93. package/corpus/templates/design/changelog/2026-07-07-fixed-the-cross-screen-drop-indicator-now-points-at-the-corr.md +6 -0
  94. package/corpus/templates/design/changelog/2026-07-07-fixed-the-zoom-menu-no-longer-leaves-the-canvas-unresponsive.md +6 -0
  95. package/corpus/templates/design/changelog/2026-07-07-local-visual-edit-keeps-bridge-access-when-opening-previousl.md +6 -0
  96. package/corpus/templates/design/changelog/2026-07-07-local-visual-edit-now-keeps-the-live-app-connection-authenti.md +6 -0
  97. package/corpus/templates/design/changelog/2026-07-07-text-drawn-on-the-canvas-is-now-readable-white-instead-of-in.md +6 -0
  98. package/corpus/templates/design/server/source-workspace.ts +27 -3
  99. package/corpus/templates/design/shared/board-file.ts +359 -0
  100. package/corpus/templates/design/shared/breakpoint-media.ts +21 -3
  101. package/corpus/templates/design/shared/code-layer.ts +145 -10
  102. package/dist/agent/durable-background.d.ts +12 -12
  103. package/dist/agent/durable-background.js +18 -18
  104. package/dist/agent/durable-background.js.map +1 -1
  105. package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
  106. package/dist/agent/engine/ai-sdk-engine.js +8 -2
  107. package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
  108. package/dist/agent/engine/anthropic-engine.d.ts.map +1 -1
  109. package/dist/agent/engine/anthropic-engine.js +11 -1
  110. package/dist/agent/engine/anthropic-engine.js.map +1 -1
  111. package/dist/agent/production-agent.d.ts +2 -2
  112. package/dist/agent/production-agent.d.ts.map +1 -1
  113. package/dist/agent/production-agent.js +18 -11
  114. package/dist/agent/production-agent.js.map +1 -1
  115. package/dist/agent/run-manager.d.ts +7 -1
  116. package/dist/agent/run-manager.d.ts.map +1 -1
  117. package/dist/agent/run-manager.js +6 -1
  118. package/dist/agent/run-manager.js.map +1 -1
  119. package/dist/agent/run-store.d.ts +1 -1
  120. package/dist/agent/run-store.d.ts.map +1 -1
  121. package/dist/agent/run-store.js +15 -10
  122. package/dist/agent/run-store.js.map +1 -1
  123. package/dist/cli/design-connect.d.ts.map +1 -1
  124. package/dist/cli/design-connect.js +12 -2
  125. package/dist/cli/design-connect.js.map +1 -1
  126. package/dist/client/KeepTabOpenNotice.d.ts +9 -10
  127. package/dist/client/KeepTabOpenNotice.d.ts.map +1 -1
  128. package/dist/client/KeepTabOpenNotice.js +8 -4
  129. package/dist/client/KeepTabOpenNotice.js.map +1 -1
  130. package/dist/client/RunStuckBanner.d.ts.map +1 -1
  131. package/dist/client/RunStuckBanner.js +14 -13
  132. package/dist/client/RunStuckBanner.js.map +1 -1
  133. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  134. package/dist/client/agent-chat-adapter.js +25 -17
  135. package/dist/client/agent-chat-adapter.js.map +1 -1
  136. package/dist/client/use-db-sync.d.ts +18 -0
  137. package/dist/client/use-db-sync.d.ts.map +1 -1
  138. package/dist/client/use-db-sync.js +88 -1
  139. package/dist/client/use-db-sync.js.map +1 -1
  140. package/dist/client/use-run-stuck-detection.d.ts +1 -1
  141. package/dist/client/use-run-stuck-detection.d.ts.map +1 -1
  142. package/dist/client/use-run-stuck-detection.js +6 -4
  143. package/dist/client/use-run-stuck-detection.js.map +1 -1
  144. package/dist/collab/client.d.ts.map +1 -1
  145. package/dist/collab/client.js +43 -4
  146. package/dist/collab/client.js.map +1 -1
  147. package/dist/collab/routes.d.ts +1 -1
  148. package/dist/collab/struct-routes.d.ts +1 -1
  149. package/dist/deploy/build.d.ts +1 -0
  150. package/dist/deploy/build.d.ts.map +1 -1
  151. package/dist/deploy/build.js +57 -0
  152. package/dist/deploy/build.js.map +1 -1
  153. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  154. package/dist/notifications/routes.d.ts +2 -2
  155. package/dist/resources/handlers.d.ts +3 -3
  156. package/dist/secrets/routes.d.ts +9 -9
  157. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  158. package/dist/server/core-routes-plugin.d.ts +10 -0
  159. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  160. package/dist/server/core-routes-plugin.js +103 -9
  161. package/dist/server/core-routes-plugin.js.map +1 -1
  162. package/docs/content/agent-native-toolkit.mdx +1 -1
  163. package/docs/content/locales/ar-SA/agent-native-toolkit.mdx +1 -1
  164. package/docs/content/locales/de-DE/agent-native-toolkit.mdx +1 -1
  165. package/docs/content/locales/es-ES/agent-native-toolkit.mdx +1 -1
  166. package/docs/content/locales/fr-FR/agent-native-toolkit.mdx +1 -1
  167. package/docs/content/locales/hi-IN/agent-native-toolkit.mdx +1 -1
  168. package/docs/content/locales/ja-JP/agent-native-toolkit.mdx +1 -1
  169. package/docs/content/locales/ko-KR/agent-native-toolkit.mdx +1 -1
  170. package/docs/content/locales/pt-BR/agent-native-toolkit.mdx +1 -1
  171. package/docs/content/locales/zh-CN/agent-native-toolkit.mdx +1 -1
  172. package/docs/content/locales/zh-TW/agent-native-toolkit.mdx +1 -1
  173. package/docs/content/toolkit-agent-ux.mdx +1 -1
  174. package/docs/content/toolkit-app-adapters.mdx +1 -1
  175. package/docs/content/toolkit-collaboration-ui.mdx +1 -1
  176. package/docs/content/toolkit-collaboration.mdx +1 -1
  177. package/docs/content/toolkit-command-navigation.mdx +2 -2
  178. package/docs/content/toolkit-comments-review.mdx +1 -1
  179. package/docs/content/toolkit-history.mdx +1 -1
  180. package/docs/content/toolkit-observability.mdx +1 -1
  181. package/docs/content/toolkit-org-team.mdx +1 -1
  182. package/docs/content/toolkit-resources.mdx +1 -1
  183. package/docs/content/toolkit-settings.mdx +1 -1
  184. package/docs/content/toolkit-setup-connections.mdx +1 -1
  185. package/docs/content/toolkit-sharing-ui.mdx +1 -1
  186. package/docs/content/toolkit-sharing.mdx +1 -1
  187. package/docs/content/toolkit-shell-hooks.mdx +1 -1
  188. package/docs/content/toolkit-ui.mdx +1 -1
  189. package/package.json +1 -1
@@ -175,15 +175,16 @@ export function RunStuckBanner({
175
175
  }
176
176
  const ownerId = autoRetryOwnerId ?? generatedOwnerIdRef.current;
177
177
  const backgroundWorkerStillAlive = isFreshBackgroundWorker(state);
178
- // Background-dispatched runs are recovered by the SERVER (chained
179
- // continuation chunks + lost-handoff sweep); an automatic client abort
180
- // would kill a live server-chained run. Auto-retry is therefore disabled
181
- // unconditionally for ANY background dispatch modenot just a
182
- // fresh-heartbeat worker — and the localStorage/Web-Locks auto-retry claim
183
- // below is never taken for these runs (the adapter's follow loop is
184
- // read-only, so multiple tabs need no retry dedup). Only the manual banner
185
- // remains, on the wider background threshold from useRunStuckDetection.
186
- const isBackgroundDispatch =
178
+ // Server-continued runs are recovered by the SERVER (chained continuation
179
+ // chunks + lost-handoff sweep); an automatic client abort would kill a live
180
+ // server-chained run. Auto-retry is therefore disabled unconditionally for
181
+ // these modes not just a fresh-heartbeat worker and the
182
+ // localStorage/Web-Locks auto-retry claim below is never taken for them (the
183
+ // adapter's follow loop is read-only, so multiple tabs need no retry dedup).
184
+ // Only the manual banner remains, on the wider server-owned threshold from
185
+ // useRunStuckDetection.
186
+ const isServerContinuedDispatch =
187
+ state.dispatchMode === "foreground-self-chain" ||
187
188
  state.dispatchMode?.startsWith("background") === true;
188
189
 
189
190
  const lastReportedRef = useRef<{
@@ -224,9 +225,9 @@ export function RunStuckBanner({
224
225
  useEffect(() => {
225
226
  if (
226
227
  !autoRetry ||
227
- // Server owns recovery for background dispatch modes — never auto-abort
228
- // (see comment on isBackgroundDispatch above).
229
- isBackgroundDispatch ||
228
+ // Server owns recovery for these dispatch modes — never auto-abort (see
229
+ // comment on isServerContinuedDispatch above).
230
+ isServerContinuedDispatch ||
230
231
  backgroundWorkerStillAlive ||
231
232
  !state.isStuck ||
232
233
  !state.runId ||
@@ -263,7 +264,7 @@ export function RunStuckBanner({
263
264
  autoRetry,
264
265
  backgroundWorkerStillAlive,
265
266
  busy,
266
- isBackgroundDispatch,
267
+ isServerContinuedDispatch,
267
268
  onRetry,
268
269
  ownerId,
269
270
  state.isStuck,
@@ -133,16 +133,15 @@ const MAX_HISTORY_LARGE_TOOL_ARGS_CHARS = 200_000;
133
133
  const STARTUP_RESPONSE_TIMEOUT_MS = 45_000;
134
134
 
135
135
  // ── Background follow mode ──────────────────────────────────────────────────
136
- // For background-dispatched runs (dispatchMode starts with "background") the
137
- // SERVER is the sole recovery brain: it chains continuation chunks itself
138
- // (fresh runId, same turnId), pre-inserts the successor run row BEFORE the old
139
- // chunk completes (so /runs/active shows an active run continuously across
140
- // chunk boundaries), and a server sweep reaps lost handoffs into loud terminal
141
- // errors. The client therefore never POSTs synthetic continuations for these
142
- // runs it demotes itself to a READER of server state: poll /runs/active,
143
- // (re)attach to whichever run of this turn is live, and fold its events into
144
- // the same assistant message. Read-only, so multiple tabs following the same
145
- // run are safe (no localStorage/Web-Locks claim needed).
136
+ // For server-continued runs (dispatchMode starts with "background" or equals
137
+ // "foreground-self-chain") the SERVER normally chains continuation chunks
138
+ // itself (fresh runId, same turnId), pre-inserts the successor run row BEFORE
139
+ // the old chunk completes (so /runs/active shows an active run continuously
140
+ // across chunk boundaries), and a server sweep reaps lost handoffs into loud
141
+ // terminal errors. The client demotes itself to a READER of server state for
142
+ // healthy handoffs. Foreground self-chain keeps one escape hatch: if the server
143
+ // reports that the self-dispatch itself failed before a successor claimed the
144
+ // run, the existing client auto-continue POST remains the fallback.
146
145
  const BACKGROUND_FOLLOW_POLL_INTERVAL_MS = 1_000;
147
146
  // How long the follow loop tolerates seeing NO active run for this turn before
148
147
  // treating the turn as ended. The server pre-inserts the successor row before
@@ -1687,12 +1686,24 @@ export function createAgentChatAdapter(
1687
1686
  if (mode) currentRunDispatchMode = mode;
1688
1687
  };
1689
1688
 
1690
- // Mode switch for recovery ownership: background-dispatched runs are
1691
- // recovered by the SERVER (chained continuations, sweeps); the client
1692
- // only follows. Foreground (null/"foreground"/unknown) keeps the full
1693
- // client-side continuation machinery unchanged.
1694
- const isBackgroundDispatch = () =>
1689
+ // Mode switch for recovery ownership: durable/background-dispatched runs
1690
+ // are always recovered by the server. Foreground self-chain follows the
1691
+ // server on healthy handoffs, but a loud self-dispatch failure falls back
1692
+ // to the client POST path because no successor run owns recovery yet.
1693
+ const isDurableBackgroundDispatch = () =>
1695
1694
  currentRunDispatchMode?.startsWith("background") === true;
1695
+ const isForegroundSelfChainDispatch = () =>
1696
+ currentRunDispatchMode === "foreground-self-chain";
1697
+ const shouldFollowServerContinuation = (
1698
+ signal?: AgentAutoContinueSignal,
1699
+ ) => {
1700
+ if (isDurableBackgroundDispatch()) return true;
1701
+ if (!isForegroundSelfChainDispatch()) return false;
1702
+ return (
1703
+ signal?.errorInfo?.errorCode !==
1704
+ "background_continuation_dispatch_failed"
1705
+ );
1706
+ };
1696
1707
 
1697
1708
  const rememberRunSeq = (seq: number) => {
1698
1709
  lastSeq = seq;
@@ -2966,7 +2977,7 @@ export function createAgentChatAdapter(
2966
2977
  // following of server state instead. This is the fix for the
2967
2978
  // client/server recovery race: client watchdog signals here are
2968
2979
  // just "reattach", not "recover".
2969
- if (isBackgroundDispatch() && threadId) {
2980
+ if (shouldFollowServerContinuation(err) && threadId) {
2970
2981
  yield* followBackgroundTurn(err);
2971
2982
  return;
2972
2983
  }
@@ -3145,7 +3156,7 @@ export function createAgentChatAdapter(
3145
3156
  // synthetic-continuation POST below. (An initial POST that failed
3146
3157
  // before any response headers arrived has no dispatch mode yet
3147
3158
  // and keeps the foreground startup-retry path.)
3148
- if (isBackgroundDispatch() && threadId) {
3159
+ if (shouldFollowServerContinuation() && threadId) {
3149
3160
  yield* followBackgroundTurn(
3150
3161
  new AgentAutoContinueSignal({ reason: "stream_ended" }),
3151
3162
  );
@@ -22,6 +22,13 @@ interface QueryClient {
22
22
  const POLL_ABORT_MIN_MS = 10_000;
23
23
  const SSE_FALLBACK_INTERVAL_MS = 15_000;
24
24
  const POLL_AUTH_FAILURE_COOLDOWN_MS = 60_000;
25
+ /**
26
+ * Max cadence for SSE/poll-driven query invalidation in `useDbSync`. Events
27
+ * that arrive within this window of the first one in a burst are merged into
28
+ * a single `invalidateForEvents` call instead of one call per event — see the
29
+ * `queueInvalidateBatch` comment at the call site.
30
+ */
31
+ const INVALIDATE_COALESCE_MS = 250;
25
32
 
26
33
  class HttpStatusError extends Error {
27
34
  status: number;
@@ -94,6 +101,46 @@ function isAuthFailure(error: unknown): boolean {
94
101
  );
95
102
  }
96
103
 
104
+ /**
105
+ * App-state keys that drive immediate UI navigation/interaction and must
106
+ * never sit behind the invalidation coalesce window (see
107
+ * `isInteractionCriticalSyncEvent`).
108
+ */
109
+ const INTERACTION_CRITICAL_APP_STATE_KEYS = [
110
+ "navigate",
111
+ "show-questions",
112
+ "__set_url__",
113
+ ];
114
+
115
+ /**
116
+ * True for sync events that drive immediate, agent-initiated UI navigation
117
+ * or interaction rather than passive data invalidation — app-state writes in
118
+ * general (they back `["app-state"]` queries directly), and specifically the
119
+ * `navigate` / `show-questions` / `__set_url__` app-state keys that
120
+ * `invalidateForEvents` special-cases into their own query keys below.
121
+ *
122
+ * `useDbSync` batches ordinary invalidation-driving events (action/collab/db
123
+ * change events) into one flush per `INVALIDATE_COALESCE_MS` so a chatty doc
124
+ * doesn't refetch on every keystroke. That trade-off is wrong for these
125
+ * events: agent-driven navigation, `set-url`, and guided-questions prompts
126
+ * must land as close to instantly as possible, so any batch containing one
127
+ * of these bypasses the coalesce window and flushes immediately instead.
128
+ *
129
+ * Exported as a small pure predicate so this classification is unit-testable
130
+ * independent of the transport/timer plumbing around it.
131
+ */
132
+ export function isInteractionCriticalSyncEvent(event: SyncEvent): boolean {
133
+ return (
134
+ event.source === "app-state" &&
135
+ (event.key === "*" ||
136
+ INTERACTION_CRITICAL_APP_STATE_KEYS.some(
137
+ (key) =>
138
+ event.key === key ||
139
+ (typeof event.key === "string" && event.key.startsWith(`${key}:`)),
140
+ ))
141
+ );
142
+ }
143
+
97
144
  async function fetchPollJson<T>(
98
145
  pollUrl: string,
99
146
  since: number,
@@ -615,6 +662,51 @@ export function useDbSync(
615
662
  // processed by THIS subscriber so stale poll re-deliveries are ignored.
616
663
  let subscriberVersion = 0;
617
664
 
665
+ // Coalesce bursts of SSE-driven invalidation into at most one flush per
666
+ // INVALIDATE_COALESCE_MS. A chatty doc (many small agent edits, several
667
+ // peers editing at once) can otherwise deliver a handful of `action`/
668
+ // `collab` events within a few hundred ms, each independently calling
669
+ // `queryClient.invalidateQueries` and firing `onEvent` — every one of
670
+ // those touches whatever query subscribers are mounted (e.g. a
671
+ // full-page editor) even though the end state only needs to be
672
+ // refreshed once. Version bookkeeping stays synchronous (below) so
673
+ // freshness filtering for the NEXT batch is unaffected by the delay.
674
+ //
675
+ // This coalesce window is wrong for interaction-critical events (agent
676
+ // navigation, `set-url`, guided questions — see
677
+ // `isInteractionCriticalSyncEvent`): those must reach the UI immediately,
678
+ // not up to INVALIDATE_COALESCE_MS late. So a fresh batch containing one
679
+ // of those flushes synchronously (queued + new events together,
680
+ // canceling any pending timer) instead of joining the coalesce window.
681
+ // Pure invalidation bursts with no interaction-critical members keep the
682
+ // coalesced behavior.
683
+ let pendingInvalidateEvents: SyncEvent[] = [];
684
+ let invalidateTimer: ReturnType<typeof setTimeout> | null = null;
685
+
686
+ function flushInvalidateBatch() {
687
+ if (invalidateTimer) {
688
+ clearTimeout(invalidateTimer);
689
+ invalidateTimer = null;
690
+ }
691
+ if (pendingInvalidateEvents.length === 0) return;
692
+ const batch = pendingInvalidateEvents;
693
+ pendingInvalidateEvents = [];
694
+ invalidateForEvents(batch);
695
+ }
696
+
697
+ function queueInvalidateBatch(events: SyncEvent[]) {
698
+ pendingInvalidateEvents.push(...events);
699
+ if (events.some(isInteractionCriticalSyncEvent)) {
700
+ flushInvalidateBatch();
701
+ return;
702
+ }
703
+ if (invalidateTimer) return;
704
+ invalidateTimer = setTimeout(
705
+ flushInvalidateBatch,
706
+ INVALIDATE_COALESCE_MS,
707
+ );
708
+ }
709
+
618
710
  function hasAppStateEvent(events: SyncEvent[], key: string): boolean {
619
711
  return events.some(
620
712
  (event) =>
@@ -722,7 +814,7 @@ export function useDbSync(
722
814
  });
723
815
 
724
816
  if (freshEvents.length > 0) {
725
- invalidateForEvents(freshEvents);
817
+ queueInvalidateBatch(freshEvents);
726
818
  }
727
819
 
728
820
  const maxEventVersion = freshEvents.reduce(
@@ -746,6 +838,12 @@ export function useDbSync(
746
838
  });
747
839
 
748
840
  return () => {
841
+ if (invalidateTimer) {
842
+ clearTimeout(invalidateTimer);
843
+ // Flush synchronously on unmount so a pending batch isn't silently
844
+ // dropped (e.g. a route change right after an agent edit lands).
845
+ flushInvalidateBatch();
846
+ }
749
847
  transport.remove(id);
750
848
  // If the registry still holds this transport, and the transport is now
751
849
  // empty, evict it so the next mount gets a fresh instance rather than a
@@ -26,7 +26,7 @@ export interface RunStuckState {
26
26
  heartbeatAt: number | null;
27
27
  /** Milliseconds since `heartbeatAt`, computed against the server clock. */
28
28
  heartbeatSinceMs: number | null;
29
- /** How the run was dispatched, e.g. foreground or background-processing. */
29
+ /** How the run was dispatched/continued, e.g. foreground-self-chain or background-processing. */
30
30
  dispatchMode: string | null;
31
31
  }
32
32
 
@@ -133,10 +133,13 @@ export function useRunStuckDetection({
133
133
  heartbeatAt != null ? nowMs - heartbeatAt : null;
134
134
  const dispatchMode =
135
135
  typeof data.dispatchMode === "string" ? data.dispatchMode : null;
136
- // Background-dispatched runs get the wider threshold: the server's
137
- // own recovery (150s no-progress backstop + chained continuations)
138
- // must get its chance before the user sees a "stuck" affordance.
139
- const effectiveThresholdMs = dispatchMode?.startsWith("background")
136
+ // Server-continued runs get the wider threshold: the server's own
137
+ // recovery (150s no-progress backstop + chained continuations) must
138
+ // get its chance before the user sees a "stuck" affordance.
139
+ const serverContinued =
140
+ dispatchMode === "foreground-self-chain" ||
141
+ dispatchMode?.startsWith("background") === true;
142
+ const effectiveThresholdMs = serverContinued
140
143
  ? backgroundStuckThresholdMs
141
144
  : stuckThresholdMs;
142
145
  const isStuck = Boolean(
@@ -94,6 +94,31 @@ function isDocumentHidden(): boolean {
94
94
  );
95
95
  }
96
96
 
97
+ /**
98
+ * Content equality for deduped active-user lists. `dedupeCollabUsersByEmail`
99
+ * always allocates a fresh array (and fresh per-user objects), so callers
100
+ * that only want to know "did the actual user set change" need this instead
101
+ * of an identity check. Order-sensitive: the dedupe helper iterates awareness
102
+ * states in a stable Map insertion order, so a reordering here does reflect
103
+ * a real change (a client joined/left and re-joined).
104
+ */
105
+ function collabUsersEqual(a: CollabUser[], b: CollabUser[]): boolean {
106
+ if (a === b) return true;
107
+ if (a.length !== b.length) return false;
108
+ for (let i = 0; i < a.length; i++) {
109
+ const left = a[i]!;
110
+ const right = b[i]!;
111
+ if (
112
+ left.email !== right.email ||
113
+ left.name !== right.name ||
114
+ left.color !== right.color
115
+ ) {
116
+ return false;
117
+ }
118
+ }
119
+ return true;
120
+ }
121
+
97
122
  /**
98
123
  * Leader election for applying authoritative external snapshots into a shared
99
124
  * collaborative document.
@@ -566,10 +591,30 @@ class CollabDocConnection {
566
591
  }
567
592
  }
568
593
  });
569
- this.setSnapshot({
570
- activeUsers: dedupeCollabUsersByEmail(users),
571
- agentPresent: hasAgent,
572
- });
594
+ const nextActiveUsers = dedupeCollabUsersByEmail(users);
595
+ // Awareness "change" fires for every remote broadcast — including cursor
596
+ // jiggles, re-published-but-unchanged heartbeat state, and edits to
597
+ // presence fields (recentEdits, selection) that don't affect who's
598
+ // active. dedupeCollabUsersByEmail always returns a fresh array, so
599
+ // without this comparison every one of those events would hand
600
+ // `activeUsers` a new identity and force every consumer (e.g. a
601
+ // full-page editor keying a useMemo/effect off it) to re-render even
602
+ // though the actual user list is unchanged. Reuse the previous
603
+ // reference when the deduped content is the same, matching the
604
+ // stable-ref discipline `usePresence`'s shallowEqualOthers already
605
+ // applies to `others`.
606
+ const activeUsers = collabUsersEqual(
607
+ this.snapshot.activeUsers,
608
+ nextActiveUsers,
609
+ )
610
+ ? this.snapshot.activeUsers
611
+ : nextActiveUsers;
612
+ if (
613
+ activeUsers !== this.snapshot.activeUsers ||
614
+ hasAgent !== this.snapshot.agentPresent
615
+ ) {
616
+ this.setSnapshot({ activeUsers, agentPresent: hasAgent });
617
+ }
573
618
 
574
619
  // Fast awareness push: whenever awareness changes (e.g. cursor moves,
575
620
  // setPresence() calls), schedule a throttled POST so peers receive updates
@@ -2427,6 +2427,105 @@ export const config = {
2427
2427
  );
2428
2428
  }
2429
2429
 
2430
+ export function assertSingleTemplateNetlifyBuildOutput(
2431
+ projectCwd: string,
2432
+ ): void {
2433
+ const failures: string[] = [];
2434
+ const publishDir = path.join(projectCwd, "dist");
2435
+ const internalDir = path.join(projectCwd, ".netlify", "functions-internal");
2436
+ const serverDir = path.join(internalDir, "server");
2437
+ const serverEntryPath = path.join(serverDir, "server.mjs");
2438
+ const serverMainPath = path.join(serverDir, "main.mjs");
2439
+
2440
+ if (!fs.existsSync(publishDir)) {
2441
+ failures.push("missing publish directory: dist");
2442
+ }
2443
+
2444
+ if (!fs.existsSync(serverDir)) {
2445
+ failures.push(
2446
+ "missing scanned Netlify server function: .netlify/functions-internal/server",
2447
+ );
2448
+ }
2449
+
2450
+ if (!fs.existsSync(serverMainPath)) {
2451
+ failures.push(
2452
+ "missing Netlify server bundle: .netlify/functions-internal/server/main.mjs",
2453
+ );
2454
+ }
2455
+
2456
+ if (!fs.existsSync(serverEntryPath)) {
2457
+ failures.push(
2458
+ "missing Netlify server entry: .netlify/functions-internal/server/server.mjs",
2459
+ );
2460
+ } else {
2461
+ const serverEntry = fs.readFileSync(serverEntryPath, "utf-8");
2462
+ if (!/\bpath\s*:\s*["']\/\*["']/.test(serverEntry)) {
2463
+ failures.push(
2464
+ 'Netlify server entry is missing the "/*" catch-all function path',
2465
+ );
2466
+ }
2467
+ if (!serverEntry.includes('"/.netlify/*"')) {
2468
+ failures.push(
2469
+ 'Netlify server catch-all is missing the "/.netlify/*" exclusion',
2470
+ );
2471
+ }
2472
+ if (!serverEntry.includes("./main.mjs")) {
2473
+ failures.push(
2474
+ "Netlify server entry does not reference the generated main.mjs bundle",
2475
+ );
2476
+ }
2477
+ }
2478
+
2479
+ if (isDurableBackgroundDeployEnabled()) {
2480
+ const backgroundDir = path.join(
2481
+ internalDir,
2482
+ AGENT_BACKGROUND_FUNCTION_NAME,
2483
+ );
2484
+ const backgroundEntryPath = path.join(
2485
+ backgroundDir,
2486
+ `${AGENT_BACKGROUND_FUNCTION_NAME}.mjs`,
2487
+ );
2488
+ if (!fs.existsSync(backgroundEntryPath)) {
2489
+ failures.push(
2490
+ `durable background is enabled but ${path.relative(
2491
+ projectCwd,
2492
+ backgroundEntryPath,
2493
+ )} was not emitted`,
2494
+ );
2495
+ } else {
2496
+ const backgroundEntry = fs.readFileSync(backgroundEntryPath, "utf-8");
2497
+ if (!/\bbackground\s*:\s*true\b/.test(backgroundEntry)) {
2498
+ failures.push(
2499
+ `durable background entry ${path.relative(
2500
+ projectCwd,
2501
+ backgroundEntryPath,
2502
+ )} is missing background: true`,
2503
+ );
2504
+ }
2505
+ if (/^\s*path\s*:/m.test(backgroundEntry)) {
2506
+ failures.push(
2507
+ `durable background entry ${path.relative(
2508
+ projectCwd,
2509
+ backgroundEntryPath,
2510
+ )} must not declare a custom path`,
2511
+ );
2512
+ }
2513
+ }
2514
+ }
2515
+
2516
+ if (failures.length > 0) {
2517
+ throw new Error(
2518
+ "[deploy] Netlify deploy guard failed; refusing to publish an output " +
2519
+ "that would likely serve Netlify 404s:\n" +
2520
+ failures.map((failure) => `- ${failure}`).join("\n"),
2521
+ );
2522
+ }
2523
+
2524
+ console.log(
2525
+ "[deploy] Netlify deploy guard passed: publish dir and catch-all server function are present.",
2526
+ );
2527
+ }
2528
+
2430
2529
  function copyInstalledLibsqlNativePackages(serverDir: string | undefined) {
2431
2530
  if (!serverDir || !fs.existsSync(serverDir)) return;
2432
2531
  const nodeModulesRoots = nodeModulesAncestors(cwd);
@@ -2976,6 +3075,10 @@ export default bundle;
2976
3075
  }
2977
3076
  }
2978
3077
 
3078
+ if (preset === "netlify") {
3079
+ assertSingleTemplateNetlifyBuildOutput(cwd);
3080
+ }
3081
+
2979
3082
  // Resolve remaining bare npm imports by bundling them into _libs/.
2980
3083
  // Nitro sometimes leaves small packages as externals even with noExternals.
2981
3084
  if (preset.startsWith("cloudflare") || preset.startsWith("deno")) {
@@ -8,6 +8,7 @@ import {
8
8
  setCookie,
9
9
  deleteCookie,
10
10
  getRequestURL,
11
+ getRequestIP,
11
12
  } from "h3";
12
13
  import type { H3Event } from "h3";
13
14
  import { readMultipartFormData } from "h3";
@@ -285,9 +286,16 @@ const BUILDER_WAITLIST_DEFAULT_USE_CASE = "builder_agent_background_coding";
285
286
  const BUILDER_WAITLIST_USE_CASES = new Set([
286
287
  BUILDER_WAITLIST_DEFAULT_USE_CASE,
287
288
  "design_publish_app",
289
+ "docs_build_online_waitlist",
288
290
  ]);
289
291
  const BUILDER_WAITLIST_FORM_TIMEOUT_MS = 8000;
290
292
  const BUILDER_WAITLIST_TEXT_LIMIT = 4000;
293
+ const BUILDER_WAITLIST_RATE_LIMIT_WINDOW_MS = 60_000;
294
+ const BUILDER_WAITLIST_RATE_LIMIT_MAX = 5;
295
+ const builderWaitlistRateLimitHits = new Map<
296
+ string,
297
+ { count: number; resetAt: number }
298
+ >();
291
299
 
292
300
  interface BuilderWaitlistFormTarget {
293
301
  formId: string;
@@ -295,6 +303,7 @@ interface BuilderWaitlistFormTarget {
295
303
  }
296
304
 
297
305
  export interface BuilderWaitlistBody {
306
+ email?: unknown;
298
307
  prompt?: unknown;
299
308
  orgName?: unknown;
300
309
  appUrl?: unknown;
@@ -332,6 +341,106 @@ function normalizeBuilderWaitlistUseCase(value: unknown): string {
332
341
  : BUILDER_WAITLIST_DEFAULT_USE_CASE;
333
342
  }
334
343
 
344
+ function isValidWaitlistEmail(email: string): boolean {
345
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
346
+ }
347
+
348
+ export function isAnonymousWaitlistSessionEmail(email: string): boolean {
349
+ return email.startsWith("anon-") && email.endsWith("@agent-native.com");
350
+ }
351
+
352
+ export function resolveWaitlistEmail(
353
+ sessionEmail: string | undefined,
354
+ bodyEmail: unknown,
355
+ ): string | null {
356
+ const provided = cleanBuilderWaitlistText(bodyEmail, 320);
357
+ if (provided && isValidWaitlistEmail(provided)) return provided;
358
+ if (sessionEmail && !isAnonymousWaitlistSessionEmail(sessionEmail)) {
359
+ return sessionEmail;
360
+ }
361
+ return null;
362
+ }
363
+
364
+ function normalizeWaitlistRateLimitPart(value: string): string {
365
+ return value.trim().toLowerCase();
366
+ }
367
+
368
+ function getBuilderWaitlistClientIp(event: H3Event): string | undefined {
369
+ const trusted =
370
+ getHeader(event, "x-nf-client-connection-ip") ??
371
+ getHeader(event, "cf-connecting-ip") ??
372
+ getHeader(event, "true-client-ip") ??
373
+ getHeader(event, "x-real-ip");
374
+ if (trusted && trusted.trim()) return trusted.trim();
375
+
376
+ const forwardedFor = getHeader(event, "x-forwarded-for");
377
+ const forwardedClientIp = forwardedFor?.split(",")[0]?.trim();
378
+ if (forwardedClientIp) return forwardedClientIp;
379
+
380
+ try {
381
+ return getRequestIP(event) ?? undefined;
382
+ } catch {
383
+ return undefined;
384
+ }
385
+ }
386
+
387
+ function getBuilderWaitlistRateLimitKeys(
388
+ event: H3Event,
389
+ email: string,
390
+ ): string[] {
391
+ const clientIp = getBuilderWaitlistClientIp(event);
392
+ return [
393
+ `email:${normalizeWaitlistRateLimitPart(email)}`,
394
+ `ip:${normalizeWaitlistRateLimitPart(clientIp ?? "unknown")}`,
395
+ ];
396
+ }
397
+
398
+ export function checkBuilderWaitlistRateLimit(
399
+ event: H3Event,
400
+ email: string,
401
+ now = Date.now(),
402
+ ): { ok: true } | { ok: false; retryAfterSeconds: number } {
403
+ const keys = getBuilderWaitlistRateLimitKeys(event, email);
404
+ let retryAfterMs = 0;
405
+
406
+ for (const key of keys) {
407
+ const entry = builderWaitlistRateLimitHits.get(key);
408
+ if (!entry) continue;
409
+ if (entry.resetAt <= now) {
410
+ builderWaitlistRateLimitHits.delete(key);
411
+ continue;
412
+ }
413
+ if (entry.count >= BUILDER_WAITLIST_RATE_LIMIT_MAX) {
414
+ retryAfterMs = Math.max(retryAfterMs, entry.resetAt - now);
415
+ }
416
+ }
417
+
418
+ if (retryAfterMs > 0) {
419
+ return {
420
+ ok: false,
421
+ retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
422
+ };
423
+ }
424
+
425
+ for (const key of keys) {
426
+ const entry = builderWaitlistRateLimitHits.get(key);
427
+ if (!entry || entry.resetAt <= now) {
428
+ builderWaitlistRateLimitHits.set(key, {
429
+ count: 1,
430
+ resetAt: now + BUILDER_WAITLIST_RATE_LIMIT_WINDOW_MS,
431
+ });
432
+ } else {
433
+ entry.count += 1;
434
+ }
435
+ }
436
+
437
+ return { ok: true };
438
+ }
439
+
440
+ export function resetBuilderWaitlistRateLimitForTests() {
441
+ builderWaitlistRateLimitHits.clear();
442
+ }
443
+
335
444
  function normalizeHttpOrigin(value: string): string | null {
336
445
  try {
337
446
  const url = new URL(value);
@@ -1869,15 +1978,35 @@ export function createCoreRoutesPlugin(
1869
1978
  return { error: "Method not allowed" };
1870
1979
  }
1871
1980
  const session = await getSession(event).catch(() => null);
1872
- if (!session?.email) {
1873
- setResponseStatus(event, 401);
1874
- return { error: "Authentication required" };
1875
- }
1876
1981
  const body = ((await readBody(event).catch(() => ({}))) ??
1877
1982
  {}) as BuilderWaitlistBody;
1983
+ const waitlistEmail = resolveWaitlistEmail(
1984
+ session?.email,
1985
+ body.email,
1986
+ );
1987
+ if (!waitlistEmail) {
1988
+ setResponseStatus(event, 400);
1989
+ return { error: "Valid email required" };
1990
+ }
1991
+ const waitlistRateLimit = checkBuilderWaitlistRateLimit(
1992
+ event,
1993
+ waitlistEmail,
1994
+ );
1995
+ if (!waitlistRateLimit.ok) {
1996
+ setResponseStatus(event, 429);
1997
+ setResponseHeader(
1998
+ event,
1999
+ "Retry-After",
2000
+ String(waitlistRateLimit.retryAfterSeconds),
2001
+ );
2002
+ return {
2003
+ error:
2004
+ "Too many waitlist requests. Please try again in a minute.",
2005
+ };
2006
+ }
1878
2007
  const waitlistPayload = buildBuilderWaitlistFormPayload(
1879
2008
  event,
1880
- session.email,
2009
+ waitlistEmail,
1881
2010
  body,
1882
2011
  );
1883
2012
  const waitlistSource = waitlistPayload.data.source;
@@ -1886,14 +2015,14 @@ export function createCoreRoutesPlugin(
1886
2015
  try {
1887
2016
  formSubmission = await submitBuilderWaitlistForm(
1888
2017
  event,
1889
- session.email,
2018
+ waitlistEmail,
1890
2019
  body,
1891
2020
  );
1892
2021
  } catch (err) {
1893
2022
  await trackBuilderLifecycle(
1894
2023
  event,
1895
2024
  "builder branch waitlist form failed",
1896
- session.email,
2025
+ waitlistEmail,
1897
2026
  {
1898
2027
  reason:
1899
2028
  err instanceof Error ? err.message : "unknown_waitlist_error",
@@ -1911,7 +2040,7 @@ export function createCoreRoutesPlugin(
1911
2040
  await trackBuilderLifecycle(
1912
2041
  event,
1913
2042
  "builder branch waitlist joined",
1914
- session.email,
2043
+ waitlistEmail,
1915
2044
  {
1916
2045
  formId: formSubmission.formId ?? null,
1917
2046
  formSubmitted: formSubmission.submitted,
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-07
4
+ ---
5
+
6
+ Dictation starts faster when using Local Whisper after the first press.