@agent-native/core 0.131.9 → 0.132.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 (292) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +99 -0
  3. package/corpus/core/docs/content/durable-background-runs.mdx +15 -6
  4. package/corpus/core/docs/design/durable-agent-runs.md +15 -11
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/a2a/activity.ts +127 -10
  7. package/corpus/core/src/a2a/index.ts +3 -0
  8. package/corpus/core/src/agent/durable-background.ts +38 -28
  9. package/corpus/core/src/agent/harness/background.ts +12 -2
  10. package/corpus/core/src/agent/production-agent.ts +18 -15
  11. package/corpus/core/src/agent/run-store.ts +45 -3
  12. package/corpus/core/src/application-state/handlers.ts +45 -0
  13. package/corpus/core/src/application-state/index.ts +3 -0
  14. package/corpus/core/src/application-state/store.ts +28 -11
  15. package/corpus/core/src/audit/redact.ts +104 -18
  16. package/corpus/core/src/client/AgentPanel.tsx +2 -7
  17. package/corpus/core/src/client/MultiTabAssistantChat.tsx +3 -0
  18. package/corpus/core/src/client/RunStuckBanner.tsx +29 -6
  19. package/corpus/core/src/client/agent-chat.ts +4 -9
  20. package/corpus/core/src/client/application-state.ts +115 -12
  21. package/corpus/core/src/client/dynamic-suggestions.ts +4 -12
  22. package/corpus/core/src/client/guided-questions.tsx +12 -23
  23. package/corpus/core/src/db/client.ts +148 -28
  24. package/corpus/core/src/db/create-get-db.ts +39 -6
  25. package/corpus/core/src/db/runtime-diagnostics.ts +41 -6
  26. package/corpus/core/src/deploy/build.ts +26 -33
  27. package/corpus/core/src/feature-flags/store.ts +10 -5
  28. package/corpus/core/src/integrations/a2a-continuations-store.ts +11 -0
  29. package/corpus/core/src/integrations/config-store.ts +13 -0
  30. package/corpus/core/src/integrations/google-docs-poller.ts +26 -2
  31. package/corpus/core/src/mcp-client/routes.ts +33 -2
  32. package/corpus/core/src/onboarding/plugin.ts +31 -25
  33. package/corpus/core/src/org/accept-pending.ts +2 -0
  34. package/corpus/core/src/org/auto-join-domain.ts +2 -0
  35. package/corpus/core/src/org/context.ts +35 -10
  36. package/corpus/core/src/org/handlers.ts +6 -0
  37. package/corpus/core/src/org/request-org-cache.ts +71 -0
  38. package/corpus/core/src/resources/store.ts +10 -9
  39. package/corpus/core/src/scripts/runner.ts +6 -3
  40. package/corpus/core/src/secrets/storage.ts +97 -5
  41. package/corpus/core/src/server/action-change.ts +33 -0
  42. package/corpus/core/src/server/action-routes.ts +11 -9
  43. package/corpus/core/src/server/agent-teams.ts +10 -2
  44. package/corpus/core/src/server/better-auth-instance.ts +39 -12
  45. package/corpus/core/src/server/core-routes-plugin.ts +19 -2
  46. package/corpus/core/src/server/credential-provider.ts +57 -14
  47. package/corpus/core/src/server/http-response-telemetry.ts +1 -0
  48. package/corpus/core/src/server/poll.ts +95 -57
  49. package/corpus/core/src/server/voice-providers-status.ts +19 -4
  50. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +38 -0
  51. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +1 -1
  52. package/corpus/templates/analytics/actions/ensure-native-v2-dashboards.ts +299 -0
  53. package/corpus/templates/analytics/actions/list-native-v2-dashboard-templates.ts +34 -0
  54. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +82 -0
  55. package/corpus/templates/analytics/app/components/layout/NewDashboardDialog.tsx +2 -2
  56. package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +40 -34
  57. package/corpus/templates/analytics/app/hooks/use-user-pref.ts +3 -3
  58. package/corpus/templates/analytics/app/i18n-data.ts +40 -0
  59. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +18 -10
  60. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +14 -0
  61. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +1 -0
  62. package/corpus/templates/analytics/app/routes/chart.tsx +3 -0
  63. package/corpus/templates/analytics/changelog/2026-07-30-analytics-dashboards-now-expose-native-funnel-heatmap-callou.md +6 -0
  64. package/corpus/templates/analytics/changelog/2026-07-30-analytics-now-ships-reusable-native-v2-dashboard-manifests-w.md +6 -0
  65. package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +81 -1
  66. package/corpus/templates/analytics/server/lib/native-v2-dashboards.ts +457 -0
  67. package/corpus/templates/analytics/server/lib/report-chart-svg.ts +73 -2
  68. package/corpus/templates/analytics/server/plugins/agent-chat.ts +1 -1
  69. package/corpus/templates/analytics/shared/dashboard-funnel.ts +83 -0
  70. package/corpus/templates/content/changelog/2026-07-30-long-running-delegated-requests-now-continue-reliably.md +6 -0
  71. package/corpus/templates/content/netlify.toml +3 -0
  72. package/corpus/templates/content/server/plugins/agent-chat.ts +1 -0
  73. package/corpus/templates/slides/.agents/skills/create-deck/SKILL.md +2 -1
  74. package/corpus/templates/slides/actions/patch-deck.ts +25 -1
  75. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +17 -4
  76. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +9 -1
  77. package/corpus/templates/slides/app/components/layout/Layout.tsx +1 -1
  78. package/corpus/templates/slides/app/context/DeckContext.tsx +11 -3
  79. package/corpus/templates/slides/app/lib/sanitize-slide-html.ts +39 -11
  80. package/corpus/templates/slides/app/pages/Index.tsx +2 -2
  81. package/corpus/templates/slides/changelog/2026-07-30-slide-previews-keep-their-aspect-ratio-isolate-embedded-styl.md +6 -0
  82. package/corpus/templates/slides/changelog/2026-07-30-slides-no-longer-lose-decks-while-organization-access-is-loa.md +6 -0
  83. package/corpus/toolkit/CHANGELOG.md +8 -0
  84. package/corpus/toolkit/package.json +1 -1
  85. package/corpus/toolkit/src/composer/runtime-adapters.tsx +16 -11
  86. package/dist/a2a/activity.d.ts +22 -4
  87. package/dist/a2a/activity.d.ts.map +1 -1
  88. package/dist/a2a/activity.js +97 -11
  89. package/dist/a2a/activity.js.map +1 -1
  90. package/dist/a2a/index.d.ts +1 -1
  91. package/dist/a2a/index.d.ts.map +1 -1
  92. package/dist/a2a/index.js +1 -1
  93. package/dist/a2a/index.js.map +1 -1
  94. package/dist/agent/durable-background.d.ts +13 -8
  95. package/dist/agent/durable-background.d.ts.map +1 -1
  96. package/dist/agent/durable-background.js +36 -28
  97. package/dist/agent/durable-background.js.map +1 -1
  98. package/dist/agent/harness/background.d.ts.map +1 -1
  99. package/dist/agent/harness/background.js +12 -2
  100. package/dist/agent/harness/background.js.map +1 -1
  101. package/dist/agent/production-agent.d.ts.map +1 -1
  102. package/dist/agent/production-agent.js +18 -15
  103. package/dist/agent/production-agent.js.map +1 -1
  104. package/dist/agent/run-store.d.ts +2 -0
  105. package/dist/agent/run-store.d.ts.map +1 -1
  106. package/dist/agent/run-store.js +44 -3
  107. package/dist/agent/run-store.js.map +1 -1
  108. package/dist/application-state/handlers.d.ts +15 -0
  109. package/dist/application-state/handlers.d.ts.map +1 -1
  110. package/dist/application-state/handlers.js +42 -2
  111. package/dist/application-state/handlers.js.map +1 -1
  112. package/dist/application-state/index.d.ts +2 -2
  113. package/dist/application-state/index.d.ts.map +1 -1
  114. package/dist/application-state/index.js +2 -2
  115. package/dist/application-state/index.js.map +1 -1
  116. package/dist/application-state/store.d.ts +11 -0
  117. package/dist/application-state/store.d.ts.map +1 -1
  118. package/dist/application-state/store.js +23 -10
  119. package/dist/application-state/store.js.map +1 -1
  120. package/dist/audit/redact.d.ts +25 -2
  121. package/dist/audit/redact.d.ts.map +1 -1
  122. package/dist/audit/redact.js +85 -21
  123. package/dist/audit/redact.js.map +1 -1
  124. package/dist/client/AgentPanel.d.ts.map +1 -1
  125. package/dist/client/AgentPanel.js +2 -7
  126. package/dist/client/AgentPanel.js.map +1 -1
  127. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  128. package/dist/client/MultiTabAssistantChat.js +1 -1
  129. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  130. package/dist/client/RunStuckBanner.d.ts +11 -1
  131. package/dist/client/RunStuckBanner.d.ts.map +1 -1
  132. package/dist/client/RunStuckBanner.js +20 -7
  133. package/dist/client/RunStuckBanner.js.map +1 -1
  134. package/dist/client/agent-chat.d.ts.map +1 -1
  135. package/dist/client/agent-chat.js +4 -6
  136. package/dist/client/agent-chat.js.map +1 -1
  137. package/dist/client/application-state.d.ts +11 -0
  138. package/dist/client/application-state.d.ts.map +1 -1
  139. package/dist/client/application-state.js +87 -9
  140. package/dist/client/application-state.js.map +1 -1
  141. package/dist/client/dynamic-suggestions.d.ts.map +1 -1
  142. package/dist/client/dynamic-suggestions.js +4 -13
  143. package/dist/client/dynamic-suggestions.js.map +1 -1
  144. package/dist/client/guided-questions.d.ts.map +1 -1
  145. package/dist/client/guided-questions.js +6 -22
  146. package/dist/client/guided-questions.js.map +1 -1
  147. package/dist/collab/awareness.d.ts +2 -2
  148. package/dist/collab/awareness.d.ts.map +1 -1
  149. package/dist/db/client.d.ts +57 -10
  150. package/dist/db/client.d.ts.map +1 -1
  151. package/dist/db/client.js +122 -31
  152. package/dist/db/client.js.map +1 -1
  153. package/dist/db/create-get-db.d.ts.map +1 -1
  154. package/dist/db/create-get-db.js +31 -7
  155. package/dist/db/create-get-db.js.map +1 -1
  156. package/dist/db/runtime-diagnostics.d.ts +1 -0
  157. package/dist/db/runtime-diagnostics.d.ts.map +1 -1
  158. package/dist/db/runtime-diagnostics.js +31 -6
  159. package/dist/db/runtime-diagnostics.js.map +1 -1
  160. package/dist/deploy/build.d.ts +5 -20
  161. package/dist/deploy/build.d.ts.map +1 -1
  162. package/dist/deploy/build.js +18 -30
  163. package/dist/deploy/build.js.map +1 -1
  164. package/dist/feature-flags/store.d.ts.map +1 -1
  165. package/dist/feature-flags/store.js +10 -5
  166. package/dist/feature-flags/store.js.map +1 -1
  167. package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
  168. package/dist/integrations/a2a-continuations-store.js +12 -0
  169. package/dist/integrations/a2a-continuations-store.js.map +1 -1
  170. package/dist/integrations/config-store.d.ts +1 -0
  171. package/dist/integrations/config-store.d.ts.map +1 -1
  172. package/dist/integrations/config-store.js +11 -0
  173. package/dist/integrations/config-store.js.map +1 -1
  174. package/dist/integrations/google-docs-poller.d.ts.map +1 -1
  175. package/dist/integrations/google-docs-poller.js +19 -2
  176. package/dist/integrations/google-docs-poller.js.map +1 -1
  177. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  178. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  179. package/dist/mcp-client/routes.d.ts.map +1 -1
  180. package/dist/mcp-client/routes.js +30 -2
  181. package/dist/mcp-client/routes.js.map +1 -1
  182. package/dist/notifications/routes.d.ts +6 -6
  183. package/dist/observability/routes.d.ts +3 -3
  184. package/dist/onboarding/plugin.d.ts.map +1 -1
  185. package/dist/onboarding/plugin.js +12 -8
  186. package/dist/onboarding/plugin.js.map +1 -1
  187. package/dist/org/accept-pending.d.ts.map +1 -1
  188. package/dist/org/accept-pending.js +2 -0
  189. package/dist/org/accept-pending.js.map +1 -1
  190. package/dist/org/auto-join-domain.d.ts.map +1 -1
  191. package/dist/org/auto-join-domain.js +2 -0
  192. package/dist/org/auto-join-domain.js.map +1 -1
  193. package/dist/org/context.d.ts.map +1 -1
  194. package/dist/org/context.js +29 -9
  195. package/dist/org/context.js.map +1 -1
  196. package/dist/org/handlers.d.ts.map +1 -1
  197. package/dist/org/handlers.js +5 -0
  198. package/dist/org/handlers.js.map +1 -1
  199. package/dist/org/request-org-cache.d.ts +16 -0
  200. package/dist/org/request-org-cache.d.ts.map +1 -0
  201. package/dist/org/request-org-cache.js +62 -0
  202. package/dist/org/request-org-cache.js.map +1 -0
  203. package/dist/progress/routes.d.ts +1 -1
  204. package/dist/resources/handlers.d.ts +1 -1
  205. package/dist/resources/store.d.ts.map +1 -1
  206. package/dist/resources/store.js +11 -9
  207. package/dist/resources/store.js.map +1 -1
  208. package/dist/scripts/runner.d.ts.map +1 -1
  209. package/dist/scripts/runner.js +3 -3
  210. package/dist/scripts/runner.js.map +1 -1
  211. package/dist/secrets/storage.d.ts.map +1 -1
  212. package/dist/secrets/storage.js +82 -5
  213. package/dist/secrets/storage.js.map +1 -1
  214. package/dist/server/action-change.d.ts +20 -0
  215. package/dist/server/action-change.d.ts.map +1 -1
  216. package/dist/server/action-change.js +29 -0
  217. package/dist/server/action-change.js.map +1 -1
  218. package/dist/server/action-routes.d.ts.map +1 -1
  219. package/dist/server/action-routes.js +7 -8
  220. package/dist/server/action-routes.js.map +1 -1
  221. package/dist/server/agent-teams.d.ts.map +1 -1
  222. package/dist/server/agent-teams.js +10 -2
  223. package/dist/server/agent-teams.js.map +1 -1
  224. package/dist/server/better-auth-instance.d.ts.map +1 -1
  225. package/dist/server/better-auth-instance.js +31 -14
  226. package/dist/server/better-auth-instance.js.map +1 -1
  227. package/dist/server/core-routes-plugin.d.ts +1 -0
  228. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  229. package/dist/server/core-routes-plugin.js +18 -4
  230. package/dist/server/core-routes-plugin.js.map +1 -1
  231. package/dist/server/credential-provider.d.ts +16 -0
  232. package/dist/server/credential-provider.d.ts.map +1 -1
  233. package/dist/server/credential-provider.js +49 -13
  234. package/dist/server/credential-provider.js.map +1 -1
  235. package/dist/server/http-response-telemetry.d.ts.map +1 -1
  236. package/dist/server/http-response-telemetry.js +1 -0
  237. package/dist/server/http-response-telemetry.js.map +1 -1
  238. package/dist/server/poll.d.ts +6 -0
  239. package/dist/server/poll.d.ts.map +1 -1
  240. package/dist/server/poll.js +84 -47
  241. package/dist/server/poll.js.map +1 -1
  242. package/dist/server/transcribe-voice.d.ts +1 -1
  243. package/dist/server/voice-providers-status.d.ts.map +1 -1
  244. package/dist/server/voice-providers-status.js +12 -3
  245. package/dist/server/voice-providers-status.js.map +1 -1
  246. package/docs/content/durable-background-runs.mdx +15 -6
  247. package/docs/design/durable-agent-runs.md +15 -11
  248. package/package.json +2 -2
  249. package/src/a2a/activity.ts +127 -10
  250. package/src/a2a/index.ts +3 -0
  251. package/src/agent/durable-background.ts +38 -28
  252. package/src/agent/harness/background.ts +12 -2
  253. package/src/agent/production-agent.ts +18 -15
  254. package/src/agent/run-store.ts +45 -3
  255. package/src/application-state/handlers.ts +45 -0
  256. package/src/application-state/index.ts +3 -0
  257. package/src/application-state/store.ts +28 -11
  258. package/src/audit/redact.ts +104 -18
  259. package/src/client/AgentPanel.tsx +2 -7
  260. package/src/client/MultiTabAssistantChat.tsx +3 -0
  261. package/src/client/RunStuckBanner.tsx +29 -6
  262. package/src/client/agent-chat.ts +4 -9
  263. package/src/client/application-state.ts +115 -12
  264. package/src/client/dynamic-suggestions.ts +4 -12
  265. package/src/client/guided-questions.tsx +12 -23
  266. package/src/db/client.ts +148 -28
  267. package/src/db/create-get-db.ts +39 -6
  268. package/src/db/runtime-diagnostics.ts +41 -6
  269. package/src/deploy/build.ts +26 -33
  270. package/src/feature-flags/store.ts +10 -5
  271. package/src/integrations/a2a-continuations-store.ts +11 -0
  272. package/src/integrations/config-store.ts +13 -0
  273. package/src/integrations/google-docs-poller.ts +26 -2
  274. package/src/mcp-client/routes.ts +33 -2
  275. package/src/onboarding/plugin.ts +31 -25
  276. package/src/org/accept-pending.ts +2 -0
  277. package/src/org/auto-join-domain.ts +2 -0
  278. package/src/org/context.ts +35 -10
  279. package/src/org/handlers.ts +6 -0
  280. package/src/org/request-org-cache.ts +71 -0
  281. package/src/resources/store.ts +10 -9
  282. package/src/scripts/runner.ts +6 -3
  283. package/src/secrets/storage.ts +97 -5
  284. package/src/server/action-change.ts +33 -0
  285. package/src/server/action-routes.ts +11 -9
  286. package/src/server/agent-teams.ts +10 -2
  287. package/src/server/better-auth-instance.ts +39 -12
  288. package/src/server/core-routes-plugin.ts +19 -2
  289. package/src/server/credential-provider.ts +57 -14
  290. package/src/server/http-response-telemetry.ts +1 -0
  291. package/src/server/poll.ts +95 -57
  292. package/src/server/voice-providers-status.ts +19 -4
@@ -93,19 +93,117 @@ function jsonBody(value: unknown): string {
93
93
  return body;
94
94
  }
95
95
 
96
+ /**
97
+ * Result of a batched read. `values` holds only the keys the server has a row
98
+ * for; every other requested key is in `missing`. A key stored with a `null`
99
+ * value lands in `values` with `null` and NOT in `missing` — that is how
100
+ * "never written" stays distinguishable from "written as null/empty".
101
+ */
102
+ export interface ClientAppStateBatch {
103
+ values: Record<string, unknown>;
104
+ missing: string[];
105
+ }
106
+
107
+ /** Server caps a batch at 100 keys; stay under it when splitting. */
108
+ const MAX_BATCH_KEYS = 100;
109
+
110
+ export async function readClientAppStateMany(
111
+ keys: readonly string[],
112
+ options: ClientAppStateReadOptions = {},
113
+ ): Promise<ClientAppStateBatch> {
114
+ const unique = [...new Set(keys)];
115
+ for (const key of unique) appStateUrl(key); // validates the key shape
116
+ if (unique.length === 0) return { values: {}, missing: [] };
117
+
118
+ const merged: ClientAppStateBatch = { values: {}, missing: [] };
119
+ for (let i = 0; i < unique.length; i += MAX_BATCH_KEYS) {
120
+ const chunk = unique.slice(i, i + MAX_BATCH_KEYS);
121
+ const url = `${agentNativePath("/_agent-native/application-state")}?keys=${chunk
122
+ .map(encodeURIComponent)
123
+ .join(",")}`;
124
+ const response = await fetch(url, {
125
+ method: "GET",
126
+ cache: "no-store",
127
+ signal: options.signal,
128
+ });
129
+ const batch = await parseAppStateResponse<ClientAppStateBatch | null>(
130
+ response,
131
+ `Read application state [${chunk.join(", ")}]`,
132
+ );
133
+ if (!batch || typeof batch !== "object" || !batch.values) {
134
+ throw new Error(
135
+ `Read application state [${chunk.join(", ")}] returned an unexpected payload.`,
136
+ );
137
+ }
138
+ Object.assign(merged.values, batch.values);
139
+ merged.missing.push(...(batch.missing ?? []));
140
+ }
141
+ return merged;
142
+ }
143
+
144
+ // Reads issued in the same tick are coalesced into one batched request. Every
145
+ // mounted composer, question card and suggestion hook reads its own key on
146
+ // mount, which used to be one HTTP request (and one full identity resolution)
147
+ // each — ~55 on an analytics dashboard load.
148
+ let pendingBatch:
149
+ | { keys: Set<string>; promise: Promise<ClientAppStateBatch> }
150
+ | undefined;
151
+
152
+ function scheduleBatchedRead(key: string): Promise<ClientAppStateBatch> {
153
+ if (pendingBatch) {
154
+ pendingBatch.keys.add(key);
155
+ return pendingBatch.promise;
156
+ }
157
+ const keys = new Set([key]);
158
+ const promise = new Promise<ClientAppStateBatch>((resolve, reject) => {
159
+ setTimeout(() => {
160
+ pendingBatch = undefined;
161
+ readClientAppStateMany([...keys]).then(resolve, reject);
162
+ }, 0);
163
+ });
164
+ pendingBatch = { keys, promise };
165
+ return promise;
166
+ }
167
+
168
+ function awaitWithAbort<T>(
169
+ promise: Promise<T>,
170
+ signal?: AbortSignal,
171
+ ): Promise<T> {
172
+ if (!signal) return promise;
173
+ const abortReason = () =>
174
+ signal.reason ?? new Error("The operation was aborted");
175
+ if (signal.aborted) return Promise.reject(abortReason());
176
+ return new Promise<T>((resolve, reject) => {
177
+ const onAbort = () => {
178
+ cleanup();
179
+ reject(abortReason());
180
+ };
181
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
182
+ signal.addEventListener("abort", onAbort, { once: true });
183
+ promise.then(
184
+ (value) => {
185
+ cleanup();
186
+ if (signal.aborted) reject(abortReason());
187
+ else resolve(value);
188
+ },
189
+ (error) => {
190
+ cleanup();
191
+ reject(error);
192
+ },
193
+ );
194
+ });
195
+ }
196
+
96
197
  export async function readClientAppState<T = unknown>(
97
198
  key: string,
98
199
  options: ClientAppStateReadOptions = {},
99
200
  ): Promise<T | null> {
100
- const response = await fetch(appStateUrl(key), {
101
- method: "GET",
102
- cache: "no-store",
103
- signal: options.signal,
104
- });
105
- return parseAppStateResponse<T | null>(
106
- response,
107
- `Read application state "${key}"`,
108
- );
201
+ appStateUrl(key); // validates the key shape before it joins a batch
202
+ if (options.signal?.aborted) {
203
+ throw options.signal.reason ?? new Error("Aborted");
204
+ }
205
+ const batch = await awaitWithAbort(scheduleBatchedRead(key), options.signal);
206
+ return (batch.values[key] ?? null) as T | null;
109
207
  }
110
208
 
111
209
  export async function writeClientAppState<T = unknown>(
@@ -129,9 +227,14 @@ export async function deleteClientAppState(
129
227
  ): Promise<void> {
130
228
  const response = await fetch(appStateUrl(key), {
131
229
  method: "DELETE",
132
- headers: options.requestSource
133
- ? { "X-Request-Source": options.requestSource }
134
- : undefined,
230
+ // DELETE carries no JSON body, so this custom header is the only
231
+ // same-origin marker the CSRF check can see from an embedded frame.
232
+ headers: {
233
+ "X-Agent-Native-CSRF": "1",
234
+ ...(options.requestSource
235
+ ? { "X-Request-Source": options.requestSource }
236
+ : {}),
237
+ },
135
238
  keepalive: options.keepalive,
136
239
  signal: options.signal,
137
240
  });
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
2
 
3
- import { agentNativePath } from "./api-path.js";
3
+ import { readClientAppState } from "./application-state.js";
4
4
  import { useChangeVersions } from "./use-change-version.js";
5
5
  import type { ChatThreadScope } from "./use-chat-threads.js";
6
6
 
@@ -89,17 +89,9 @@ function appStateKeyForBrowserTab(key: string, browserTabId?: string): string {
89
89
  }
90
90
 
91
91
  async function readAppState(key: string): Promise<unknown> {
92
- const res = await fetch(
93
- agentNativePath(`/_agent-native/application-state/${key}`),
94
- );
95
- if (!res.ok || res.status === 204) return null;
96
- const text = await res.text();
97
- if (!text) return null;
98
- try {
99
- return JSON.parse(text);
100
- } catch {
101
- return null;
102
- }
92
+ // Reads issued in the same tick coalesce into one batched request, so the
93
+ // four keys below cost one round trip rather than four.
94
+ return readClientAppState(key).catch(() => null);
103
95
  }
104
96
 
105
97
  async function readScopedAppState(
@@ -12,8 +12,11 @@ import {
12
12
  import { useCallback, useEffect, useMemo, useState } from "react";
13
13
 
14
14
  import { sendToAgentChat } from "./agent-chat.js";
15
- import { agentNativePath } from "./api-path.js";
16
- import { setClientAppState } from "./application-state.js";
15
+ import {
16
+ deleteClientAppState,
17
+ readClientAppState,
18
+ setClientAppState,
19
+ } from "./application-state.js";
17
20
  import { useChangeVersions } from "./use-change-version.js";
18
21
  import { cn } from "./utils.js";
19
22
 
@@ -998,10 +1001,6 @@ export function useGuidedQuestionFlow({
998
1001
  () => normalizeBrowserTabId(browserTabId),
999
1002
  [browserTabId],
1000
1003
  );
1001
- const endpointFor = useCallback(
1002
- (key: string) => agentNativePath(`/_agent-native/application-state/${key}`),
1003
- [],
1004
- );
1005
1004
  const scopedKey = normalizedBrowserTabId
1006
1005
  ? `${stateKey}:${normalizedBrowserTabId}`
1007
1006
  : stateKey;
@@ -1036,17 +1035,11 @@ export function useGuidedQuestionFlow({
1036
1035
  enabled,
1037
1036
  queryFn: async () => {
1038
1037
  const read = async (key: string) => {
1039
- const res = await fetch(endpointFor(key));
1040
- if (!res.ok) return null;
1041
- const text = await res.text();
1042
- if (!text) return null;
1043
- try {
1044
- const parsed = JSON.parse(text);
1045
- if (Array.isArray(parsed?.questions) && parsed.questions.length > 0) {
1046
- return parsed as GuidedQuestionPayload;
1047
- }
1048
- } catch {
1049
- return null;
1038
+ const parsed = await readClientAppState<GuidedQuestionPayload>(
1039
+ key,
1040
+ ).catch(() => null);
1041
+ if (Array.isArray(parsed?.questions) && parsed.questions.length > 0) {
1042
+ return parsed;
1050
1043
  }
1051
1044
  return null;
1052
1045
  };
@@ -1086,16 +1079,12 @@ export function useGuidedQuestionFlow({
1086
1079
  const clear = useCallback(() => {
1087
1080
  setPayload(null);
1088
1081
  queryClient.setQueryData(resolvedQueryKey, null);
1089
- const del = (key: string) =>
1090
- fetch(endpointFor(key), {
1091
- method: "DELETE",
1092
- headers: { "X-Agent-Native-CSRF": "1" },
1093
- }).catch(() => {});
1082
+ const del = (key: string) => deleteClientAppState(key).catch(() => {});
1094
1083
  // Clear whichever key actually held the payload (scoped or bare) so the
1095
1084
  // card doesn't reappear on the next poll.
1096
1085
  del(scopedKey);
1097
1086
  if (scopedKey !== stateKey) del(stateKey);
1098
- }, [endpointFor, queryClient, resolvedQueryKey, scopedKey, stateKey]);
1087
+ }, [queryClient, resolvedQueryKey, scopedKey, stateKey]);
1099
1088
 
1100
1089
  const handleSubmit = useCallback(
1101
1090
  (answers: GuidedQuestionAnswers) => {
@@ -954,19 +954,19 @@ export function isServerlessRuntime(): boolean {
954
954
  }
955
955
 
956
956
  /**
957
- * postgres.js pool options tuned per runtime. A serverless instance handles
958
- * one request at a time, so a single connection is enough. Keeping the
959
- * foreground pool at 1 is important because each instance can also open an
960
- * app and Better Auth pool; a max of 2 on each pool still exhausted Neon
961
- * under a burst of warm instances. idle_timeout is shortened on
957
+ * postgres.js pool options tuned per runtime. idle_timeout is shortened on
962
958
  * serverless so a thawed-but-idle instance releases its connections quickly.
963
- * Long-lived Node servers keep the normal pool for throughput.
959
+ * Long-lived Node servers keep the larger pool for throughput.
960
+ *
961
+ * The cap assumes {@link sharedDbPool}: one pool per URL for the whole
962
+ * process, not one per consumer. See {@link neonPoolMax} for why the cap must
963
+ * leave room for concurrency.
964
964
  */
965
965
  export function pgPoolOptions(url: string): Record<string, unknown> {
966
966
  const serverless = isServerlessRuntime();
967
967
  return {
968
968
  onnotice: () => {},
969
- max: serverless ? 1 : 10,
969
+ max: serverless ? 4 : 20,
970
970
  idle_timeout: serverless ? 20 : 240,
971
971
  max_lifetime: 60 * 30,
972
972
  connect_timeout: 10,
@@ -977,13 +977,21 @@ export function pgPoolOptions(url: string): Record<string, unknown> {
977
977
  }
978
978
 
979
979
  /**
980
- * Connection cap for the @neondatabase/serverless `Pool`. Same instance
981
- * accumulation risk as postgres.js — one connection is enough for a
982
- * foreground serverless invocation and keeps the aggregate app/framework/auth
983
- * budget bounded.
980
+ * Connection cap for the @neondatabase/serverless `Pool`.
981
+ *
982
+ * TRAP: this number is the ceiling on how many statements one request can have
983
+ * in flight at once. It used to be 1 on serverless, which made every
984
+ * `Promise.all([...reads])` in a request serialize behind the single slot —
985
+ * against a remote endpoint that is ~83ms of pure wait per query, so ten
986
+ * "concurrent" reads took ~880ms instead of ~86ms. The cap was 1 only because
987
+ * every consumer built its OWN pool (the DbExec singleton, Better Auth, and one
988
+ * per `createGetDb()` schema module), so the real per-instance connection count
989
+ * was that number multiplied by ~6. {@link sharedDbPool} collapses those into
990
+ * one pool per URL, so the same aggregate budget buys in-request concurrency.
991
+ * Keep pools shared if you raise this.
984
992
  */
985
993
  export function neonPoolMax(): number {
986
- if (!isServerlessRuntime()) return 10;
994
+ if (!isServerlessRuntime()) return 20;
987
995
  // The durable background-function worker is a SINGLE process per run (unlike
988
996
  // the many warm request-instances the foreground serverless has), so it can
989
997
  // safely hold a larger pool without risking Neon's connection cap. The agent's
@@ -996,7 +1004,7 @@ export function neonPoolMax(): number {
996
1004
  // burst; keep the foreground serverless pool tiny to avoid "Max client
997
1005
  // connections reached" across many warm instances.
998
1006
  if (isBackgroundFunctionPoolContext()) return 8;
999
- return 1;
1007
+ return 4;
1000
1008
  }
1001
1009
 
1002
1010
  /**
@@ -1110,6 +1118,113 @@ export function attachNeonPoolErrorLogger(
1110
1118
  });
1111
1119
  }
1112
1120
 
1121
+ // ---------------------------------------------------------------------------
1122
+ // Shared connection pools
1123
+ // ---------------------------------------------------------------------------
1124
+
1125
+ interface ClosablePool {
1126
+ end(): Promise<unknown>;
1127
+ }
1128
+
1129
+ const _sharedDbPools = new Map<string, ClosablePool>();
1130
+ const _sharedDbPoolCloseHooks = new Set<() => void>();
1131
+ const _sharedDbPoolReplacementHooks = new Map<string, Set<() => void>>();
1132
+
1133
+ /**
1134
+ * One connection pool per (driver, URL) for the whole process.
1135
+ *
1136
+ * Opening a connection to a remote database costs a full TCP + TLS + auth
1137
+ * round-trip chain (~300-800ms measured against Neon in-region), and it used
1138
+ * to be paid once per CONSUMER: the `getDbExec` singleton, Better Auth, and one
1139
+ * more for every `createGetDb()` schema module (four in core alone, plus each
1140
+ * app's). Their `max` values also multiplied against the provider's connection
1141
+ * cap, which forced each pool down to a size too small to run a request's reads
1142
+ * concurrently.
1143
+ *
1144
+ * TRAP: only consumers that live as long as the process may share. Anything
1145
+ * handed a `close()` — `createDbExec()`, the migration exec on the direct
1146
+ * endpoint — must keep a private pool, or closing it takes the whole process's
1147
+ * database access down with it.
1148
+ */
1149
+ export function sharedDbPool<T extends ClosablePool>(
1150
+ driver: string,
1151
+ url: string,
1152
+ create: () => T,
1153
+ ): T {
1154
+ const key = `${driver}${url}`;
1155
+ const existing = _sharedDbPools.get(key);
1156
+ if (existing) return existing as T;
1157
+ const created = create();
1158
+ _sharedDbPools.set(key, created);
1159
+ return created;
1160
+ }
1161
+
1162
+ /**
1163
+ * Swap the pool registered for a key, for the postgres.js recycle path that
1164
+ * replaces a pool whose query timed out. Without this the registry would keep
1165
+ * handing out the discarded pool.
1166
+ */
1167
+ export function replaceSharedDbPool<T extends ClosablePool>(
1168
+ driver: string,
1169
+ url: string,
1170
+ previous: T,
1171
+ next: T,
1172
+ ): void {
1173
+ const key = `${driver}${url}`;
1174
+ if (_sharedDbPools.get(key) !== previous) return;
1175
+ _sharedDbPools.set(key, next);
1176
+ for (const hook of _sharedDbPoolReplacementHooks.get(key) ?? []) {
1177
+ try {
1178
+ hook();
1179
+ } catch {
1180
+ // A consumer's reset must not block the replacement from being used.
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ /**
1186
+ * Run `hook` when one shared pool is replaced, so derived consumers rebuild
1187
+ * their handles instead of continuing to use the timed-out pool.
1188
+ */
1189
+ export function onSharedDbPoolReplaced(
1190
+ driver: string,
1191
+ url: string,
1192
+ hook: () => void,
1193
+ ): void {
1194
+ const key = `${driver}${String.fromCharCode(0)}${url}`;
1195
+ const hooks = _sharedDbPoolReplacementHooks.get(key) ?? new Set();
1196
+ hooks.add(hook);
1197
+ _sharedDbPoolReplacementHooks.set(key, hooks);
1198
+ }
1199
+
1200
+ /**
1201
+ * Run `hook` when the shared pools are closed, so consumers holding a derived
1202
+ * handle (a Drizzle instance bound to the pool, the Better Auth adapter) drop
1203
+ * it instead of issuing queries on a closed pool.
1204
+ */
1205
+ export function onSharedDbPoolsClosed(hook: () => void): void {
1206
+ _sharedDbPoolCloseHooks.add(hook);
1207
+ }
1208
+
1209
+ export async function closeSharedDbPools(): Promise<void> {
1210
+ const pools = [..._sharedDbPools.values()];
1211
+ _sharedDbPools.clear();
1212
+ for (const hook of _sharedDbPoolCloseHooks) {
1213
+ try {
1214
+ hook();
1215
+ } catch {
1216
+ // A consumer's reset must not block the rest from being released.
1217
+ }
1218
+ }
1219
+ await Promise.all(
1220
+ pools.map((pool) =>
1221
+ Promise.resolve(pool.end()).catch(() => {
1222
+ // Already closed (process exiting, provider hung up) — nothing to do.
1223
+ }),
1224
+ ),
1225
+ );
1226
+ }
1227
+
1113
1228
  function disposePostgresPoolEventually(
1114
1229
  pool: { end: () => Promise<unknown> },
1115
1230
  label: string,
@@ -1130,8 +1245,6 @@ function disposePostgresPoolEventually(
1130
1245
  // ---------------------------------------------------------------------------
1131
1246
 
1132
1247
  let _exec: DbExec | undefined;
1133
- let _pgPool: any;
1134
- let _neonPool: any;
1135
1248
  let _sqlite: any;
1136
1249
  let _initPromise: Promise<void> | undefined;
1137
1250
 
@@ -1231,9 +1344,14 @@ async function createDbExecInternal(
1231
1344
  // connections, so its direct executions use stateless Neon HTTP instead.
1232
1345
  // The foreground and transaction surface keep the WebSocket pool.
1233
1346
  const bgHttp = isBackgroundFunctionPoolContext();
1234
- const pool = new Pool({ connectionString: url, max: neonPoolMax() });
1347
+ const makePool = () =>
1348
+ new Pool({ connectionString: url, max: neonPoolMax() });
1349
+ // The singleton exec shares the process pool; `createDbExec()` callers own
1350
+ // a `close()` and so must not be handed it.
1351
+ const pool = trackSingletonResources
1352
+ ? sharedDbPool("neon", url, makePool)
1353
+ : makePool();
1235
1354
  attachNeonPoolErrorLogger(pool);
1236
- if (trackSingletonResources) _neonPool = pool;
1237
1355
  const httpSql = bgHttp ? neon(url, { fullResults: true }) : null;
1238
1356
  async function queryNeonClient(
1239
1357
  client: any,
@@ -1461,6 +1579,7 @@ async function createDbExecInternal(
1461
1579
  }, 1);
1462
1580
  },
1463
1581
  async close() {
1582
+ if (trackSingletonResources) return closeSharedDbPools();
1464
1583
  await pool.end();
1465
1584
  },
1466
1585
  };
@@ -1566,12 +1685,17 @@ async function createDbExecInternal(
1566
1685
  // server-side timeout, avoiding ECONNRESET when the server hangs up.
1567
1686
  const createPool = () => postgres(url, pgPoolOptions(url));
1568
1687
  type PostgresPool = ReturnType<typeof createPool>;
1569
- let pool = createPool();
1570
- if (trackSingletonResources) _pgPool = pool;
1688
+ // Same rule as the Neon path: the singleton exec shares the process pool,
1689
+ // `createDbExec()` callers own a `close()` and get a private one.
1690
+ let pool = trackSingletonResources
1691
+ ? sharedDbPool("postgres-js", url, createPool)
1692
+ : createPool();
1571
1693
  const recyclePool = (timedOutPool: PostgresPool) => {
1572
1694
  if (pool === timedOutPool) {
1573
1695
  pool = createPool();
1574
- if (trackSingletonResources) _pgPool = pool;
1696
+ if (trackSingletonResources) {
1697
+ replaceSharedDbPool("postgres-js", url, timedOutPool, pool);
1698
+ }
1575
1699
  }
1576
1700
  disposePostgresPoolEventually(timedOutPool, "timed-out pooled query");
1577
1701
  };
@@ -1626,6 +1750,7 @@ async function createDbExecInternal(
1626
1750
  return result as T;
1627
1751
  },
1628
1752
  async close() {
1753
+ if (trackSingletonResources) return closeSharedDbPools();
1629
1754
  await pool.end();
1630
1755
  },
1631
1756
  };
@@ -1884,14 +2009,9 @@ export function getDbExec(): DbExec {
1884
2009
 
1885
2010
  /** Close the database connection (for scripts that need cleanup). */
1886
2011
  export async function closeDbExec(): Promise<void> {
1887
- if (_pgPool) {
1888
- await _pgPool.end();
1889
- _pgPool = undefined;
1890
- }
1891
- if (_neonPool) {
1892
- await _neonPool.end();
1893
- _neonPool = undefined;
1894
- }
2012
+ // Both Postgres pools live in the shared registry, which also notifies the
2013
+ // Drizzle / Better Auth consumers bound to them.
2014
+ await closeSharedDbPools();
1895
2015
  if (_sqlite) {
1896
2016
  _sqlite.close();
1897
2017
  _sqlite = undefined;
@@ -21,6 +21,9 @@ import {
21
21
  withDbTimeout,
22
22
  retryOnConnectionError,
23
23
  dbOpTimeoutMs,
24
+ sharedDbPool,
25
+ onSharedDbPoolsClosed,
26
+ onSharedDbPoolReplaced,
24
27
  } from "./client.js";
25
28
 
26
29
  // Lazy driver loaders — cached promises so dynamic import only runs once.
@@ -444,6 +447,27 @@ export function createGetDb<T extends Record<string, unknown>>(schema: T) {
444
447
  let _db: any;
445
448
  let _dbReady: Promise<any> | undefined;
446
449
 
450
+ // The Drizzle instance is bound to a shared pool, so a `closeDbExec()` (test
451
+ // teardown, script cleanup) must invalidate it rather than leave this store
452
+ // issuing queries on a closed pool. Registered lazily from the pooled
453
+ // branches only — `createGetDb` is called at module scope by every store, and
454
+ // core's specs widely mock `db/client.js`.
455
+ let _closeHookRegistered = false;
456
+ function resetOnPoolClose(driver?: string, url?: string): void {
457
+ if (_closeHookRegistered) return;
458
+ _closeHookRegistered = true;
459
+ onSharedDbPoolsClosed(() => {
460
+ _db = undefined;
461
+ _dbReady = undefined;
462
+ });
463
+ if (driver && url) {
464
+ onSharedDbPoolReplaced(driver, url, () => {
465
+ _db = undefined;
466
+ _dbReady = undefined;
467
+ });
468
+ }
469
+ }
470
+
447
471
  function startInit(): Promise<any> {
448
472
  if (_dbReady) return _dbReady;
449
473
 
@@ -473,10 +497,15 @@ export function createGetDb<T extends Record<string, unknown>>(schema: T) {
473
497
  if (dialect === "postgres") {
474
498
  if (isNeonUrl(url)) {
475
499
  _dbReady = getNeonServerlessDrizzle().then(({ drizzle, Pool }) => {
476
- const rawPool = new Pool({
477
- connectionString: url,
478
- max: neonPoolMax(),
479
- });
500
+ // Shared with the DbExec singleton, Better Auth, and every other
501
+ // `createGetDb` store: one connect per process instead of one per
502
+ // schema module. See `sharedDbPool` in client.ts.
503
+ resetOnPoolClose("neon", url);
504
+ const rawPool = sharedDbPool(
505
+ "neon",
506
+ url,
507
+ () => new Pool({ connectionString: url, max: neonPoolMax() }),
508
+ );
480
509
  attachNeonPoolErrorLogger(rawPool);
481
510
  // Wrap the pool with the resilience layer so Drizzle queries get the
482
511
  // same withDbTimeout + retryOnConnectionError protection as the raw
@@ -489,8 +518,12 @@ export function createGetDb<T extends Record<string, unknown>>(schema: T) {
489
518
  _dbReady = getPgDrizzle().then(({ drizzle, postgres }) => {
490
519
  // pgPoolOptions caps the pool to a small size on serverless so
491
520
  // concurrent frozen instances don't exhaust Neon/Postgres'
492
- // connection limit ("Max client connections reached").
493
- const client = postgres(url, pgPoolOptions(url));
521
+ // connection limit ("Max client connections reached"). Shared across
522
+ // consumers see `sharedDbPool` in client.ts.
523
+ resetOnPoolClose("postgres-js", url);
524
+ const client = sharedDbPool("postgres-js", url, () =>
525
+ postgres(url, pgPoolOptions(url)),
526
+ );
494
527
  _db = drizzle(buildResilientPostgresJsClient(client), { schema });
495
528
  });
496
529
  }
@@ -51,7 +51,7 @@ export interface DatabaseSchemaHealthResult {
51
51
  error?: string;
52
52
  }
53
53
 
54
- const DEFAULT_REQUIRED_SCHEMA: RequiredSchemaTable[] = [
54
+ export const DEFAULT_REQUIRED_SCHEMA: RequiredSchemaTable[] = [
55
55
  {
56
56
  table: "agent_runs",
57
57
  columns: [
@@ -253,6 +253,24 @@ async function tableColumns(
253
253
  : sqliteTableColumns(exec, table);
254
254
  }
255
255
 
256
+ /**
257
+ * Memo for the default probe, which several client surfaces re-run on every
258
+ * page load at one `information_schema` round trip per required table.
259
+ *
260
+ * Only a clean, completed result is memoized: a probe reporting a missing table
261
+ * or an unreadable database must never be served from cache, or the repair that
262
+ * follows stays invisible for the whole window. Schema changes are additive and
263
+ * applied by migrations, so a clean answer cannot silently go bad — but the
264
+ * window is still kept to seconds, short enough that anything a deploy changes
265
+ * shows up on the next page load rather than being pinned. Callers that
266
+ * override `exec`, `dialect`, or `required` bypass it entirely.
267
+ */
268
+ const SCHEMA_HEALTH_MEMO_MS = 5_000;
269
+ let schemaHealthMemo: {
270
+ at: number;
271
+ result: DatabaseSchemaHealthResult;
272
+ } | null = null;
273
+
256
274
  export async function runDatabaseSchemaHealthCheck(
257
275
  options: {
258
276
  exec?: DbExec;
@@ -260,6 +278,15 @@ export async function runDatabaseSchemaHealthCheck(
260
278
  required?: RequiredSchemaTable[];
261
279
  } = {},
262
280
  ): Promise<DatabaseSchemaHealthResult> {
281
+ const memoizable = !options.exec && !options.dialect && !options.required;
282
+ if (
283
+ memoizable &&
284
+ schemaHealthMemo &&
285
+ Date.now() - schemaHealthMemo.at < SCHEMA_HEALTH_MEMO_MS
286
+ ) {
287
+ return schemaHealthMemo.result;
288
+ }
289
+
263
290
  const dialect = options.dialect ?? getDialect();
264
291
  const exec = options.exec ?? getDbExec();
265
292
  const required = options.required ?? DEFAULT_REQUIRED_SCHEMA;
@@ -267,18 +294,24 @@ export async function runDatabaseSchemaHealthCheck(
267
294
  const missingColumns: Array<{ table: string; column: string }> = [];
268
295
 
269
296
  try {
270
- for (const requirement of required) {
271
- const columns = await tableColumns(exec, dialect, requirement.table);
297
+ // Independent probes: serially they cost one network round trip each.
298
+ const found = await Promise.all(
299
+ required.map((requirement) =>
300
+ tableColumns(exec, dialect, requirement.table),
301
+ ),
302
+ );
303
+ required.forEach((requirement, index) => {
304
+ const columns = found[index];
272
305
  if (!columns) {
273
306
  missingTables.push(requirement.table);
274
- continue;
307
+ return;
275
308
  }
276
309
  for (const column of requirement.columns) {
277
310
  if (!columns.has(column)) {
278
311
  missingColumns.push({ table: requirement.table, column });
279
312
  }
280
313
  }
281
- }
314
+ });
282
315
  } catch (err) {
283
316
  return {
284
317
  ok: false,
@@ -290,13 +323,15 @@ export async function runDatabaseSchemaHealthCheck(
290
323
  };
291
324
  }
292
325
 
293
- return {
326
+ const result: DatabaseSchemaHealthResult = {
294
327
  ok: missingTables.length === 0 && missingColumns.length === 0,
295
328
  checked: true,
296
329
  dialect,
297
330
  missingTables,
298
331
  missingColumns,
299
332
  };
333
+ if (memoizable && result.ok) schemaHealthMemo = { at: Date.now(), result };
334
+ return result;
300
335
  }
301
336
 
302
337
  export function formatRuntimeDebugFingerprint(