@agent-native/core 0.131.7 → 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 (320) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +123 -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 +38 -34
  15. package/corpus/core/src/audit/redact.ts +104 -18
  16. package/corpus/core/src/cli/create.ts +115 -16
  17. package/corpus/core/src/client/AgentPanel.tsx +2 -7
  18. package/corpus/core/src/client/AssistantChat.tsx +10 -2
  19. package/corpus/core/src/client/MultiTabAssistantChat.tsx +3 -0
  20. package/corpus/core/src/client/RunStuckBanner.tsx +29 -6
  21. package/corpus/core/src/client/agent-chat.ts +4 -9
  22. package/corpus/core/src/client/application-state.ts +115 -12
  23. package/corpus/core/src/client/dynamic-suggestions.ts +4 -12
  24. package/corpus/core/src/client/frame-protocol.ts +5 -1
  25. package/corpus/core/src/client/guided-questions.tsx +12 -23
  26. package/corpus/core/src/db/client.ts +148 -28
  27. package/corpus/core/src/db/create-get-db.ts +39 -6
  28. package/corpus/core/src/db/runtime-diagnostics.ts +41 -6
  29. package/corpus/core/src/deploy/build.ts +26 -33
  30. package/corpus/core/src/feature-flags/store.ts +10 -5
  31. package/corpus/core/src/integrations/a2a-continuations-store.ts +11 -0
  32. package/corpus/core/src/integrations/config-store.ts +13 -0
  33. package/corpus/core/src/integrations/google-docs-poller.ts +26 -2
  34. package/corpus/core/src/mcp-client/routes.ts +33 -2
  35. package/corpus/core/src/onboarding/plugin.ts +31 -25
  36. package/corpus/core/src/org/accept-pending.ts +2 -0
  37. package/corpus/core/src/org/auto-join-domain.ts +2 -0
  38. package/corpus/core/src/org/context.ts +35 -10
  39. package/corpus/core/src/org/handlers.ts +6 -0
  40. package/corpus/core/src/org/request-org-cache.ts +71 -0
  41. package/corpus/core/src/resources/store.ts +10 -9
  42. package/corpus/core/src/scripts/runner.ts +6 -3
  43. package/corpus/core/src/secrets/storage.ts +97 -5
  44. package/corpus/core/src/server/action-change.ts +33 -0
  45. package/corpus/core/src/server/action-routes.ts +11 -9
  46. package/corpus/core/src/server/agent-teams.ts +10 -2
  47. package/corpus/core/src/server/better-auth-instance.ts +39 -12
  48. package/corpus/core/src/server/core-routes-plugin.ts +19 -2
  49. package/corpus/core/src/server/credential-provider.ts +57 -14
  50. package/corpus/core/src/server/http-response-telemetry.ts +1 -0
  51. package/corpus/core/src/server/poll.ts +95 -57
  52. package/corpus/core/src/server/voice-providers-status.ts +19 -4
  53. package/corpus/core/src/workspace-files/tool.ts +18 -11
  54. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +38 -0
  55. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +1 -1
  56. package/corpus/templates/analytics/actions/ensure-native-v2-dashboards.ts +299 -0
  57. package/corpus/templates/analytics/actions/list-native-v2-dashboard-templates.ts +34 -0
  58. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +82 -0
  59. package/corpus/templates/analytics/app/components/layout/NewDashboardDialog.tsx +2 -2
  60. package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +40 -34
  61. package/corpus/templates/analytics/app/hooks/use-user-pref.ts +3 -3
  62. package/corpus/templates/analytics/app/i18n-data.ts +40 -0
  63. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +18 -10
  64. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +14 -0
  65. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +1 -0
  66. package/corpus/templates/analytics/app/routes/chart.tsx +3 -0
  67. package/corpus/templates/analytics/changelog/2026-07-30-analytics-dashboards-now-expose-native-funnel-heatmap-callou.md +6 -0
  68. package/corpus/templates/analytics/changelog/2026-07-30-analytics-now-ships-reusable-native-v2-dashboard-manifests-w.md +6 -0
  69. package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +81 -1
  70. package/corpus/templates/analytics/server/lib/native-v2-dashboards.ts +457 -0
  71. package/corpus/templates/analytics/server/lib/report-chart-svg.ts +73 -2
  72. package/corpus/templates/analytics/server/plugins/agent-chat.ts +1 -1
  73. package/corpus/templates/analytics/shared/dashboard-funnel.ts +83 -0
  74. package/corpus/templates/clips/actions/generate-workflow.ts +6 -4
  75. package/corpus/templates/clips/actions/reconcile-workflow-generation.ts +257 -0
  76. package/corpus/templates/clips/app/hooks/use-auto-title.ts +166 -4
  77. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -1
  78. package/corpus/templates/clips/changelog/2026-07-27-stopped-agent-runs-no-longer-leave-generated-workflow-cards-.md +6 -0
  79. package/corpus/templates/clips/changelog/2026-07-30-workflow-generation-now-stops-retrying-after-repeated-failur.md +6 -0
  80. package/corpus/templates/clips/shared/workflow.ts +5 -0
  81. package/corpus/templates/content/changelog/2026-07-30-long-running-delegated-requests-now-continue-reliably.md +6 -0
  82. package/corpus/templates/content/netlify.toml +3 -0
  83. package/corpus/templates/content/server/plugins/agent-chat.ts +1 -0
  84. package/corpus/templates/slides/.agents/skills/create-deck/SKILL.md +2 -1
  85. package/corpus/templates/slides/actions/patch-deck.ts +25 -1
  86. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +17 -4
  87. package/corpus/templates/slides/app/components/deck/SlideRenderer.tsx +9 -1
  88. package/corpus/templates/slides/app/components/layout/Layout.tsx +1 -1
  89. package/corpus/templates/slides/app/context/DeckContext.tsx +11 -3
  90. package/corpus/templates/slides/app/lib/sanitize-slide-html.ts +39 -11
  91. package/corpus/templates/slides/app/pages/Index.tsx +2 -2
  92. package/corpus/templates/slides/changelog/2026-07-30-slide-previews-keep-their-aspect-ratio-isolate-embedded-styl.md +6 -0
  93. package/corpus/templates/slides/changelog/2026-07-30-slides-no-longer-lose-decks-while-organization-access-is-loa.md +6 -0
  94. package/corpus/toolkit/CHANGELOG.md +8 -0
  95. package/corpus/toolkit/package.json +1 -1
  96. package/corpus/toolkit/src/composer/runtime-adapters.tsx +16 -11
  97. package/dist/a2a/activity.d.ts +22 -4
  98. package/dist/a2a/activity.d.ts.map +1 -1
  99. package/dist/a2a/activity.js +97 -11
  100. package/dist/a2a/activity.js.map +1 -1
  101. package/dist/a2a/index.d.ts +1 -1
  102. package/dist/a2a/index.d.ts.map +1 -1
  103. package/dist/a2a/index.js +1 -1
  104. package/dist/a2a/index.js.map +1 -1
  105. package/dist/agent/durable-background.d.ts +13 -8
  106. package/dist/agent/durable-background.d.ts.map +1 -1
  107. package/dist/agent/durable-background.js +36 -28
  108. package/dist/agent/durable-background.js.map +1 -1
  109. package/dist/agent/harness/background.d.ts.map +1 -1
  110. package/dist/agent/harness/background.js +12 -2
  111. package/dist/agent/harness/background.js.map +1 -1
  112. package/dist/agent/production-agent.d.ts.map +1 -1
  113. package/dist/agent/production-agent.js +18 -15
  114. package/dist/agent/production-agent.js.map +1 -1
  115. package/dist/agent/run-store.d.ts +2 -0
  116. package/dist/agent/run-store.d.ts.map +1 -1
  117. package/dist/agent/run-store.js +44 -3
  118. package/dist/agent/run-store.js.map +1 -1
  119. package/dist/application-state/handlers.d.ts +15 -0
  120. package/dist/application-state/handlers.d.ts.map +1 -1
  121. package/dist/application-state/handlers.js +42 -2
  122. package/dist/application-state/handlers.js.map +1 -1
  123. package/dist/application-state/index.d.ts +2 -2
  124. package/dist/application-state/index.d.ts.map +1 -1
  125. package/dist/application-state/index.js +2 -2
  126. package/dist/application-state/index.js.map +1 -1
  127. package/dist/application-state/store.d.ts +11 -0
  128. package/dist/application-state/store.d.ts.map +1 -1
  129. package/dist/application-state/store.js +37 -40
  130. package/dist/application-state/store.js.map +1 -1
  131. package/dist/audit/redact.d.ts +25 -2
  132. package/dist/audit/redact.d.ts.map +1 -1
  133. package/dist/audit/redact.js +85 -21
  134. package/dist/audit/redact.js.map +1 -1
  135. package/dist/cli/create.d.ts +8 -1
  136. package/dist/cli/create.d.ts.map +1 -1
  137. package/dist/cli/create.js +98 -16
  138. package/dist/cli/create.js.map +1 -1
  139. package/dist/client/AgentPanel.d.ts.map +1 -1
  140. package/dist/client/AgentPanel.js +2 -7
  141. package/dist/client/AgentPanel.js.map +1 -1
  142. package/dist/client/AssistantChat.d.ts.map +1 -1
  143. package/dist/client/AssistantChat.js +10 -2
  144. package/dist/client/AssistantChat.js.map +1 -1
  145. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  146. package/dist/client/MultiTabAssistantChat.js +1 -1
  147. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  148. package/dist/client/RunStuckBanner.d.ts +11 -1
  149. package/dist/client/RunStuckBanner.d.ts.map +1 -1
  150. package/dist/client/RunStuckBanner.js +20 -7
  151. package/dist/client/RunStuckBanner.js.map +1 -1
  152. package/dist/client/agent-chat.d.ts.map +1 -1
  153. package/dist/client/agent-chat.js +4 -6
  154. package/dist/client/agent-chat.js.map +1 -1
  155. package/dist/client/application-state.d.ts +11 -0
  156. package/dist/client/application-state.d.ts.map +1 -1
  157. package/dist/client/application-state.js +87 -9
  158. package/dist/client/application-state.js.map +1 -1
  159. package/dist/client/dynamic-suggestions.d.ts.map +1 -1
  160. package/dist/client/dynamic-suggestions.js +4 -13
  161. package/dist/client/dynamic-suggestions.js.map +1 -1
  162. package/dist/client/frame-protocol.d.ts +1 -0
  163. package/dist/client/frame-protocol.d.ts.map +1 -1
  164. package/dist/client/frame-protocol.js.map +1 -1
  165. package/dist/client/guided-questions.d.ts.map +1 -1
  166. package/dist/client/guided-questions.js +6 -22
  167. package/dist/client/guided-questions.js.map +1 -1
  168. package/dist/collab/awareness.d.ts +2 -2
  169. package/dist/collab/awareness.d.ts.map +1 -1
  170. package/dist/collab/routes.d.ts +1 -1
  171. package/dist/db/client.d.ts +57 -10
  172. package/dist/db/client.d.ts.map +1 -1
  173. package/dist/db/client.js +122 -31
  174. package/dist/db/client.js.map +1 -1
  175. package/dist/db/create-get-db.d.ts.map +1 -1
  176. package/dist/db/create-get-db.js +31 -7
  177. package/dist/db/create-get-db.js.map +1 -1
  178. package/dist/db/runtime-diagnostics.d.ts +1 -0
  179. package/dist/db/runtime-diagnostics.d.ts.map +1 -1
  180. package/dist/db/runtime-diagnostics.js +31 -6
  181. package/dist/db/runtime-diagnostics.js.map +1 -1
  182. package/dist/deploy/build.d.ts +5 -20
  183. package/dist/deploy/build.d.ts.map +1 -1
  184. package/dist/deploy/build.js +18 -30
  185. package/dist/deploy/build.js.map +1 -1
  186. package/dist/feature-flags/store.d.ts.map +1 -1
  187. package/dist/feature-flags/store.js +10 -5
  188. package/dist/feature-flags/store.js.map +1 -1
  189. package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
  190. package/dist/integrations/a2a-continuations-store.js +12 -0
  191. package/dist/integrations/a2a-continuations-store.js.map +1 -1
  192. package/dist/integrations/config-store.d.ts +1 -0
  193. package/dist/integrations/config-store.d.ts.map +1 -1
  194. package/dist/integrations/config-store.js +11 -0
  195. package/dist/integrations/config-store.js.map +1 -1
  196. package/dist/integrations/google-docs-poller.d.ts.map +1 -1
  197. package/dist/integrations/google-docs-poller.js +19 -2
  198. package/dist/integrations/google-docs-poller.js.map +1 -1
  199. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  200. package/dist/mcp/screen-memory-stdio.d.ts.map +1 -1
  201. package/dist/mcp-client/routes.d.ts.map +1 -1
  202. package/dist/mcp-client/routes.js +30 -2
  203. package/dist/mcp-client/routes.js.map +1 -1
  204. package/dist/notifications/routes.d.ts +6 -6
  205. package/dist/onboarding/plugin.d.ts.map +1 -1
  206. package/dist/onboarding/plugin.js +12 -8
  207. package/dist/onboarding/plugin.js.map +1 -1
  208. package/dist/org/accept-pending.d.ts.map +1 -1
  209. package/dist/org/accept-pending.js +2 -0
  210. package/dist/org/accept-pending.js.map +1 -1
  211. package/dist/org/auto-join-domain.d.ts.map +1 -1
  212. package/dist/org/auto-join-domain.js +2 -0
  213. package/dist/org/auto-join-domain.js.map +1 -1
  214. package/dist/org/context.d.ts.map +1 -1
  215. package/dist/org/context.js +29 -9
  216. package/dist/org/context.js.map +1 -1
  217. package/dist/org/handlers.d.ts.map +1 -1
  218. package/dist/org/handlers.js +5 -0
  219. package/dist/org/handlers.js.map +1 -1
  220. package/dist/org/request-org-cache.d.ts +16 -0
  221. package/dist/org/request-org-cache.d.ts.map +1 -0
  222. package/dist/org/request-org-cache.js +62 -0
  223. package/dist/org/request-org-cache.js.map +1 -0
  224. package/dist/progress/routes.d.ts +1 -1
  225. package/dist/resources/handlers.d.ts +1 -1
  226. package/dist/resources/store.d.ts.map +1 -1
  227. package/dist/resources/store.js +11 -9
  228. package/dist/resources/store.js.map +1 -1
  229. package/dist/scripts/runner.d.ts.map +1 -1
  230. package/dist/scripts/runner.js +3 -3
  231. package/dist/scripts/runner.js.map +1 -1
  232. package/dist/secrets/storage.d.ts.map +1 -1
  233. package/dist/secrets/storage.js +82 -5
  234. package/dist/secrets/storage.js.map +1 -1
  235. package/dist/server/action-change.d.ts +20 -0
  236. package/dist/server/action-change.d.ts.map +1 -1
  237. package/dist/server/action-change.js +29 -0
  238. package/dist/server/action-change.js.map +1 -1
  239. package/dist/server/action-routes.d.ts.map +1 -1
  240. package/dist/server/action-routes.js +7 -8
  241. package/dist/server/action-routes.js.map +1 -1
  242. package/dist/server/agent-teams.d.ts.map +1 -1
  243. package/dist/server/agent-teams.js +10 -2
  244. package/dist/server/agent-teams.js.map +1 -1
  245. package/dist/server/better-auth-instance.d.ts.map +1 -1
  246. package/dist/server/better-auth-instance.js +31 -14
  247. package/dist/server/better-auth-instance.js.map +1 -1
  248. package/dist/server/core-routes-plugin.d.ts +1 -0
  249. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  250. package/dist/server/core-routes-plugin.js +18 -4
  251. package/dist/server/core-routes-plugin.js.map +1 -1
  252. package/dist/server/credential-provider.d.ts +16 -0
  253. package/dist/server/credential-provider.d.ts.map +1 -1
  254. package/dist/server/credential-provider.js +49 -13
  255. package/dist/server/credential-provider.js.map +1 -1
  256. package/dist/server/http-response-telemetry.d.ts.map +1 -1
  257. package/dist/server/http-response-telemetry.js +1 -0
  258. package/dist/server/http-response-telemetry.js.map +1 -1
  259. package/dist/server/poll.d.ts +6 -0
  260. package/dist/server/poll.d.ts.map +1 -1
  261. package/dist/server/poll.js +84 -47
  262. package/dist/server/poll.js.map +1 -1
  263. package/dist/server/realtime-token.d.ts +1 -1
  264. package/dist/server/voice-providers-status.d.ts.map +1 -1
  265. package/dist/server/voice-providers-status.js +12 -3
  266. package/dist/server/voice-providers-status.js.map +1 -1
  267. package/dist/workspace-files/tool.d.ts.map +1 -1
  268. package/dist/workspace-files/tool.js +18 -11
  269. package/dist/workspace-files/tool.js.map +1 -1
  270. package/docs/content/durable-background-runs.mdx +15 -6
  271. package/docs/design/durable-agent-runs.md +15 -11
  272. package/package.json +3 -3
  273. package/src/a2a/activity.ts +127 -10
  274. package/src/a2a/index.ts +3 -0
  275. package/src/agent/durable-background.ts +38 -28
  276. package/src/agent/harness/background.ts +12 -2
  277. package/src/agent/production-agent.ts +18 -15
  278. package/src/agent/run-store.ts +45 -3
  279. package/src/application-state/handlers.ts +45 -0
  280. package/src/application-state/index.ts +3 -0
  281. package/src/application-state/store.ts +38 -34
  282. package/src/audit/redact.ts +104 -18
  283. package/src/cli/create.ts +115 -16
  284. package/src/client/AgentPanel.tsx +2 -7
  285. package/src/client/AssistantChat.tsx +10 -2
  286. package/src/client/MultiTabAssistantChat.tsx +3 -0
  287. package/src/client/RunStuckBanner.tsx +29 -6
  288. package/src/client/agent-chat.ts +4 -9
  289. package/src/client/application-state.ts +115 -12
  290. package/src/client/dynamic-suggestions.ts +4 -12
  291. package/src/client/frame-protocol.ts +5 -1
  292. package/src/client/guided-questions.tsx +12 -23
  293. package/src/db/client.ts +148 -28
  294. package/src/db/create-get-db.ts +39 -6
  295. package/src/db/runtime-diagnostics.ts +41 -6
  296. package/src/deploy/build.ts +26 -33
  297. package/src/feature-flags/store.ts +10 -5
  298. package/src/integrations/a2a-continuations-store.ts +11 -0
  299. package/src/integrations/config-store.ts +13 -0
  300. package/src/integrations/google-docs-poller.ts +26 -2
  301. package/src/mcp-client/routes.ts +33 -2
  302. package/src/onboarding/plugin.ts +31 -25
  303. package/src/org/accept-pending.ts +2 -0
  304. package/src/org/auto-join-domain.ts +2 -0
  305. package/src/org/context.ts +35 -10
  306. package/src/org/handlers.ts +6 -0
  307. package/src/org/request-org-cache.ts +71 -0
  308. package/src/resources/store.ts +10 -9
  309. package/src/scripts/runner.ts +6 -3
  310. package/src/secrets/storage.ts +97 -5
  311. package/src/server/action-change.ts +33 -0
  312. package/src/server/action-routes.ts +11 -9
  313. package/src/server/agent-teams.ts +10 -2
  314. package/src/server/better-auth-instance.ts +39 -12
  315. package/src/server/core-routes-plugin.ts +19 -2
  316. package/src/server/credential-provider.ts +57 -14
  317. package/src/server/http-response-telemetry.ts +1 -0
  318. package/src/server/poll.ts +95 -57
  319. package/src/server/voice-providers-status.ts +19 -4
  320. package/src/workspace-files/tool.ts +18 -11
@@ -21,7 +21,7 @@ import {
21
21
  MCP_EMBED_CORS_ALLOW_HEADERS,
22
22
  shouldAllowMcpEmbedCredentials,
23
23
  } from "../shared/mcp-embed-headers.js";
24
- import { notifyActionChange } from "./action-change.js";
24
+ import { actionCallIsReadOnly, notifyActionChange } from "./action-change.js";
25
25
  import {
26
26
  seedAgentRunOwnerContext,
27
27
  type AgentRunOwnerContext,
@@ -603,14 +603,16 @@ export function mountActionRoutes(
603
603
  // their action queries. The calling tab already refetches via
604
604
  // useActionMutation's onSuccess, so this is mainly cross-tab
605
605
  // sync (and parity with the agent's tool-call path).
606
- // Explicit entry.readOnly (true OR false) wins over the method
607
- // heuristic. defineAction already auto-infers GET readOnly=true,
608
- // so for actions registered through that path entry.readOnly is
609
- // always set and the fallback just guards legacy wrap paths.
610
- const isReadOnly =
611
- typeof entry.readOnly === "boolean"
612
- ? entry.readOnly
613
- : method === "GET";
606
+ // A per-call Plan-mode effect wins over entry.readOnly, which
607
+ // wins (true OR false) over the method heuristic. defineAction
608
+ // already auto-infers GET readOnly=true, so for actions
609
+ // registered through that path entry.readOnly is always set and
610
+ // the fallback just guards legacy wrap paths.
611
+ const isReadOnly = actionCallIsReadOnly(
612
+ entry,
613
+ params,
614
+ method === "GET",
615
+ );
614
616
  if (!isReadOnly) {
615
617
  try {
616
618
  await notifyActionChange({
@@ -57,6 +57,7 @@ import {
57
57
  listAppState,
58
58
  deleteAppState,
59
59
  } from "../application-state/script-helpers.js";
60
+ import { redactArgsToValue, redactTextToSummary } from "../audit/redact.js";
60
61
  import { createThread } from "../chat-threads/store.js";
61
62
  import type {
62
63
  BackgroundAgentRun,
@@ -1112,13 +1113,20 @@ function summarizeAgentChatEvent(event: RunEvent): {
1112
1113
  return {
1113
1114
  kind: "status",
1114
1115
  message: `Running ${payload.tool}`,
1115
- metadata: { tool: payload.tool, input: payload.input },
1116
+ metadata: {
1117
+ tool: payload.tool,
1118
+ input: redactArgsToValue(payload.input),
1119
+ },
1116
1120
  };
1117
1121
  case "tool_done":
1118
1122
  return {
1119
1123
  kind: "artifact",
1120
1124
  message: payload.result,
1121
- metadata: { tool: payload.tool },
1125
+ metadata: {
1126
+ tool: payload.tool,
1127
+ input: redactArgsToValue(payload.input),
1128
+ result: redactTextToSummary(payload.result),
1129
+ },
1122
1130
  };
1123
1131
  case "agent_task":
1124
1132
  return {
@@ -38,6 +38,9 @@ import {
38
38
  pgPoolOptions,
39
39
  neonPoolMax,
40
40
  attachNeonPoolErrorLogger,
41
+ sharedDbPool,
42
+ onSharedDbPoolsClosed,
43
+ onSharedDbPoolReplaced,
41
44
  } from "../db/client.js";
42
45
  import { ensureTableExists } from "../db/ddl-guard.js";
43
46
  import { saveOAuthTokens } from "../oauth-tokens/store.js";
@@ -808,15 +811,34 @@ export async function getBetterAuthInternalAdapter(
808
811
  export async function resetBetterAuth(): Promise<void> {
809
812
  _auth = undefined;
810
813
  _initPromise = undefined;
811
- if (_neonAuthPool) {
812
- try {
813
- await _neonAuthPool.end();
814
- } catch {
815
- // Pool may have already closed (process exiting, etc.) — don't block reset.
816
- }
814
+ // The Postgres pool belongs to the process (see `sharedDbPool`), not to Better
815
+ // Auth — ending it here would take the framework's and every store's database
816
+ // access down with it. `closeDbExec()` owns that.
817
+ _neonAuthPool = undefined;
818
+ await closePgliteClients();
819
+ }
820
+
821
+ // A `closeDbExec()` releases the pool this instance's adapter is bound to, so
822
+ // the next `getAuth()` must build a fresh one. Registered from the pooled
823
+ // branches rather than at module load: core's specs widely mock
824
+ // `db/client.js`, and an import-time call into the mock breaks every one of
825
+ // them that doesn't stub this export.
826
+ let _poolCloseHookRegistered = false;
827
+ function resetAuthOnPoolClose(driver?: string, url?: string): void {
828
+ if (_poolCloseHookRegistered) return;
829
+ _poolCloseHookRegistered = true;
830
+ onSharedDbPoolsClosed(() => {
831
+ _auth = undefined;
832
+ _initPromise = undefined;
817
833
  _neonAuthPool = undefined;
834
+ });
835
+ if (driver && url) {
836
+ onSharedDbPoolReplaced(driver, url, () => {
837
+ _auth = undefined;
838
+ _initPromise = undefined;
839
+ _neonAuthPool = undefined;
840
+ });
818
841
  }
819
- await closePgliteClients();
820
842
  }
821
843
 
822
844
  // ---------------------------------------------------------------------------
@@ -1160,10 +1182,12 @@ async function buildDatabaseConfig(
1160
1182
  // session lookup on essentially every authenticated request, so an
1161
1183
  // un-capped pool here is a primary contributor to "Max client
1162
1184
  // connections reached" across concurrent serverless instances.
1163
- _neonAuthPool = new Pool({
1164
- connectionString: url,
1165
- max: neonPoolMax(),
1166
- });
1185
+ resetAuthOnPoolClose("neon", url);
1186
+ _neonAuthPool = sharedDbPool(
1187
+ "neon",
1188
+ url,
1189
+ () => new Pool({ connectionString: url, max: neonPoolMax() }),
1190
+ );
1167
1191
  attachNeonPoolErrorLogger(_neonAuthPool, "db/neon-auth");
1168
1192
  const { drizzle } = await import("drizzle-orm/neon-serverless");
1169
1193
  const db = drizzle(buildResilientNeonPool(_neonAuthPool), {
@@ -1182,7 +1206,10 @@ async function buildDatabaseConfig(
1182
1206
  // un-capped pool here is a primary contributor to "Max client connections
1183
1207
  // reached" across concurrent serverless instances.
1184
1208
  const { default: postgres } = await import("postgres");
1185
- const sql = postgres(url, pgPoolOptions(url));
1209
+ resetAuthOnPoolClose("postgres-js", url);
1210
+ const sql = sharedDbPool("postgres-js", url, () =>
1211
+ postgres(url, pgPoolOptions(url)),
1212
+ );
1186
1213
  const { drizzle } = await import("drizzle-orm/postgres-js");
1187
1214
  const db = drizzle(buildResilientPostgresJsClient(sql), {
1188
1215
  schema: pgAuthSchema,
@@ -46,6 +46,7 @@ import {
46
46
  putComposeDraft,
47
47
  deleteComposeDraft,
48
48
  deleteAllComposeDrafts,
49
+ getStateMany,
49
50
  } from "../application-state/handlers.js";
50
51
  import { mountBrowserSessionRoutes } from "../browser-sessions/routes.js";
51
52
  import { mountDbAdminRoutes } from "../db-admin/routes.js";
@@ -152,6 +153,7 @@ import {
152
153
  import type { EnvKeyConfig } from "./create-server.js";
153
154
  import {
154
155
  canUseDeployCredentialFallbackForRequest,
156
+ prefetchSecrets,
155
157
  readDeployCredentialEnv,
156
158
  resolveSecret,
157
159
  } from "./credential-provider.js";
@@ -1286,6 +1288,7 @@ export async function readLegacyCoreRouteInitSettings(
1286
1288
  * GET /_agent-native/health — DB liveness probe + scale-to-zero warmup
1287
1289
  * GET /_agent-native/env-status — env key configuration status (when envKeys provided)
1288
1290
  * POST /_agent-native/env-vars — compatibility route that saves keys to scoped DB secrets
1291
+ * GET /_agent-native/application-state?keys=a,b,c — batched read of many keys
1289
1292
  * GET /_agent-native/application-state/:key — read application state
1290
1293
  * PUT /_agent-native/application-state/:key — write application state
1291
1294
  * DELETE /_agent-native/application-state/:key — delete application state
@@ -3322,10 +3325,17 @@ export function createCoreRoutesPlugin(
3322
3325
  /* org module not present in this template */
3323
3326
  }
3324
3327
  }
3328
+ // One context for the whole sweep so the per-request secret memo is
3329
+ // shared, and one batched read per scope to fill it. Without this
3330
+ // every key pays its own four-scope waterfall.
3331
+ const requestContext = { userEmail, orgId };
3332
+ await runWithRequestContext(requestContext, () =>
3333
+ prefetchSecrets(allowedEnvKeyNames),
3334
+ );
3325
3335
  return Promise.all(
3326
3336
  envKeys.map(async (cfg) => {
3327
3337
  const configured = await runWithRequestContext(
3328
- { userEmail, orgId },
3338
+ requestContext,
3329
3339
  () => resolveSecret(cfg.key).then(Boolean),
3330
3340
  );
3331
3341
  return {
@@ -4245,7 +4255,14 @@ export function createCoreRoutesPlugin(
4245
4255
  (event.url?.pathname || "").replace(/^\/+/, "").split("/")[0] ||
4246
4256
  "";
4247
4257
  // Skip — compose handler above already handled it
4248
- if (key === "compose" || key === "") return;
4258
+ if (key === "compose") return;
4259
+ // Collection root: `GET ?keys=a,b,c` batches many single-key reads
4260
+ // into one request (and one identity resolution) — the chat rail
4261
+ // alone reads ~6 keys on every mount.
4262
+ if (key === "") {
4263
+ if (getMethod(event) === "GET") return getStateMany(event);
4264
+ return;
4265
+ }
4249
4266
  if (event.context) {
4250
4267
  event.context.params = { ...event.context.params, key };
4251
4268
  }
@@ -1276,11 +1276,50 @@ export async function deleteBuilderCredentials(
1276
1276
  // ANTHROPIC_API_KEY, but Builder identity keys keep the stricter scoped policy.
1277
1277
  // ---------------------------------------------------------------------------
1278
1278
 
1279
+ /**
1280
+ * Warm this request's secret memo for many keys with one read per scope.
1281
+ *
1282
+ * `resolveSecret` walks four scopes per key, so a status endpoint asking about
1283
+ * a dozen keys costs ~50 round trips against a remote database. Reading each
1284
+ * scope once for the whole key set collapses that to four, and the subsequent
1285
+ * `resolveSecret` calls answer from the per-request memo — same precedence,
1286
+ * same identity scoping, no new cache to invalidate.
1287
+ *
1288
+ * Best-effort on purpose: `readAppSecrets` only memoizes keys a statement
1289
+ * actually covered, so a failure here leaves each key's own lookup to run and
1290
+ * report the failure. It must never turn an unreadable store into "not set".
1291
+ */
1292
+ export async function prefetchSecrets(keys: readonly string[]): Promise<void> {
1293
+ const email = getRequestUserEmail();
1294
+ if (!email || keys.length === 0) return;
1295
+ const { readAppSecrets } = await import("../secrets/storage.js");
1296
+ const orgId =
1297
+ getRequestOrgId() || (await resolveOrgIdForRequestEmail(email)).orgId;
1298
+ const scopes: Array<{
1299
+ scope: "user" | "org" | "workspace";
1300
+ scopeId: string;
1301
+ }> = [
1302
+ { scope: "user", scopeId: email },
1303
+ ...(orgId
1304
+ ? ([
1305
+ { scope: "org", scopeId: orgId },
1306
+ { scope: "workspace", scopeId: orgId },
1307
+ ] as const)
1308
+ : []),
1309
+ { scope: "workspace", scopeId: `solo:${email}` },
1310
+ ];
1311
+ await Promise.all(
1312
+ scopes.map((s) => readAppSecrets({ keys, ...s }).catch(() => undefined)),
1313
+ );
1314
+ }
1315
+
1279
1316
  /**
1280
1317
  * Resolve a request-scoped secret. Reads from `app_secrets` first (current
1281
1318
  * user override, active org, workspace row for that org, then the solo
1282
1319
  * workspace row); falls back to `process.env` only when the deploy fallback
1283
1320
  * policy allows it.
1321
+ *
1322
+ * Resolving several keys in one request? Call `prefetchSecrets` first.
1284
1323
  */
1285
1324
  export async function resolveSecret(key: string): Promise<string | null> {
1286
1325
  const resolved = await resolveSecretDetailed(key);
@@ -1307,6 +1346,7 @@ export async function resolveSecretDetailed(
1307
1346
  if (email) {
1308
1347
  try {
1309
1348
  const { readAppSecret } = await import("../secrets/storage.js");
1349
+
1310
1350
  // Per-user override first.
1311
1351
  const userSecret = await readAppSecret({
1312
1352
  key,
@@ -1333,14 +1373,25 @@ export async function resolveSecretDetailed(
1333
1373
  orgId = resolved.orgId;
1334
1374
  }
1335
1375
 
1376
+ if (lookupFailed) {
1377
+ return { value: null, lookupFailed: true, cause };
1378
+ }
1379
+
1336
1380
  if (orgId) {
1381
+ // These rows are independent once the org is known, so read them in
1382
+ // parallel while still applying precedence below in the same order.
1383
+ const [orgRead, workspaceRead] = await Promise.allSettled([
1384
+ readAppSecret({ key, scope: "org", scopeId: orgId }),
1385
+ readAppSecret({ key, scope: "workspace", scopeId: orgId }),
1386
+ ]);
1387
+ const unwrap = <T>(settled: PromiseSettledResult<T>): T => {
1388
+ if (settled.status === "rejected") throw settled.reason;
1389
+ return settled.value;
1390
+ };
1391
+
1337
1392
  // Fall back to the active org's shared row, when present. Builder
1338
1393
  // Connect uses this first-class org scope.
1339
- const orgSecret = await readAppSecret({
1340
- key,
1341
- scope: "org",
1342
- scopeId: orgId,
1343
- });
1394
+ const orgSecret = unwrap(orgRead);
1344
1395
  if (orgSecret?.value) {
1345
1396
  if (traceLookup) {
1346
1397
  console.log(
@@ -1353,11 +1404,7 @@ export async function resolveSecretDetailed(
1353
1404
  // Registered secrets historically used "workspace" scope for
1354
1405
  // org-shared configuration. Keep reading it so Settings status and
1355
1406
  // runtime resolution agree.
1356
- const workspaceSecret = await readAppSecret({
1357
- key,
1358
- scope: "workspace",
1359
- scopeId: orgId,
1360
- });
1407
+ const workspaceSecret = unwrap(workspaceRead);
1361
1408
  if (workspaceSecret?.value) {
1362
1409
  if (traceLookup) {
1363
1410
  console.log(
@@ -1368,10 +1415,6 @@ export async function resolveSecretDetailed(
1368
1415
  }
1369
1416
  }
1370
1417
 
1371
- if (lookupFailed) {
1372
- return { value: null, lookupFailed: true, cause };
1373
- }
1374
-
1375
1418
  // Solo-workspace fallback: always checked, even when an org id was found
1376
1419
  // above. A secret written before the user joined/created an org lives
1377
1420
  // here, and must not become unreachable once that org exists. It stays
@@ -367,6 +367,7 @@ export function installHttpResponseTelemetryHooks(nitroApp: any): void {
367
367
  );
368
368
  }
369
369
  if (state.db.operationCount > 0) {
370
+ appendServerTiming(response, event, "db-ops", state.db.operationCount);
370
371
  appendServerTiming(response, event, "db", state.db.operationWallMs);
371
372
  appendServerTiming(
372
373
  response,
@@ -442,6 +442,12 @@ export class AppSyncState {
442
442
  private lastExtensionsUpdatedAt: string | number | undefined;
443
443
  private lastExtensionMarkerTs = 0;
444
444
  private lastActionMarkerTs = 0;
445
+ /**
446
+ * Set by the seed when a durable action marker predates this process, so the
447
+ * first `doCheckExternalDbChanges` reads the marker rows even though the
448
+ * MAX(updated_at) probe cannot distinguish that row from the seeded max.
449
+ */
450
+ private replayActionMarkerOnce = false;
445
451
 
446
452
  /**
447
453
  * Tracks the latest updated_at seen on the `__screen_refresh__` key.
@@ -1355,6 +1361,7 @@ export class AppSyncState {
1355
1361
  // performed by a separate action process. Do not baseline past an existing
1356
1362
  // marker on cold start, or the first poll after the action will miss it.
1357
1363
  this.lastActionMarkerTs = 0;
1364
+ this.replayActionMarkerOnce = actionMarkerTs > 0;
1358
1365
  this.lastScreenRefreshTs = refreshTs;
1359
1366
  this.lastScreenRefreshTsBySession.clear();
1360
1367
  for (const row of refreshResult.rows) {
@@ -1416,28 +1423,54 @@ export class AppSyncState {
1416
1423
  // concurrently to shave stacked latency; results are still processed in
1417
1424
  // the original sequential order, and conditional follow-up queries stay
1418
1425
  // sequential within their branch.
1426
+ //
1427
+ // `lastAppStateTs` is maintained as MAX(updated_at) over the whole
1428
+ // `application_state` table (seeded in `seedVersionFromDb`, advanced by
1429
+ // the scan below over every key, not just the ones that emit events).
1430
+ // The application-state row scan and screen/extension marker reads only
1431
+ // report rows strictly newer than a watermark that is itself <=
1432
+ // `lastAppStateTs`, so an unadvanced MAX proves those reads are no-ops.
1433
+ // The action marker keeps its own max probe because its watermark must
1434
+ // remain independent under cross-process clock skew.
1419
1435
  const [
1420
- appResult,
1436
+ appStateMaxTs,
1421
1437
  actionMarkerTs,
1422
- refreshResult,
1423
- extensionMarkerTs,
1424
1438
  settingsTs,
1425
1439
  extensionsMaxUpdatedAt,
1426
1440
  ] = await Promise.all([
1427
- db.execute({
1428
- sql: "SELECT session_id, key, updated_at FROM application_state WHERE updated_at > ? ORDER BY updated_at ASC",
1429
- args: [this.lastAppStateTs],
1430
- }),
1441
+ readMaxUpdatedAt(db, "application_state"),
1431
1442
  readActionMarkerMaxUpdatedAt(db),
1432
- db.execute({
1433
- sql: "SELECT session_id, updated_at, value FROM application_state WHERE key = ?",
1434
- args: [SCREEN_REFRESH_KEY],
1435
- }),
1436
- readExtensionMarkerMaxUpdatedAt(db),
1437
1443
  readMaxUpdatedAt(db, "settings"),
1438
1444
  readMaxUpdatedAtRaw(db, "tools"),
1439
1445
  ]);
1440
1446
 
1447
+ // The seed deliberately rewinds `lastActionMarkerTs` to 0 so a marker
1448
+ // written before this process booted still reaches the first poll. That
1449
+ // replay is invisible to the MAX probe (the row is already counted in the
1450
+ // seeded max), so it gets one forced full read.
1451
+ const replayActionMarker = this.replayActionMarkerOnce;
1452
+ this.replayActionMarkerOnce = false;
1453
+ const appStateChanged =
1454
+ appStateMaxTs > this.lastAppStateTs || replayActionMarker;
1455
+
1456
+ const [appResult, refreshResult, extensionMarkerTs] = appStateChanged
1457
+ ? await Promise.all([
1458
+ db.execute({
1459
+ sql: "SELECT session_id, key, updated_at FROM application_state WHERE updated_at > ? ORDER BY updated_at ASC",
1460
+ args: [this.lastAppStateTs],
1461
+ }),
1462
+ db.execute({
1463
+ sql: "SELECT session_id, updated_at, value FROM application_state WHERE key = ?",
1464
+ args: [SCREEN_REFRESH_KEY],
1465
+ }),
1466
+ readExtensionMarkerMaxUpdatedAt(db),
1467
+ ])
1468
+ : ([
1469
+ { rows: [] as Record<string, unknown>[] },
1470
+ null,
1471
+ this.lastExtensionMarkerTs,
1472
+ ] as const);
1473
+
1441
1474
  // Check application_state for external writes. Preserve the changed key so
1442
1475
  // clients can invalidate one-shot command queries (`navigate`, `__set_url__`)
1443
1476
  // only when those command rows actually change; noisy keys such as
@@ -1497,54 +1530,59 @@ export class AppSyncState {
1497
1530
  // tool writes to application_state under a well-known key; when its
1498
1531
  // updated_at bumps, emit a distinct event so the client invalidates
1499
1532
  // all queries (not just the ones matching its default queryKey prefix).
1500
- const refreshTs = refreshResult.rows.reduce(
1501
- (max, row) => Math.max(max, timestampValue(row.updated_at)),
1502
- 0,
1503
- );
1504
- if (!this.screenRefreshInitialized) {
1505
- this.lastScreenRefreshTs = refreshTs;
1506
- for (const row of refreshResult.rows) {
1507
- if (typeof row.session_id === "string") {
1508
- this.lastScreenRefreshTsBySession.set(
1509
- row.session_id,
1510
- timestampValue(row.updated_at),
1511
- );
1512
- }
1513
- }
1514
- this.screenRefreshInitialized = true;
1515
- } else if (refreshTs > this.lastScreenRefreshTs) {
1516
- // Emit a per-user event only for the session(s) whose row actually
1517
- // advanced, scoped with `owner` so canSeeChangeForUser delivers it only
1518
- // to that user — not every authenticated poller.
1519
- for (const row of refreshResult.rows) {
1520
- const owner =
1521
- typeof row.session_id === "string" ? row.session_id : undefined;
1522
- if (!owner) continue;
1523
- const rowTs = timestampValue(row.updated_at);
1524
- if (rowTs <= (this.lastScreenRefreshTsBySession.get(owner) ?? 0)) {
1525
- continue;
1533
+ // `refreshResult` is null exactly when the MAX probe proved no
1534
+ // application_state row moved, which is also exactly when this block
1535
+ // could not emit anything.
1536
+ if (refreshResult) {
1537
+ const refreshTs = refreshResult.rows.reduce(
1538
+ (max, row) => Math.max(max, timestampValue(row.updated_at)),
1539
+ 0,
1540
+ );
1541
+ if (!this.screenRefreshInitialized) {
1542
+ this.lastScreenRefreshTs = refreshTs;
1543
+ for (const row of refreshResult.rows) {
1544
+ if (typeof row.session_id === "string") {
1545
+ this.lastScreenRefreshTsBySession.set(
1546
+ row.session_id,
1547
+ timestampValue(row.updated_at),
1548
+ );
1549
+ }
1526
1550
  }
1527
- let scope: string | undefined;
1528
- try {
1529
- const raw = row.value;
1530
- if (typeof raw === "string") {
1531
- const parsed = JSON.parse(raw);
1532
- if (typeof parsed?.scope === "string") scope = parsed.scope;
1551
+ this.screenRefreshInitialized = true;
1552
+ } else if (refreshTs > this.lastScreenRefreshTs) {
1553
+ // Emit a per-user event only for the session(s) whose row actually
1554
+ // advanced, scoped with `owner` so canSeeChangeForUser delivers it only
1555
+ // to that user — not every authenticated poller.
1556
+ for (const row of refreshResult.rows) {
1557
+ const owner =
1558
+ typeof row.session_id === "string" ? row.session_id : undefined;
1559
+ if (!owner) continue;
1560
+ const rowTs = timestampValue(row.updated_at);
1561
+ if (rowTs <= (this.lastScreenRefreshTsBySession.get(owner) ?? 0)) {
1562
+ continue;
1533
1563
  }
1534
- } catch {}
1535
- this.recordChange(
1536
- {
1537
- source: "screen-refresh",
1538
- type: "change",
1539
- key: SCREEN_REFRESH_KEY,
1540
- owner,
1541
- ...(scope ? { scope } : {}),
1542
- },
1543
- { dedupeKey: `screen-refresh|${rowTs}` },
1544
- );
1545
- this.lastScreenRefreshTsBySession.set(owner, rowTs);
1564
+ let scope: string | undefined;
1565
+ try {
1566
+ const raw = row.value;
1567
+ if (typeof raw === "string") {
1568
+ const parsed = JSON.parse(raw);
1569
+ if (typeof parsed?.scope === "string") scope = parsed.scope;
1570
+ }
1571
+ } catch {}
1572
+ this.recordChange(
1573
+ {
1574
+ source: "screen-refresh",
1575
+ type: "change",
1576
+ key: SCREEN_REFRESH_KEY,
1577
+ owner,
1578
+ ...(scope ? { scope } : {}),
1579
+ },
1580
+ { dedupeKey: `screen-refresh|${rowTs}` },
1581
+ );
1582
+ this.lastScreenRefreshTsBySession.set(owner, rowTs);
1583
+ }
1584
+ this.lastScreenRefreshTs = refreshTs;
1546
1585
  }
1547
- this.lastScreenRefreshTs = refreshTs;
1548
1586
  }
1549
1587
 
1550
1588
  // Extension mutations write a durable marker row so delete and hide/unhide
@@ -22,6 +22,7 @@ import {
22
22
  import { getOrgContext } from "../org/context.js";
23
23
  import { getSession } from "./auth.js";
24
24
  import {
25
+ prefetchSecrets,
25
26
  resolveHasCompleteBuilderConnection,
26
27
  resolveSecret,
27
28
  } from "./credential-provider.js";
@@ -74,10 +75,14 @@ export function createVoiceProvidersStatusHandler() {
74
75
  async function hasKey(key: string): Promise<boolean> {
75
76
  try {
76
77
  if (key === "GOOGLE_APPLICATION_CREDENTIALS") {
77
- const resolved = await resolveGoogleRealtimeCredentials({
78
- userEmail: session?.email,
79
- orgId: orgCtx?.orgId ?? undefined,
80
- });
78
+ // Same identity, passed explicitly — the context is here only so this
79
+ // scope sweep shares the prefetched per-request memo below.
80
+ const resolved = await withRequestContext(() =>
81
+ resolveGoogleRealtimeCredentials({
82
+ userEmail: session?.email,
83
+ orgId: orgCtx?.orgId ?? undefined,
84
+ }),
85
+ );
81
86
  return typeof resolved === "string" && resolved.length > 0;
82
87
  }
83
88
  const resolved = await withRequestContext(() => resolveSecret(key));
@@ -87,6 +92,16 @@ export function createVoiceProvidersStatusHandler() {
87
92
  }
88
93
  }
89
94
 
95
+ // One read per scope for every key below, instead of one per key per scope.
96
+ await withRequestContext(() =>
97
+ prefetchSecrets([
98
+ "GEMINI_API_KEY",
99
+ "OPENAI_API_KEY",
100
+ "GROQ_API_KEY",
101
+ "GOOGLE_APPLICATION_CREDENTIALS",
102
+ ]),
103
+ );
104
+
90
105
  let builder = false;
91
106
  try {
92
107
  builder =
@@ -86,13 +86,16 @@ export function createWorkspaceFilesTool(): Record<string, ActionEntry> {
86
86
  'MIME type for new files. Default: "text/plain". Use "application/json" for JSON, "text/markdown" for Markdown.',
87
87
  },
88
88
  offset: {
89
- type: "number",
89
+ type: "integer",
90
+ minimum: 0,
90
91
  description:
91
- "Character offset to start reading from (for paging large files). Default: 0.",
92
+ "Non-negative character offset to start reading from (for paging large files). Default: 0.",
92
93
  },
93
94
  maxChars: {
94
- type: "number",
95
- description: `Maximum characters to return when reading. Default: ${DEFAULT_READ_CHARS}. Max: ${MAX_READ_CHARS}.`,
95
+ type: "integer",
96
+ minimum: 1,
97
+ maximum: MAX_READ_CHARS,
98
+ description: `Positive maximum characters to return when reading. Default: ${DEFAULT_READ_CHARS}. Max: ${MAX_READ_CHARS}.`,
96
99
  },
97
100
  pattern: {
98
101
  type: "string",
@@ -168,16 +171,19 @@ export function createWorkspaceFilesTool(): Record<string, ActionEntry> {
168
171
  if (!path) return "Error: path is required for read.";
169
172
  const rawOffset = Number(args.offset);
170
173
  const offset =
171
- Number.isFinite(rawOffset) && rawOffset > 0 ? rawOffset : 0;
174
+ Number.isFinite(rawOffset) && rawOffset > 0
175
+ ? Math.floor(rawOffset)
176
+ : 0;
172
177
  const rawMax = Number(args.maxChars);
173
178
  const maxChars =
174
179
  Number.isFinite(rawMax) && rawMax > 0
175
- ? Math.min(rawMax, MAX_READ_CHARS)
180
+ ? Math.min(Math.max(1, Math.floor(rawMax)), MAX_READ_CHARS)
176
181
  : DEFAULT_READ_CHARS;
177
182
 
183
+ // The sentinel character distinguishes an exact page from a truncated one.
178
184
  const file = await readWorkspaceFile(scope, path, {
179
185
  offset,
180
- maxChars,
186
+ maxChars: maxChars + 1,
181
187
  });
182
188
  if (!file) {
183
189
  return JSON.stringify({
@@ -186,19 +192,20 @@ export function createWorkspaceFilesTool(): Record<string, ActionEntry> {
186
192
  });
187
193
  }
188
194
 
189
- const truncated = file.content.length >= maxChars;
195
+ const truncated = file.content.length > maxChars;
196
+ const content = file.content.slice(0, maxChars);
190
197
  return JSON.stringify({
191
198
  ok: true,
192
199
  path: file.path,
193
200
  contentType: file.contentType,
194
201
  sizeBytes: file.sizeBytes,
195
202
  updatedAt: file.updatedAt,
196
- content: file.content,
203
+ content,
197
204
  ...(truncated
198
205
  ? {
199
206
  truncated: true,
200
- nextOffset: offset + file.content.length,
201
- hint: `File has more content. Call again with offset: ${offset + file.content.length}`,
207
+ nextOffset: offset + content.length,
208
+ hint: `File has more content. Call again with offset: ${offset + content.length}`,
202
209
  }
203
210
  : {}),
204
211
  });