@agent-native/core 0.7.7 → 0.7.11

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 (338) hide show
  1. package/dist/agent/engine/ai-sdk-engine.d.ts +14 -2
  2. package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
  3. package/dist/agent/engine/ai-sdk-engine.js +70 -54
  4. package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
  5. package/dist/agent/engine/anthropic-engine.d.ts +1 -6
  6. package/dist/agent/engine/anthropic-engine.d.ts.map +1 -1
  7. package/dist/agent/engine/anthropic-engine.js +3 -14
  8. package/dist/agent/engine/anthropic-engine.js.map +1 -1
  9. package/dist/agent/engine/builtin.d.ts.map +1 -1
  10. package/dist/agent/engine/builtin.js +3 -0
  11. package/dist/agent/engine/builtin.js.map +1 -1
  12. package/dist/agent/engine/translate-ai-sdk.d.ts +35 -10
  13. package/dist/agent/engine/translate-ai-sdk.d.ts.map +1 -1
  14. package/dist/agent/engine/translate-ai-sdk.js +190 -91
  15. package/dist/agent/engine/translate-ai-sdk.js.map +1 -1
  16. package/dist/agent/engine/types.d.ts +10 -1
  17. package/dist/agent/engine/types.d.ts.map +1 -1
  18. package/dist/agent/production-agent.d.ts +15 -1
  19. package/dist/agent/production-agent.d.ts.map +1 -1
  20. package/dist/agent/production-agent.js +78 -21
  21. package/dist/agent/production-agent.js.map +1 -1
  22. package/dist/agent/thread-data-builder.js +1 -1
  23. package/dist/agent/thread-data-builder.js.map +1 -1
  24. package/dist/agent/types.d.ts +4 -0
  25. package/dist/agent/types.d.ts.map +1 -1
  26. package/dist/application-state/script-helpers.d.ts +12 -5
  27. package/dist/application-state/script-helpers.d.ts.map +1 -1
  28. package/dist/application-state/script-helpers.js +41 -20
  29. package/dist/application-state/script-helpers.js.map +1 -1
  30. package/dist/catalog.json +15 -0
  31. package/dist/chat-threads/store.d.ts.map +1 -1
  32. package/dist/chat-threads/store.js +7 -5
  33. package/dist/chat-threads/store.js.map +1 -1
  34. package/dist/checkpoints/index.d.ts +3 -0
  35. package/dist/checkpoints/index.d.ts.map +1 -0
  36. package/dist/checkpoints/index.js +3 -0
  37. package/dist/checkpoints/index.js.map +1 -0
  38. package/dist/checkpoints/service.d.ts +6 -0
  39. package/dist/checkpoints/service.d.ts.map +1 -0
  40. package/dist/checkpoints/service.js +107 -0
  41. package/dist/checkpoints/service.js.map +1 -0
  42. package/dist/checkpoints/store.d.ts +27 -0
  43. package/dist/checkpoints/store.d.ts.map +1 -0
  44. package/dist/checkpoints/store.js +92 -0
  45. package/dist/checkpoints/store.js.map +1 -0
  46. package/dist/cli/create.d.ts.map +1 -1
  47. package/dist/cli/create.js +85 -1
  48. package/dist/cli/create.js.map +1 -1
  49. package/dist/cli/index.js +46 -3
  50. package/dist/cli/index.js.map +1 -1
  51. package/dist/cli/templates-meta.d.ts.map +1 -1
  52. package/dist/cli/templates-meta.js +33 -0
  53. package/dist/cli/templates-meta.js.map +1 -1
  54. package/dist/client/AgentPanel.d.ts.map +1 -1
  55. package/dist/client/AgentPanel.js +3 -1
  56. package/dist/client/AgentPanel.js.map +1 -1
  57. package/dist/client/AssistantChat.d.ts +15 -0
  58. package/dist/client/AssistantChat.d.ts.map +1 -1
  59. package/dist/client/AssistantChat.js +145 -67
  60. package/dist/client/AssistantChat.js.map +1 -1
  61. package/dist/client/CommandMenu.d.ts.map +1 -1
  62. package/dist/client/CommandMenu.js +9 -5
  63. package/dist/client/CommandMenu.js.map +1 -1
  64. package/dist/client/ConnectBuilderCard.js +1 -1
  65. package/dist/client/ConnectBuilderCard.js.map +1 -1
  66. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  67. package/dist/client/MultiTabAssistantChat.js +78 -4
  68. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  69. package/dist/client/agent-chat-adapter.d.ts +6 -0
  70. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  71. package/dist/client/agent-chat-adapter.js +4 -0
  72. package/dist/client/agent-chat-adapter.js.map +1 -1
  73. package/dist/client/composer/TiptapComposer.d.ts +12 -1
  74. package/dist/client/composer/TiptapComposer.d.ts.map +1 -1
  75. package/dist/client/composer/TiptapComposer.js +71 -3
  76. package/dist/client/composer/TiptapComposer.js.map +1 -1
  77. package/dist/client/notifications/NotificationsBell.d.ts +23 -0
  78. package/dist/client/notifications/NotificationsBell.d.ts.map +1 -0
  79. package/dist/client/notifications/NotificationsBell.js +165 -0
  80. package/dist/client/notifications/NotificationsBell.js.map +1 -0
  81. package/dist/client/notifications/index.d.ts +2 -0
  82. package/dist/client/notifications/index.d.ts.map +1 -0
  83. package/dist/client/notifications/index.js +2 -0
  84. package/dist/client/notifications/index.js.map +1 -0
  85. package/dist/client/onboarding/OnboardingPanel.js +6 -3
  86. package/dist/client/onboarding/OnboardingPanel.js.map +1 -1
  87. package/dist/client/progress/RunsTray.d.ts +18 -0
  88. package/dist/client/progress/RunsTray.d.ts.map +1 -0
  89. package/dist/client/progress/RunsTray.js +70 -0
  90. package/dist/client/progress/RunsTray.js.map +1 -0
  91. package/dist/client/progress/index.d.ts +2 -0
  92. package/dist/client/progress/index.d.ts.map +1 -0
  93. package/dist/client/progress/index.js +2 -0
  94. package/dist/client/progress/index.js.map +1 -0
  95. package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
  96. package/dist/client/resources/ResourcesPanel.js +19 -4
  97. package/dist/client/resources/ResourcesPanel.js.map +1 -1
  98. package/dist/client/settings/AutomationsSection.d.ts +2 -0
  99. package/dist/client/settings/AutomationsSection.d.ts.map +1 -0
  100. package/dist/client/settings/AutomationsSection.js +214 -0
  101. package/dist/client/settings/AutomationsSection.js.map +1 -0
  102. package/dist/client/settings/ComingSoonSection.d.ts.map +1 -1
  103. package/dist/client/settings/ComingSoonSection.js +2 -1
  104. package/dist/client/settings/ComingSoonSection.js.map +1 -1
  105. package/dist/client/settings/LLMSection.d.ts.map +1 -1
  106. package/dist/client/settings/LLMSection.js +137 -10
  107. package/dist/client/settings/LLMSection.js.map +1 -1
  108. package/dist/client/settings/SecretsSection.d.ts.map +1 -1
  109. package/dist/client/settings/SecretsSection.js +122 -3
  110. package/dist/client/settings/SecretsSection.js.map +1 -1
  111. package/dist/client/settings/SettingsPanel.d.ts.map +1 -1
  112. package/dist/client/settings/SettingsPanel.js +140 -11
  113. package/dist/client/settings/SettingsPanel.js.map +1 -1
  114. package/dist/client/settings/VoiceTranscriptionSection.d.ts.map +1 -1
  115. package/dist/client/settings/VoiceTranscriptionSection.js +2 -2
  116. package/dist/client/settings/VoiceTranscriptionSection.js.map +1 -1
  117. package/dist/client/use-pausing-interval.d.ts +11 -0
  118. package/dist/client/use-pausing-interval.d.ts.map +1 -0
  119. package/dist/client/use-pausing-interval.js +49 -0
  120. package/dist/client/use-pausing-interval.js.map +1 -0
  121. package/dist/db/client.d.ts +26 -0
  122. package/dist/db/client.d.ts.map +1 -1
  123. package/dist/db/client.js +84 -2
  124. package/dist/db/client.js.map +1 -1
  125. package/dist/db/drizzle-config.d.ts +33 -0
  126. package/dist/db/drizzle-config.d.ts.map +1 -0
  127. package/dist/db/drizzle-config.js +132 -0
  128. package/dist/db/drizzle-config.js.map +1 -0
  129. package/dist/db/migrations.d.ts.map +1 -1
  130. package/dist/db/migrations.js +11 -6
  131. package/dist/db/migrations.js.map +1 -1
  132. package/dist/deploy/build.js +2 -1
  133. package/dist/deploy/build.js.map +1 -1
  134. package/dist/event-bus/bus.d.ts +20 -0
  135. package/dist/event-bus/bus.d.ts.map +1 -0
  136. package/dist/event-bus/bus.js +108 -0
  137. package/dist/event-bus/bus.js.map +1 -0
  138. package/dist/event-bus/index.d.ts +4 -0
  139. package/dist/event-bus/index.d.ts.map +1 -0
  140. package/dist/event-bus/index.js +3 -0
  141. package/dist/event-bus/index.js.map +1 -0
  142. package/dist/event-bus/registry.d.ts +22 -0
  143. package/dist/event-bus/registry.d.ts.map +1 -0
  144. package/dist/event-bus/registry.js +63 -0
  145. package/dist/event-bus/registry.js.map +1 -0
  146. package/dist/event-bus/types.d.ts +27 -0
  147. package/dist/event-bus/types.d.ts.map +1 -0
  148. package/dist/event-bus/types.js +2 -0
  149. package/dist/event-bus/types.js.map +1 -0
  150. package/dist/integrations/config-store.d.ts.map +1 -1
  151. package/dist/integrations/config-store.js +16 -12
  152. package/dist/integrations/config-store.js.map +1 -1
  153. package/dist/integrations/google-docs-poller.d.ts.map +1 -1
  154. package/dist/integrations/google-docs-poller.js +5 -1
  155. package/dist/integrations/google-docs-poller.js.map +1 -1
  156. package/dist/jobs/scheduler.d.ts.map +1 -1
  157. package/dist/jobs/scheduler.js +7 -3
  158. package/dist/jobs/scheduler.js.map +1 -1
  159. package/dist/notifications/actions.d.ts +10 -0
  160. package/dist/notifications/actions.d.ts.map +1 -0
  161. package/dist/notifications/actions.js +114 -0
  162. package/dist/notifications/actions.js.map +1 -0
  163. package/dist/notifications/channels.d.ts +15 -0
  164. package/dist/notifications/channels.d.ts.map +1 -0
  165. package/dist/notifications/channels.js +97 -0
  166. package/dist/notifications/channels.js.map +1 -0
  167. package/dist/notifications/index.d.ts +4 -0
  168. package/dist/notifications/index.d.ts.map +1 -0
  169. package/dist/notifications/index.js +3 -0
  170. package/dist/notifications/index.js.map +1 -0
  171. package/dist/notifications/registry.d.ts +9 -0
  172. package/dist/notifications/registry.d.ts.map +1 -0
  173. package/dist/notifications/registry.js +146 -0
  174. package/dist/notifications/registry.js.map +1 -0
  175. package/dist/notifications/routes.d.ts +34 -0
  176. package/dist/notifications/routes.d.ts.map +1 -0
  177. package/dist/notifications/routes.js +69 -0
  178. package/dist/notifications/routes.js.map +1 -0
  179. package/dist/notifications/store.d.ts +25 -0
  180. package/dist/notifications/store.d.ts.map +1 -0
  181. package/dist/notifications/store.js +158 -0
  182. package/dist/notifications/store.js.map +1 -0
  183. package/dist/notifications/types.d.ts +43 -0
  184. package/dist/notifications/types.d.ts.map +1 -0
  185. package/dist/notifications/types.js +2 -0
  186. package/dist/notifications/types.js.map +1 -0
  187. package/dist/org/handlers.d.ts.map +1 -1
  188. package/dist/org/handlers.js +7 -26
  189. package/dist/org/handlers.js.map +1 -1
  190. package/dist/progress/actions.d.ts +8 -0
  191. package/dist/progress/actions.d.ts.map +1 -0
  192. package/dist/progress/actions.js +158 -0
  193. package/dist/progress/actions.js.map +1 -0
  194. package/dist/progress/index.d.ts +3 -0
  195. package/dist/progress/index.d.ts.map +1 -0
  196. package/dist/progress/index.js +2 -0
  197. package/dist/progress/index.js.map +1 -0
  198. package/dist/progress/registry.d.ts +22 -0
  199. package/dist/progress/registry.d.ts.map +1 -0
  200. package/dist/progress/registry.js +98 -0
  201. package/dist/progress/registry.js.map +1 -0
  202. package/dist/progress/routes.d.ts +21 -0
  203. package/dist/progress/routes.d.ts.map +1 -0
  204. package/dist/progress/routes.js +59 -0
  205. package/dist/progress/routes.js.map +1 -0
  206. package/dist/progress/store.d.ts +7 -0
  207. package/dist/progress/store.d.ts.map +1 -0
  208. package/dist/progress/store.js +195 -0
  209. package/dist/progress/store.js.map +1 -0
  210. package/dist/progress/types.d.ts +49 -0
  211. package/dist/progress/types.d.ts.map +1 -0
  212. package/dist/progress/types.js +7 -0
  213. package/dist/progress/types.js.map +1 -0
  214. package/dist/resources/store.d.ts.map +1 -1
  215. package/dist/resources/store.js +19 -15
  216. package/dist/resources/store.js.map +1 -1
  217. package/dist/secrets/index.d.ts +3 -2
  218. package/dist/secrets/index.d.ts.map +1 -1
  219. package/dist/secrets/index.js +3 -2
  220. package/dist/secrets/index.js.map +1 -1
  221. package/dist/secrets/routes.d.ts +41 -2
  222. package/dist/secrets/routes.d.ts.map +1 -1
  223. package/dist/secrets/routes.js +167 -1
  224. package/dist/secrets/routes.js.map +1 -1
  225. package/dist/secrets/schema.d.ts +39 -1
  226. package/dist/secrets/schema.d.ts.map +1 -1
  227. package/dist/secrets/schema.js +6 -0
  228. package/dist/secrets/schema.js.map +1 -1
  229. package/dist/secrets/storage.d.ts +26 -0
  230. package/dist/secrets/storage.d.ts.map +1 -1
  231. package/dist/secrets/storage.js +111 -5
  232. package/dist/secrets/storage.js.map +1 -1
  233. package/dist/secrets/substitution.d.ts +39 -0
  234. package/dist/secrets/substitution.d.ts.map +1 -0
  235. package/dist/secrets/substitution.js +93 -0
  236. package/dist/secrets/substitution.js.map +1 -0
  237. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  238. package/dist/server/agent-chat-plugin.js +1657 -1410
  239. package/dist/server/agent-chat-plugin.js.map +1 -1
  240. package/dist/server/auth.d.ts +11 -0
  241. package/dist/server/auth.d.ts.map +1 -1
  242. package/dist/server/auth.js +74 -21
  243. package/dist/server/auth.js.map +1 -1
  244. package/dist/server/better-auth-instance.d.ts.map +1 -1
  245. package/dist/server/better-auth-instance.js +34 -16
  246. package/dist/server/better-auth-instance.js.map +1 -1
  247. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  248. package/dist/server/core-routes-plugin.js +115 -1
  249. package/dist/server/core-routes-plugin.js.map +1 -1
  250. package/dist/server/email-templates.d.ts +43 -0
  251. package/dist/server/email-templates.d.ts.map +1 -0
  252. package/dist/server/email-templates.js +86 -0
  253. package/dist/server/email-templates.js.map +1 -0
  254. package/dist/server/framework-request-handler.d.ts +15 -0
  255. package/dist/server/framework-request-handler.d.ts.map +1 -1
  256. package/dist/server/framework-request-handler.js +64 -1
  257. package/dist/server/framework-request-handler.js.map +1 -1
  258. package/dist/server/onboarding-html.d.ts +11 -0
  259. package/dist/server/onboarding-html.d.ts.map +1 -1
  260. package/dist/server/onboarding-html.js +275 -16
  261. package/dist/server/onboarding-html.js.map +1 -1
  262. package/dist/server/schema-prompt.d.ts.map +1 -1
  263. package/dist/server/schema-prompt.js +5 -0
  264. package/dist/server/schema-prompt.js.map +1 -1
  265. package/dist/shared/index.d.ts +1 -0
  266. package/dist/shared/index.d.ts.map +1 -1
  267. package/dist/shared/index.js +1 -0
  268. package/dist/shared/index.js.map +1 -1
  269. package/dist/shared/truncate.d.ts +8 -0
  270. package/dist/shared/truncate.d.ts.map +1 -0
  271. package/dist/shared/truncate.js +12 -0
  272. package/dist/shared/truncate.js.map +1 -0
  273. package/dist/templates/default/.agents/skills/agent-engines/SKILL.md +60 -4
  274. package/dist/templates/default/.agents/skills/notifications/SKILL.md +95 -0
  275. package/dist/templates/default/.agents/skills/progress/SKILL.md +97 -0
  276. package/dist/templates/default/AGENTS.md +12 -10
  277. package/dist/templates/default/package.json +10 -10
  278. package/dist/templates/workspace-core/package.json +5 -5
  279. package/dist/templates/workspace-root/package.json +1 -1
  280. package/dist/templates/workspace-root/tsconfig.base.json +1 -2
  281. package/dist/tools/fetch-tool.d.ts +22 -0
  282. package/dist/tools/fetch-tool.d.ts.map +1 -0
  283. package/dist/tools/fetch-tool.js +156 -0
  284. package/dist/tools/fetch-tool.js.map +1 -0
  285. package/dist/tracking/index.d.ts +4 -0
  286. package/dist/tracking/index.d.ts.map +1 -0
  287. package/dist/tracking/index.js +3 -0
  288. package/dist/tracking/index.js.map +1 -0
  289. package/dist/tracking/providers.d.ts +15 -0
  290. package/dist/tracking/providers.d.ts.map +1 -0
  291. package/dist/tracking/providers.js +195 -0
  292. package/dist/tracking/providers.js.map +1 -0
  293. package/dist/tracking/registry.d.ts +10 -0
  294. package/dist/tracking/registry.d.ts.map +1 -0
  295. package/dist/tracking/registry.js +75 -0
  296. package/dist/tracking/registry.js.map +1 -0
  297. package/dist/tracking/types.d.ts +13 -0
  298. package/dist/tracking/types.d.ts.map +1 -0
  299. package/dist/tracking/types.js +2 -0
  300. package/dist/tracking/types.js.map +1 -0
  301. package/dist/triggers/actions.d.ts +10 -0
  302. package/dist/triggers/actions.d.ts.map +1 -0
  303. package/dist/triggers/actions.js +277 -0
  304. package/dist/triggers/actions.js.map +1 -0
  305. package/dist/triggers/condition-evaluator.d.ts +15 -0
  306. package/dist/triggers/condition-evaluator.d.ts.map +1 -0
  307. package/dist/triggers/condition-evaluator.js +107 -0
  308. package/dist/triggers/condition-evaluator.js.map +1 -0
  309. package/dist/triggers/dispatcher.d.ts +32 -0
  310. package/dist/triggers/dispatcher.d.ts.map +1 -0
  311. package/dist/triggers/dispatcher.js +291 -0
  312. package/dist/triggers/dispatcher.js.map +1 -0
  313. package/dist/triggers/index.d.ts +5 -0
  314. package/dist/triggers/index.d.ts.map +1 -0
  315. package/dist/triggers/index.js +4 -0
  316. package/dist/triggers/index.js.map +1 -0
  317. package/dist/triggers/types.d.ts +35 -0
  318. package/dist/triggers/types.d.ts.map +1 -0
  319. package/dist/triggers/types.js +9 -0
  320. package/dist/triggers/types.js.map +1 -0
  321. package/dist/vite/client.d.ts.map +1 -1
  322. package/dist/vite/client.js +66 -16
  323. package/dist/vite/client.js.map +1 -1
  324. package/docs/content/automations.md +239 -0
  325. package/docs/content/multi-tenancy.md +88 -0
  326. package/docs/content/notifications.md +199 -0
  327. package/docs/content/progress.md +176 -0
  328. package/docs/content/tracking.md +168 -0
  329. package/package.json +54 -35
  330. package/src/templates/default/.agents/skills/agent-engines/SKILL.md +60 -4
  331. package/src/templates/default/.agents/skills/notifications/SKILL.md +95 -0
  332. package/src/templates/default/.agents/skills/progress/SKILL.md +97 -0
  333. package/src/templates/default/AGENTS.md +12 -10
  334. package/src/templates/default/package.json +10 -10
  335. package/src/templates/workspace-core/package.json +5 -5
  336. package/src/templates/workspace-root/package.json +1 -1
  337. package/src/templates/workspace-root/tsconfig.base.json +1 -2
  338. package/tsconfig.base.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import { runWithRequestContext, getRequestOrgId } from "./request-context.js";
2
2
  import { getSetting, putSetting } from "../settings/store.js";
3
- import { getH3App } from "./framework-request-handler.js";
3
+ import { getH3App, trackPluginInit } from "./framework-request-handler.js";
4
4
  import { createProductionAgentHandler, runAgentLoop, actionsToEngineTools, getActiveRunForThreadAsync, abortRun, subscribeToRun, } from "../agent/production-agent.js";
5
5
  import { resolveEngine, createAnthropicEngine } from "../agent/engine/index.js";
6
6
  import { McpClientManager, loadMcpConfig, autoDetectMcpConfig, mcpToolsToActionEntries, syncMcpActionEntries, mountMcpServersRoutes, mountMcpHubRoutes, buildMergedConfig, getHubStatus, isHubServeEnabled, } from "../mcp-client/index.js";
@@ -240,7 +240,7 @@ async function createDbScriptEntries() {
240
240
  },
241
241
  }, schemaMod.default, { readOnly: true }),
242
242
  "db-query": wrapCliScript({
243
- description: "Read from the app's SQL database. Runs a SELECT (or WITH/EXPLAIN/PRAGMA) against the app's own tables settings, application_state, and all template tables. Results are automatically scoped to the current user/org; DO NOT add `WHERE owner_email = ...` yourself. This queries the APP DATABASEnot any external data source.",
243
+ description: "Read from the app's own SQL database ONLY. Runs a SELECT against the app's internal tables (settings, application_state, template tables). Results are auto-scoped to the current user/org. IMPORTANT: This tool CANNOT access external data sources like BigQuery, HubSpot, Jira, GA4, etc. For those, use the appropriate template action (e.g. `bigquery` for warehouse tables, `ga4-report` for Google Analytics). If a table isn't in the app schema, don't try db-query use the data-source-specific action.",
244
244
  parameters: {
245
245
  type: "object",
246
246
  properties: {
@@ -262,7 +262,7 @@ async function createDbScriptEntries() {
262
262
  },
263
263
  }, queryMod.default, { readOnly: true }),
264
264
  "db-exec": wrapCliScript({
265
- description: "Write to the app's SQL database. Runs INSERT / UPDATE / DELETE against the app's own tables. Writes are automatically scoped to the current user/org, and `owner_email` / `org_id` are auto-injected on INSERT. Use this to update rows in the settings table (e.g. edit a dashboard config stored under `o:<orgId>:sql-dashboard-<id>`). This writes to the APP DATABASE — not any external data source.",
265
+ description: "Write to the app's own SQL database ONLY. Runs INSERT / UPDATE / DELETE against the app's internal tables. Writes are auto-scoped to the current user/org, and `owner_email` / `org_id` are auto-injected on INSERT. IMPORTANT: This tool CANNOT write to external data sources like BigQuery, HubSpot, etc. For external services, use the appropriate template action.",
266
266
  parameters: {
267
267
  type: "object",
268
268
  properties: {
@@ -1274,6 +1274,8 @@ ${lines.join("\n")}`;
1274
1274
 
1275
1275
  **Use these actions directly to accomplish tasks. Do NOT use \`db-schema\`, \`search-files\`, or \`shell\` to explore the app — these actions already connect to the correct database and services.**
1276
1276
 
1277
+ **For external data sources (BigQuery, HubSpot, Jira, GA4, etc.), use the data-source-specific action below — NOT \`db-query\`.** \`db-query\` only reaches the app's own internal database. If the user asks about tables not in the app schema, pick the matching action here.
1278
+
1277
1279
  Parameter notation: \`name*\` = required, \`name?\` = optional. Always pass the tool's parameters as a JSON object to the tool_use call — never via shell or string-concatenated CLI flags.
1278
1280
 
1279
1281
  ${lines.join("\n")}`;
@@ -1353,481 +1355,736 @@ function isLocalhost(event) {
1353
1355
  }
1354
1356
  }
1355
1357
  export function createAgentChatPlugin(options) {
1356
- return async (nitroApp) => {
1357
- // Wait for default framework plugins (auth, core-routes, integrations, ...)
1358
- // to finish mounting their middleware before we register our own. Without
1359
- // this, requests can race ahead of the bootstrap and hit the SSR catch-all.
1360
- const { awaitBootstrap } = await import("./framework-request-handler.js");
1361
- await awaitBootstrap(nitroApp);
1362
- // Reap phantom runs left over from the previous process (HMR restart,
1363
- // process crash, isolate eviction). Any run whose heartbeat is already
1364
- // stale by startup time had a dead producer; mark it errored so the
1365
- // next /runs/active check returns a terminal status and reconnecting
1366
- // clients don't spin on "Thinking...". Runs owned by OTHER live
1367
- // isolates are protected by their fresh heartbeats.
1368
- try {
1369
- const { reapAllStaleRuns } = await import("../agent/run-store.js");
1370
- const reaped = await reapAllStaleRuns();
1371
- if (reaped > 0) {
1372
- console.log(`[agent-chat] reaped ${reaped} stale run(s) on startup`);
1373
- }
1374
- }
1375
- catch {
1376
- // Best effort — don't block plugin init if SQL isn't ready yet.
1377
- }
1378
- const env = process.env.NODE_ENV;
1379
- // AGENT_MODE=production forces production agent constraints even in dev
1380
- const canToggle = (env === "development" || env === "test") &&
1381
- process.env.AGENT_MODE !== "production";
1382
- const routePath = options?.path ?? "/_agent-native/agent-chat";
1383
- // Mutable mode flag — persisted to the `settings` table so a user who
1384
- // toggles to "Production" stays in prod mode across server restarts.
1385
- // Hoisted here (before any tool-registry / handler closures are built)
1386
- // so every runtime decision point can close over it and see live changes
1387
- // when the user toggles the Environment dropdown.
1388
- const AGENT_MODE_SETTING_KEY = "agent-chat.mode";
1389
- let currentDevMode = canToggle;
1390
- if (canToggle) {
1358
+ return (nitroApp) => {
1359
+ // Nitro v3 calls plugins synchronously and doesn't await async return
1360
+ // values. We track the async init so the framework's readiness gate
1361
+ // holds /_agent-native requests until routes are registered.
1362
+ const initPromise = (async () => {
1363
+ const { awaitBootstrap } = await import("./framework-request-handler.js");
1364
+ await awaitBootstrap(nitroApp);
1365
+ // Reap phantom runs left over from the previous process (HMR restart,
1366
+ // process crash, isolate eviction). Any run whose heartbeat is already
1367
+ // stale by startup time had a dead producer; mark it errored so the
1368
+ // next /runs/active check returns a terminal status and reconnecting
1369
+ // clients don't spin on "Thinking...". Runs owned by OTHER live
1370
+ // isolates are protected by their fresh heartbeats.
1391
1371
  try {
1392
- const persisted = await getSetting(AGENT_MODE_SETTING_KEY);
1393
- if (persisted && typeof persisted.devMode === "boolean") {
1394
- currentDevMode = persisted.devMode;
1372
+ const { reapAllStaleRuns } = await import("../agent/run-store.js");
1373
+ const reaped = await reapAllStaleRuns();
1374
+ if (reaped > 0) {
1375
+ console.log(`[agent-chat] reaped ${reaped} stale run(s) on startup`);
1395
1376
  }
1396
1377
  }
1397
1378
  catch {
1398
- // Settings table may not be ready yet fall back to default.
1379
+ // Best effort don't block plugin init if SQL isn't ready yet.
1399
1380
  }
1400
- }
1401
- // Every closure that picks between dev/prod tools, prompts, or handlers
1402
- // at request time should call this getter instead of reading `canToggle`.
1403
- // `canToggle` means "this environment allows toggling" (static); this
1404
- // function means "the user currently has dev mode ON" (live).
1405
- const isDevMode = () => currentDevMode;
1406
- // Initialize MCP client. Merges file/env config + auto-detected binaries
1407
- // + any remote servers users have added through the settings UI (persisted
1408
- // in the settings table, scanned across all scopes so we never drop
1409
- // another user's entries). Graceful-degrade: any failure yields zero MCP
1410
- // tools and agent-chat keeps working as before.
1411
- let mcpConfig = await buildMergedConfig().catch((err) => {
1412
- console.warn(`[mcp-client] buildMergedConfig failed: ${err?.message ?? err}`);
1413
- return null;
1414
- });
1415
- if (!mcpConfig) {
1416
- const fileOrEnv = loadMcpConfig() ?? autoDetectMcpConfig();
1417
- mcpConfig = fileOrEnv;
1418
- if (mcpConfig?.source) {
1419
- console.log(`[mcp-client] loaded config from ${mcpConfig.source} (${Object.keys(mcpConfig.servers).length} server(s))`);
1381
+ const env = process.env.NODE_ENV;
1382
+ // AGENT_MODE=production forces production agent constraints even in dev
1383
+ const canToggle = (env === "development" || env === "test") &&
1384
+ process.env.AGENT_MODE !== "production";
1385
+ const routePath = options?.path ?? "/_agent-native/agent-chat";
1386
+ // Mutable mode flag persisted to the `settings` table so a user who
1387
+ // toggles to "Production" stays in prod mode across server restarts.
1388
+ // Hoisted here (before any tool-registry / handler closures are built)
1389
+ // so every runtime decision point can close over it and see live changes
1390
+ // when the user toggles the Environment dropdown.
1391
+ const AGENT_MODE_SETTING_KEY = "agent-chat.mode";
1392
+ let currentDevMode = canToggle;
1393
+ if (canToggle) {
1394
+ try {
1395
+ const persisted = await getSetting(AGENT_MODE_SETTING_KEY);
1396
+ if (persisted && typeof persisted.devMode === "boolean") {
1397
+ currentDevMode = persisted.devMode;
1398
+ }
1399
+ }
1400
+ catch {
1401
+ // Settings table may not be ready yet — fall back to default.
1402
+ }
1403
+ }
1404
+ // Every closure that picks between dev/prod tools, prompts, or handlers
1405
+ // at request time should call this getter instead of reading `canToggle`.
1406
+ // `canToggle` means "this environment allows toggling" (static); this
1407
+ // function means "the user currently has dev mode ON" (live).
1408
+ const isDevMode = () => currentDevMode;
1409
+ // Initialize MCP client. Merges file/env config + auto-detected binaries
1410
+ // + any remote servers users have added through the settings UI (persisted
1411
+ // in the settings table, scanned across all scopes so we never drop
1412
+ // another user's entries). Graceful-degrade: any failure yields zero MCP
1413
+ // tools and agent-chat keeps working as before.
1414
+ let mcpConfig = await buildMergedConfig().catch((err) => {
1415
+ console.warn(`[mcp-client] buildMergedConfig failed: ${err?.message ?? err}`);
1416
+ return null;
1417
+ });
1418
+ if (!mcpConfig) {
1419
+ const fileOrEnv = loadMcpConfig() ?? autoDetectMcpConfig();
1420
+ mcpConfig = fileOrEnv;
1421
+ if (mcpConfig?.source) {
1422
+ console.log(`[mcp-client] loaded config from ${mcpConfig.source} (${Object.keys(mcpConfig.servers).length} server(s))`);
1423
+ }
1424
+ else {
1425
+ console.log("[mcp-client] no configured MCP servers — skipping MCP tools");
1426
+ }
1420
1427
  }
1421
- else {
1422
- console.log("[mcp-client] no configured MCP servers skipping MCP tools");
1428
+ else if (mcpConfig.source) {
1429
+ console.log(`[mcp-client] merged config (${Object.keys(mcpConfig.servers).length} server(s), source: ${mcpConfig.source})`);
1423
1430
  }
1424
- }
1425
- else if (mcpConfig.source) {
1426
- console.log(`[mcp-client] merged config (${Object.keys(mcpConfig.servers).length} server(s), source: ${mcpConfig.source})`);
1427
- }
1428
- const mcpManager = new McpClientManager(mcpConfig);
1429
- try {
1430
- await mcpManager.start();
1431
- }
1432
- catch (err) {
1433
- console.warn(`[mcp-client] start() failed: ${err?.message ?? err}. Continuing without MCP tools.`);
1434
- }
1435
- setGlobalMcpManager(mcpManager);
1436
- const mcpActionEntries = mcpToolsToActionEntries(mcpManager);
1437
- // Mount status + management routes so the settings UI can list / add /
1438
- // remove remote MCP servers and hot-reload the running manager.
1439
- mountMcpStatusRoute(nitroApp, mcpManager);
1440
- mountMcpServersRoutes(nitroApp, mcpManager);
1441
- // Hub-serve: expose org-scope servers to other agent-native apps in the
1442
- // workspace when `AGENT_NATIVE_MCP_HUB_TOKEN` is set (dispatch, by
1443
- // convention). Gated by the env var so mounting is a no-op otherwise.
1444
- if (isHubServeEnabled()) {
1445
- mountMcpHubRoutes(nitroApp);
1446
- console.log("[mcp-client] hub serve enabled — other apps can pull org servers via /_agent-native/mcp/hub/servers");
1447
- }
1448
- const hubStatus = getHubStatus();
1449
- if (hubStatus.consuming) {
1450
- console.log(`[mcp-client] hub consume enabled — pulling from ${hubStatus.hubUrl}`);
1451
- }
1452
- mountMcpHubStatusRoute(nitroApp);
1453
- // Ensure we tear down child processes if the host shuts down cleanly.
1454
- if (typeof process !== "undefined" &&
1455
- typeof process.once === "function" &&
1456
- !globalThis.__agentNativeMcpExitHooked) {
1457
- globalThis.__agentNativeMcpExitHooked = true;
1458
- const stop = () => {
1459
- const mgr = getGlobalMcpManager();
1460
- if (mgr)
1461
- void mgr.stop();
1462
- };
1463
- process.once("exit", stop);
1464
- process.once("SIGTERM", stop);
1465
- process.once("SIGINT", stop);
1466
- }
1467
- // Resolve actions — prefer `actions`, fall back to deprecated `scripts`
1468
- const rawActions = options?.actions ?? options?.scripts;
1469
- const templateScripts = typeof rawActions === "function"
1470
- ? await rawActions()
1471
- : (rawActions ?? {});
1472
- // Resource, chat, docs, db, and cross-agent scripts are available in both prod and dev modes
1473
- const resourceScripts = await createResourceScriptEntries();
1474
- const docsScripts = await createDocsScriptEntries();
1475
- const dbScripts = await createDbScriptEntries();
1476
- const refreshScreenTool = createRefreshScreenEntry();
1477
- const urlTools = createUrlTools();
1478
- const engineScripts = await createAgentEngineScriptEntries();
1479
- const chatScripts = {
1480
- ...(await createChatScriptEntries()),
1481
- ...engineScripts,
1482
- };
1483
- const callAgentScript = await createCallAgentScriptEntry(options?.appId);
1484
- let _currentRequestOrigin = "http://localhost:3000";
1485
- const browserTools = createBuilderBrowserTool({
1486
- getOrigin: () => _currentRequestOrigin,
1487
- });
1488
- // Auto-mount A2A protocol endpoints so every app is discoverable
1489
- // and callable by other agents via the standard protocol.
1490
- // In dev mode, include dev scripts (filesystem-discovered) so the A2A agent
1491
- // has access to the same tools as the interactive agent.
1492
- let devScriptsForA2A = {};
1493
- let discoveredActions = {};
1494
- if (canToggle) {
1431
+ const mcpManager = new McpClientManager(mcpConfig);
1495
1432
  try {
1496
- const { createDevScriptRegistry } = await import("../scripts/dev/index.js");
1497
- devScriptsForA2A = await createDevScriptRegistry();
1433
+ await mcpManager.start();
1498
1434
  }
1499
- catch { }
1500
- // Auto-discover template action files and register as shell-based tools.
1501
- // This ensures templates without a custom agent-chat plugin (e.g., analytics)
1502
- // still have their domain actions available as tools.
1503
- try {
1504
- const fs = await import("fs");
1505
- const pathMod = await import("path");
1506
- const cwd = process.cwd();
1507
- const skipFiles = new Set([
1508
- "helpers",
1509
- "run",
1510
- "registry",
1511
- "_utils",
1512
- "db-connect",
1513
- "db-status",
1514
- ]);
1515
- for (const dir of ["actions", "scripts"]) {
1516
- const actionsDir = pathMod.join(cwd, dir);
1517
- const _fs = await lazyFs();
1518
- if (!_fs.existsSync(actionsDir))
1519
- continue;
1520
- const files = _fs
1521
- .readdirSync(actionsDir)
1522
- .filter((f) => f.endsWith(".ts") &&
1523
- !f.startsWith("_") &&
1524
- !skipFiles.has(f.replace(/\.ts$/, "")));
1525
- for (const file of files) {
1526
- const name = file.replace(/\.ts$/, "");
1527
- if (templateScripts[name] || devScriptsForA2A[name])
1435
+ catch (err) {
1436
+ console.warn(`[mcp-client] start() failed: ${err?.message ?? err}. Continuing without MCP tools.`);
1437
+ }
1438
+ setGlobalMcpManager(mcpManager);
1439
+ const mcpActionEntries = mcpToolsToActionEntries(mcpManager);
1440
+ // Mount status + management routes so the settings UI can list / add /
1441
+ // remove remote MCP servers and hot-reload the running manager.
1442
+ mountMcpStatusRoute(nitroApp, mcpManager);
1443
+ mountMcpServersRoutes(nitroApp, mcpManager);
1444
+ // Hub-serve: expose org-scope servers to other agent-native apps in the
1445
+ // workspace when `AGENT_NATIVE_MCP_HUB_TOKEN` is set (dispatch, by
1446
+ // convention). Gated by the env var so mounting is a no-op otherwise.
1447
+ if (isHubServeEnabled()) {
1448
+ mountMcpHubRoutes(nitroApp);
1449
+ console.log("[mcp-client] hub serve enabled — other apps can pull org servers via /_agent-native/mcp/hub/servers");
1450
+ }
1451
+ const hubStatus = getHubStatus();
1452
+ if (hubStatus.consuming) {
1453
+ console.log(`[mcp-client] hub consume enabled — pulling from ${hubStatus.hubUrl}`);
1454
+ }
1455
+ mountMcpHubStatusRoute(nitroApp);
1456
+ // Ensure we tear down child processes if the host shuts down cleanly.
1457
+ if (typeof process !== "undefined" &&
1458
+ typeof process.once === "function" &&
1459
+ !globalThis.__agentNativeMcpExitHooked) {
1460
+ globalThis.__agentNativeMcpExitHooked = true;
1461
+ const stop = () => {
1462
+ const mgr = getGlobalMcpManager();
1463
+ if (mgr)
1464
+ void mgr.stop();
1465
+ };
1466
+ process.once("exit", stop);
1467
+ process.once("SIGTERM", stop);
1468
+ process.once("SIGINT", stop);
1469
+ }
1470
+ // Resolve actions — prefer `actions`, fall back to deprecated `scripts`
1471
+ const rawActions = options?.actions ?? options?.scripts;
1472
+ const templateScripts = typeof rawActions === "function"
1473
+ ? await rawActions()
1474
+ : (rawActions ?? {});
1475
+ // Resource, chat, docs, db, and cross-agent scripts are available in both prod and dev modes
1476
+ const resourceScripts = await createResourceScriptEntries();
1477
+ const docsScripts = await createDocsScriptEntries();
1478
+ const dbScripts = await createDbScriptEntries();
1479
+ const refreshScreenTool = createRefreshScreenEntry();
1480
+ const urlTools = createUrlTools();
1481
+ const engineScripts = await createAgentEngineScriptEntries();
1482
+ const chatScripts = {
1483
+ ...(await createChatScriptEntries()),
1484
+ ...engineScripts,
1485
+ };
1486
+ const callAgentScript = await createCallAgentScriptEntry(options?.appId);
1487
+ let _currentRequestOrigin = "http://localhost:3000";
1488
+ const browserTools = createBuilderBrowserTool({
1489
+ getOrigin: () => _currentRequestOrigin,
1490
+ });
1491
+ // Auto-mount A2A protocol endpoints so every app is discoverable
1492
+ // and callable by other agents via the standard protocol.
1493
+ // In dev mode, include dev scripts (filesystem-discovered) so the A2A agent
1494
+ // has access to the same tools as the interactive agent.
1495
+ let devScriptsForA2A = {};
1496
+ let discoveredActions = {};
1497
+ if (canToggle) {
1498
+ try {
1499
+ const { createDevScriptRegistry } = await import("../scripts/dev/index.js");
1500
+ devScriptsForA2A = await createDevScriptRegistry();
1501
+ }
1502
+ catch { }
1503
+ // Auto-discover template action files and register as shell-based tools.
1504
+ // This ensures templates without a custom agent-chat plugin (e.g., analytics)
1505
+ // still have their domain actions available as tools.
1506
+ try {
1507
+ const fs = await import("fs");
1508
+ const pathMod = await import("path");
1509
+ const cwd = process.cwd();
1510
+ const skipFiles = new Set([
1511
+ "helpers",
1512
+ "run",
1513
+ "registry",
1514
+ "_utils",
1515
+ "db-connect",
1516
+ "db-status",
1517
+ ]);
1518
+ for (const dir of ["actions", "scripts"]) {
1519
+ const actionsDir = pathMod.join(cwd, dir);
1520
+ const _fs = await lazyFs();
1521
+ if (!_fs.existsSync(actionsDir))
1528
1522
  continue;
1529
- // Try to load the action module directly so we get the real
1530
- // run function (not a shell wrapper). This makes HTTP endpoints
1531
- // work correctly. Only fall back to shell wrapper if the import
1532
- // fails (e.g., CLI-style scripts that throw at top level).
1533
- const filePath = pathMod.join(actionsDir, file);
1534
- try {
1535
- const mod = await import(/* @vite-ignore */ filePath);
1536
- const def = mod.default && typeof mod.default === "object"
1537
- ? mod.default
1538
- : mod;
1539
- if (def?.tool && typeof def.run === "function") {
1540
- discoveredActions[name] = {
1541
- tool: def.tool,
1542
- run: def.run,
1543
- ...(def.http !== undefined ? { http: def.http } : {}),
1544
- };
1523
+ const files = _fs
1524
+ .readdirSync(actionsDir)
1525
+ .filter((f) => f.endsWith(".ts") &&
1526
+ !f.startsWith("_") &&
1527
+ !skipFiles.has(f.replace(/\.ts$/, "")));
1528
+ for (const file of files) {
1529
+ const name = file.replace(/\.ts$/, "");
1530
+ if (templateScripts[name] || devScriptsForA2A[name])
1545
1531
  continue;
1532
+ // Try to load the action module directly so we get the real
1533
+ // run function (not a shell wrapper). This makes HTTP endpoints
1534
+ // work correctly. Only fall back to shell wrapper if the import
1535
+ // fails (e.g., CLI-style scripts that throw at top level).
1536
+ const filePath = pathMod.join(actionsDir, file);
1537
+ try {
1538
+ const mod = await import(/* @vite-ignore */ filePath);
1539
+ const def = mod.default && typeof mod.default === "object"
1540
+ ? mod.default
1541
+ : mod;
1542
+ if (def?.tool && typeof def.run === "function") {
1543
+ discoveredActions[name] = {
1544
+ tool: def.tool,
1545
+ run: def.run,
1546
+ ...(def.http !== undefined ? { http: def.http } : {}),
1547
+ };
1548
+ continue;
1549
+ }
1546
1550
  }
1547
- }
1548
- catch {
1549
- // Fall through to shell wrapper for CLI-style scripts
1550
- // (and .ts files Node can't parse natively).
1551
- }
1552
- // Static-parse the source for `http: false` or
1553
- // `http: { method: "GET" }` so the shell-wrapper fallback still
1554
- // mounts HTTP routes with the correct method. We can't load the
1555
- // .ts module to read the real defineAction object in this Node
1556
- // context, so this regex sniff is the best we can do until the
1557
- // discovery is moved into a Vite-aware codepath.
1558
- let httpConfig;
1559
- try {
1560
- const src = _fs.readFileSync(filePath, "utf-8");
1561
- if (/\bhttp\s*:\s*false\b/.test(src)) {
1562
- httpConfig = false;
1551
+ catch {
1552
+ // Fall through to shell wrapper for CLI-style scripts
1553
+ // (and .ts files Node can't parse natively).
1563
1554
  }
1564
- else {
1565
- const httpStart = src.search(/\bhttp\s*:\s*\{/);
1566
- if (httpStart >= 0) {
1567
- const window = src.slice(httpStart, httpStart + 200);
1568
- const m = window.match(/method\s*:\s*['"`](GET|POST|PUT|DELETE)['"`]/);
1569
- const p = window.match(/path\s*:\s*['"`]([^'"`]+)['"`]/);
1570
- if (m || p) {
1571
- httpConfig = {
1572
- ...(m
1573
- ? {
1574
- method: m[1],
1575
- }
1576
- : {}),
1577
- ...(p ? { path: p[1] } : {}),
1578
- };
1555
+ // Static-parse the source for `http: false` or
1556
+ // `http: { method: "GET" }` so the shell-wrapper fallback still
1557
+ // mounts HTTP routes with the correct method. We can't load the
1558
+ // .ts module to read the real defineAction object in this Node
1559
+ // context, so this regex sniff is the best we can do until the
1560
+ // discovery is moved into a Vite-aware codepath.
1561
+ let httpConfig;
1562
+ try {
1563
+ const src = _fs.readFileSync(filePath, "utf-8");
1564
+ if (/\bhttp\s*:\s*false\b/.test(src)) {
1565
+ httpConfig = false;
1566
+ }
1567
+ else {
1568
+ const httpStart = src.search(/\bhttp\s*:\s*\{/);
1569
+ if (httpStart >= 0) {
1570
+ const window = src.slice(httpStart, httpStart + 200);
1571
+ const m = window.match(/method\s*:\s*['"`](GET|POST|PUT|DELETE)['"`]/);
1572
+ const p = window.match(/path\s*:\s*['"`]([^'"`]+)['"`]/);
1573
+ if (m || p) {
1574
+ httpConfig = {
1575
+ ...(m
1576
+ ? {
1577
+ method: m[1],
1578
+ }
1579
+ : {}),
1580
+ ...(p ? { path: p[1] } : {}),
1581
+ };
1582
+ }
1579
1583
  }
1580
1584
  }
1581
1585
  }
1582
- }
1583
- catch {
1584
- // File read failed — leave httpConfig undefined (default POST)
1585
- }
1586
- // Fallback: shell-based wrapper for CLI-style scripts
1587
- discoveredActions[name] = {
1588
- tool: {
1589
- description: `Run the ${name} action. Use: pnpm action ${name} --arg=value`,
1590
- parameters: {
1591
- type: "object",
1592
- properties: {
1593
- args: {
1594
- type: "string",
1595
- description: "CLI arguments as a string (e.g., --metrics=sessions --days=7)",
1586
+ catch {
1587
+ // File read failed — leave httpConfig undefined (default POST)
1588
+ }
1589
+ // Fallback: shell-based wrapper for CLI-style scripts
1590
+ discoveredActions[name] = {
1591
+ tool: {
1592
+ description: `Run the ${name} action. Use: pnpm action ${name} --arg=value`,
1593
+ parameters: {
1594
+ type: "object",
1595
+ properties: {
1596
+ args: {
1597
+ type: "string",
1598
+ description: "CLI arguments as a string (e.g., --metrics=sessions --days=7)",
1599
+ },
1596
1600
  },
1597
1601
  },
1598
1602
  },
1599
- },
1600
- run: async (input) => {
1601
- const shellEntry = devScriptsForA2A["shell"];
1602
- if (!shellEntry)
1603
- return "Error: shell not available";
1604
- return shellEntry.run({
1605
- command: `pnpm action ${name} ${input.args || ""}`.trim(),
1606
- });
1607
- },
1608
- ...(httpConfig !== undefined ? { http: httpConfig } : {}),
1609
- };
1603
+ run: async (input) => {
1604
+ const shellEntry = devScriptsForA2A["shell"];
1605
+ if (!shellEntry)
1606
+ return "Error: shell not available";
1607
+ return shellEntry.run({
1608
+ command: `pnpm action ${name} ${input.args || ""}`.trim(),
1609
+ });
1610
+ },
1611
+ ...(httpConfig !== undefined ? { http: httpConfig } : {}),
1612
+ };
1613
+ }
1610
1614
  }
1615
+ if (Object.keys(discoveredActions).length > 0 && process.env.DEBUG)
1616
+ console.log(`[agent-chat] Auto-discovered ${Object.keys(discoveredActions).length} action(s): ${Object.keys(discoveredActions).join(", ")}`);
1611
1617
  }
1612
- if (Object.keys(discoveredActions).length > 0 && process.env.DEBUG)
1613
- console.log(`[agent-chat] Auto-discovered ${Object.keys(discoveredActions).length} action(s): ${Object.keys(discoveredActions).join(", ")}`);
1618
+ catch { }
1619
+ }
1620
+ // Mutable owner — set per-request by the production handler, read by
1621
+ // automation tools and fetch tool via closure. Declared here (before
1622
+ // allScripts) so the tools are in scope when allScripts is built.
1623
+ let _currentRunOwner = "local@localhost";
1624
+ // Automation tools + fetch tool — depend on _currentRunOwner via callback
1625
+ let automationTools = {};
1626
+ try {
1627
+ const { createAutomationToolEntries } = await import("../triggers/actions.js");
1628
+ automationTools = createAutomationToolEntries(() => _currentRunOwner);
1614
1629
  }
1615
1630
  catch { }
1616
- }
1617
- // In dev mode, template actions (templateScripts and discoveredActions) are
1618
- // NOT registered as native tools — the agent invokes them via shell instead.
1619
- // This avoids degenerate empty-object tool calls that Anthropic models
1620
- // sometimes emit for actions with complex schemas. Production keeps the
1621
- // native registration since it has no shell access.
1622
- const allScripts = canToggle
1623
- ? {
1624
- ...resourceScripts,
1625
- ...docsScripts,
1626
- ...chatScripts,
1627
- ...callAgentScript,
1628
- ...browserTools,
1629
- ...devScriptsForA2A,
1631
+ let notificationTools = {};
1632
+ try {
1633
+ const { createNotificationToolEntries } = await import("../notifications/actions.js");
1634
+ notificationTools = createNotificationToolEntries(() => _currentRunOwner);
1630
1635
  }
1631
- : {
1632
- ...discoveredActions,
1633
- ...templateScripts,
1634
- ...resourceScripts,
1635
- ...docsScripts,
1636
- ...dbScripts,
1637
- ...refreshScreenTool,
1638
- ...urlTools,
1639
- ...chatScripts,
1640
- ...callAgentScript,
1641
- ...browserTools,
1642
- ...devScriptsForA2A,
1643
- };
1644
- const { mountA2A } = await import("../a2a/server.js");
1645
- mountA2A(nitroApp, {
1646
- name: options?.appId
1647
- ? options.appId.charAt(0).toUpperCase() + options.appId.slice(1)
1648
- : "Agent",
1649
- description: `Agent-native ${options?.appId ?? "app"} agent`,
1650
- skills: Object.entries(allScripts).map(([name, entry]) => ({
1651
- id: name,
1652
- name,
1653
- description: entry.tool.description,
1654
- })),
1655
- streaming: true,
1656
- handler: async function* (message, context) {
1657
- // Resolve the caller's identity for user-scoped data access.
1658
- const isDev = process.env.NODE_ENV !== "production";
1659
- let userEmail;
1660
- if (isDev) {
1661
- userEmail = context.metadata?.userEmail || undefined;
1662
- if (!userEmail) {
1663
- try {
1664
- const { getDbExec } = await import("../db/client.js");
1665
- const db = getDbExec();
1666
- const { rows } = await db.execute({
1667
- sql: "SELECT email FROM sessions ORDER BY created_at DESC LIMIT 1",
1668
- args: [],
1669
- });
1670
- if (rows[0])
1671
- userEmail = rows[0].email;
1636
+ catch { }
1637
+ let progressTools = {};
1638
+ try {
1639
+ const { createProgressToolEntries } = await import("../progress/actions.js");
1640
+ progressTools = createProgressToolEntries(() => _currentRunOwner);
1641
+ }
1642
+ catch { }
1643
+ let fetchTool = {};
1644
+ try {
1645
+ const { createFetchToolEntry } = await import("../tools/fetch-tool.js");
1646
+ const { resolveKeyReferences, validateUrlAllowlist, getKeyAllowlist } = await import("../secrets/substitution.js");
1647
+ fetchTool = createFetchToolEntry({
1648
+ resolveKeys: async (text) => resolveKeyReferences(text, "user", _currentRunOwner),
1649
+ validateUrl: async (url, usedKeys) => {
1650
+ for (const keyName of usedKeys) {
1651
+ const allowlist = await getKeyAllowlist(keyName, "user", _currentRunOwner);
1652
+ if (allowlist && !validateUrlAllowlist(url, allowlist)) {
1653
+ return false;
1654
+ }
1672
1655
  }
1673
- catch { }
1674
- }
1656
+ return true;
1657
+ },
1658
+ });
1659
+ }
1660
+ catch { }
1661
+ // In dev mode, template actions (templateScripts and discoveredActions) are
1662
+ // NOT registered as native tools — the agent invokes them via shell instead.
1663
+ // This avoids degenerate empty-object tool calls that Anthropic models
1664
+ // sometimes emit for actions with complex schemas. Production keeps the
1665
+ // native registration since it has no shell access.
1666
+ const allScripts = canToggle
1667
+ ? {
1668
+ ...resourceScripts,
1669
+ ...docsScripts,
1670
+ ...chatScripts,
1671
+ ...callAgentScript,
1672
+ ...automationTools,
1673
+ ...notificationTools,
1674
+ ...progressTools,
1675
+ ...fetchTool,
1676
+ ...browserTools,
1677
+ ...devScriptsForA2A,
1675
1678
  }
1676
- else {
1677
- const googleToken = context.metadata?.googleToken;
1678
- if (googleToken) {
1679
- try {
1680
- const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(googleToken)}`);
1681
- if (res.ok) {
1682
- const info = (await res.json());
1683
- if (info.email && info.email_verified === "true") {
1684
- userEmail = info.email;
1679
+ : {
1680
+ ...discoveredActions,
1681
+ ...templateScripts,
1682
+ ...resourceScripts,
1683
+ ...docsScripts,
1684
+ ...dbScripts,
1685
+ ...refreshScreenTool,
1686
+ ...urlTools,
1687
+ ...chatScripts,
1688
+ ...callAgentScript,
1689
+ ...automationTools,
1690
+ ...notificationTools,
1691
+ ...progressTools,
1692
+ ...fetchTool,
1693
+ ...browserTools,
1694
+ ...devScriptsForA2A,
1695
+ };
1696
+ const { mountA2A } = await import("../a2a/server.js");
1697
+ mountA2A(nitroApp, {
1698
+ name: options?.appId
1699
+ ? options.appId.charAt(0).toUpperCase() + options.appId.slice(1)
1700
+ : "Agent",
1701
+ description: `Agent-native ${options?.appId ?? "app"} agent`,
1702
+ skills: Object.entries(allScripts).map(([name, entry]) => ({
1703
+ id: name,
1704
+ name,
1705
+ description: entry.tool.description,
1706
+ })),
1707
+ streaming: true,
1708
+ handler: async function* (message, context) {
1709
+ // Resolve the caller's identity for user-scoped data access.
1710
+ const isDev = process.env.NODE_ENV !== "production";
1711
+ let userEmail;
1712
+ if (isDev) {
1713
+ userEmail = context.metadata?.userEmail || undefined;
1714
+ if (!userEmail) {
1715
+ try {
1716
+ const { getDbExec } = await import("../db/client.js");
1717
+ const db = getDbExec();
1718
+ const { rows } = await db.execute({
1719
+ sql: "SELECT email FROM sessions ORDER BY created_at DESC LIMIT 1",
1720
+ args: [],
1721
+ });
1722
+ if (rows[0])
1723
+ userEmail = rows[0].email;
1724
+ }
1725
+ catch { }
1726
+ }
1727
+ }
1728
+ else {
1729
+ const googleToken = context.metadata?.googleToken;
1730
+ if (googleToken) {
1731
+ try {
1732
+ const res = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${encodeURIComponent(googleToken)}`);
1733
+ if (res.ok) {
1734
+ const info = (await res.json());
1735
+ if (info.email && info.email_verified === "true") {
1736
+ userEmail = info.email;
1737
+ }
1685
1738
  }
1686
1739
  }
1740
+ catch { }
1687
1741
  }
1688
- catch { }
1689
1742
  }
1690
- }
1691
- if (userEmail) {
1692
- process.env.AGENT_USER_EMAIL = userEmail;
1693
- }
1694
- const text = message.parts
1695
- .filter((p) => p.type === "text")
1696
- .map((p) => p.text)
1697
- .join("\n");
1698
- if (!text) {
1743
+ if (userEmail) {
1744
+ process.env.AGENT_USER_EMAIL = userEmail;
1745
+ }
1746
+ const text = message.parts
1747
+ .filter((p) => p.type === "text")
1748
+ .map((p) => p.text)
1749
+ .join("\n");
1750
+ if (!text) {
1751
+ yield {
1752
+ role: "agent",
1753
+ parts: [
1754
+ { type: "text", text: "No text content in message" },
1755
+ ],
1756
+ };
1757
+ return;
1758
+ }
1759
+ // Use the SAME agent setup as the interactive chat — identical tools,
1760
+ // prompt, and capabilities. The A2A agent IS the app's agent.
1761
+ const a2aEngine = await resolveEngine({
1762
+ engineOption: options?.engine,
1763
+ apiKey: options?.apiKey,
1764
+ });
1765
+ // Use the same handler (dev or prod) that the interactive chat uses
1766
+ const devActive = isDevMode();
1767
+ const handler = devActive && devHandler ? devHandler : prodHandler;
1768
+ // Build the same system prompt the interactive agent uses
1769
+ const owner = userEmail || "local@localhost";
1770
+ const resources = await loadResourcesForPrompt(owner);
1771
+ const schemaBlock = await buildSchemaBlock(owner, devActive);
1772
+ const systemPrompt = devActive
1773
+ ? devPrompt + resources + schemaBlock
1774
+ : basePrompt + resources + schemaBlock;
1775
+ const model = options?.model ??
1776
+ (canToggle ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001");
1777
+ // Build tools — same as interactive handler but WITHOUT call-agent
1778
+ // to prevent infinite recursive A2A loops (agent calling itself).
1779
+ // In dev mode, template actions are invoked via shell (not native tools),
1780
+ // so they're omitted from the tool registry — see allScripts comment.
1781
+ const a2aActions = devActive
1782
+ ? {
1783
+ ...resourceScripts,
1784
+ ...docsScripts,
1785
+ ...chatScripts,
1786
+ ...browserTools,
1787
+ ...devScriptsForA2A,
1788
+ }
1789
+ : {
1790
+ ...templateScripts,
1791
+ ...resourceScripts,
1792
+ ...docsScripts,
1793
+ ...dbScripts,
1794
+ ...refreshScreenTool,
1795
+ ...urlTools,
1796
+ ...chatScripts,
1797
+ ...browserTools,
1798
+ };
1799
+ const a2aTools = actionsToEngineTools(a2aActions);
1800
+ const a2aMessages = [
1801
+ { role: "user", content: [{ type: "text", text }] },
1802
+ ];
1803
+ // Run the SAME agent loop, collect text events, yield as A2A messages
1804
+ let accumulatedText = "";
1805
+ const controller = new AbortController();
1806
+ console.log(`[A2A] Starting agent loop: ${a2aTools.length} tools, prompt ${systemPrompt.length} chars`);
1807
+ await runAgentLoop({
1808
+ engine: a2aEngine,
1809
+ model,
1810
+ systemPrompt,
1811
+ tools: a2aTools,
1812
+ messages: a2aMessages,
1813
+ actions: a2aActions,
1814
+ send: (event) => {
1815
+ if (event.type === "text") {
1816
+ accumulatedText += event.text;
1817
+ }
1818
+ else if (event.type === "tool_start") {
1819
+ console.log(`[A2A] Tool call: ${event.tool}`);
1820
+ }
1821
+ else if (event.type === "error") {
1822
+ console.error(`[A2A] Error: ${event.error}`);
1823
+ }
1824
+ else if (event.type === "done") {
1825
+ console.log(`[A2A] Done. Response: ${accumulatedText.length} chars`);
1826
+ }
1827
+ },
1828
+ signal: controller.signal,
1829
+ });
1830
+ console.log(`[A2A] Loop complete. Text: ${accumulatedText.slice(0, 100)}...`);
1831
+ // Yield the final accumulated text
1699
1832
  yield {
1700
1833
  role: "agent",
1701
1834
  parts: [
1702
- { type: "text", text: "No text content in message" },
1835
+ {
1836
+ type: "text",
1837
+ text: accumulatedText || "(no response)",
1838
+ },
1703
1839
  ],
1704
1840
  };
1705
- return;
1841
+ },
1842
+ });
1843
+ // Generate an "Available Actions" section from template-specific actions
1844
+ // so the agent knows to use them instead of raw SQL.
1845
+ //
1846
+ // Production: actions are native tools — emit `name(arg*: type) — desc`
1847
+ // Dev: actions are invoked via shell — emit `pnpm action name --arg <type>`
1848
+ // and include discoveredActions too, since those are also missing
1849
+ // from the dev tool registry.
1850
+ const prodActionsPrompt = generateActionsPrompt(templateScripts, "tool");
1851
+ const devActionsPrompt = generateActionsPrompt({ ...discoveredActions, ...templateScripts }, "cli");
1852
+ // Build system prompts — dynamic functions that pre-load resources per-request.
1853
+ // Production gets PROD_FRAMEWORK_PROMPT, dev gets DEV_FRAMEWORK_PROMPT.
1854
+ // Custom systemPrompt from options overrides the framework default entirely.
1855
+ const prodPrompt = (options?.systemPrompt ?? PROD_FRAMEWORK_PROMPT) + prodActionsPrompt;
1856
+ const devPrompt = (options?.devSystemPrompt
1857
+ ? options.devSystemPrompt +
1858
+ (options?.systemPrompt ?? PROD_FRAMEWORK_PROMPT)
1859
+ : DEV_FRAMEWORK_PROMPT) + devActionsPrompt;
1860
+ // Keep legacy names for the composition below
1861
+ const basePrompt = prodPrompt;
1862
+ const devPrefix = options?.devSystemPrompt ?? DEFAULT_DEV_PROMPT;
1863
+ // Mount MCP remote server — same action registry as A2A + agent chat
1864
+ const { mountMCP } = await import("../mcp/server.js");
1865
+ mountMCP(nitroApp, {
1866
+ name: options?.appId
1867
+ ? options.appId.charAt(0).toUpperCase() + options.appId.slice(1)
1868
+ : "Agent",
1869
+ description: `Agent-native ${options?.appId ?? "app"} agent`,
1870
+ actions: allScripts,
1871
+ askAgent: async (message) => {
1872
+ const mcpEngine = await resolveEngine({
1873
+ engineOption: options?.engine,
1874
+ apiKey: options?.apiKey,
1875
+ });
1876
+ const model = options?.model ??
1877
+ (canToggle ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001");
1878
+ // Same actions as A2A — without call-agent to prevent loops.
1879
+ // In dev mode, template actions go through shell, not native tools.
1880
+ const devActiveMcp = isDevMode();
1881
+ const mcpActions = devActiveMcp
1882
+ ? {
1883
+ ...resourceScripts,
1884
+ ...docsScripts,
1885
+ ...chatScripts,
1886
+ ...devScriptsForA2A,
1887
+ }
1888
+ : {
1889
+ ...templateScripts,
1890
+ ...resourceScripts,
1891
+ ...docsScripts,
1892
+ ...dbScripts,
1893
+ ...refreshScreenTool,
1894
+ ...urlTools,
1895
+ ...chatScripts,
1896
+ };
1897
+ const mcpTools = actionsToEngineTools(mcpActions);
1898
+ const resources = await loadResourcesForPrompt("local@localhost");
1899
+ const schemaBlock = await buildSchemaBlock("local@localhost", devActiveMcp);
1900
+ const systemPrompt = devActiveMcp
1901
+ ? devPrompt + resources + schemaBlock
1902
+ : basePrompt + resources + schemaBlock;
1903
+ let accumulatedText = "";
1904
+ const controller = new AbortController();
1905
+ await runAgentLoop({
1906
+ engine: mcpEngine,
1907
+ model,
1908
+ systemPrompt,
1909
+ tools: mcpTools,
1910
+ messages: [
1911
+ { role: "user", content: [{ type: "text", text: message }] },
1912
+ ],
1913
+ actions: mcpActions,
1914
+ send: (event) => {
1915
+ if (event.type === "text")
1916
+ accumulatedText += event.text;
1917
+ },
1918
+ signal: controller.signal,
1919
+ });
1920
+ return accumulatedText || "(no response)";
1921
+ },
1922
+ });
1923
+ // Resolve owner from the H3 event's session — matches how resources are created
1924
+ const getOwnerFromEvent = async (event) => {
1925
+ try {
1926
+ const session = await getSession(event);
1927
+ return session?.email || "local@localhost";
1706
1928
  }
1707
- // Use the SAME agent setup as the interactive chat — identical tools,
1708
- // prompt, and capabilities. The A2A agent IS the app's agent.
1709
- const a2aEngine = await resolveEngine({
1710
- engineOption: options?.engine,
1711
- apiKey: options?.apiKey,
1929
+ catch {
1930
+ return "local@localhost";
1931
+ }
1932
+ };
1933
+ // Auto-mount template actions as HTTP endpoints under /_agent-native/actions/
1934
+ // Include engine management scripts so the UI can call list/set/test-agent-engine.
1935
+ const httpActions = {
1936
+ ...discoveredActions,
1937
+ ...templateScripts,
1938
+ ...engineScripts,
1939
+ };
1940
+ // Framework-level sharing actions — merged with skipExisting semantics so
1941
+ // any template that provides a same-named action wins. When templates use
1942
+ // `loadActionsFromStaticRegistry`, `autoDiscoverActions` never runs, so
1943
+ // this is the single point that guarantees share-resource, unshare-resource,
1944
+ // list-resource-shares, and set-resource-visibility are always mounted.
1945
+ try {
1946
+ const { mergeCoreSharingActions } = await import("./action-discovery.js");
1947
+ await mergeCoreSharingActions(httpActions);
1948
+ }
1949
+ catch {
1950
+ // Ignore — templates without sharing still work.
1951
+ }
1952
+ if (Object.keys(httpActions).length > 0) {
1953
+ const { mountActionRoutes } = await import("./action-routes.js");
1954
+ mountActionRoutes(nitroApp, httpActions, {
1955
+ getOwnerFromEvent,
1956
+ resolveOrgId: options?.resolveOrgId,
1712
1957
  });
1713
- // Use the same handler (dev or prod) that the interactive chat uses
1714
- const devActive = isDevMode();
1715
- const handler = devActive && devHandler ? devHandler : prodHandler;
1716
- // Build the same system prompt the interactive agent uses
1717
- const owner = userEmail || "local@localhost";
1718
- const resources = await loadResourcesForPrompt(owner);
1719
- const schemaBlock = await buildSchemaBlock(owner, devActive);
1720
- const systemPrompt = devActive
1721
- ? devPrompt + resources + schemaBlock
1722
- : basePrompt + resources + schemaBlock;
1723
- const model = options?.model ??
1724
- (canToggle ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001");
1725
- // Build tools — same as interactive handler but WITHOUT call-agent
1726
- // to prevent infinite recursive A2A loops (agent calling itself).
1727
- // In dev mode, template actions are invoked via shell (not native tools),
1728
- // so they're omitted from the tool registry — see allScripts comment.
1729
- const a2aActions = devActive
1730
- ? {
1731
- ...resourceScripts,
1732
- ...docsScripts,
1733
- ...chatScripts,
1734
- ...browserTools,
1735
- ...devScriptsForA2A,
1736
- }
1737
- : {
1738
- ...templateScripts,
1739
- ...resourceScripts,
1740
- ...docsScripts,
1741
- ...dbScripts,
1742
- ...refreshScreenTool,
1743
- ...urlTools,
1744
- ...chatScripts,
1745
- ...browserTools,
1746
- };
1747
- const a2aTools = actionsToEngineTools(a2aActions);
1748
- const a2aMessages = [
1749
- { role: "user", content: [{ type: "text", text }] },
1750
- ];
1751
- // Run the SAME agent loop, collect text events, yield as A2A messages
1752
- let accumulatedText = "";
1753
- const controller = new AbortController();
1754
- console.log(`[A2A] Starting agent loop: ${a2aTools.length} tools, prompt ${systemPrompt.length} chars`);
1755
- await runAgentLoop({
1756
- engine: a2aEngine,
1757
- model,
1758
- systemPrompt,
1759
- tools: a2aTools,
1760
- messages: a2aMessages,
1761
- actions: a2aActions,
1762
- send: (event) => {
1763
- if (event.type === "text") {
1764
- accumulatedText += event.text;
1958
+ }
1959
+ // Callback to persist agent response when run finishes (even if client disconnected).
1960
+ // Reconstructs the assistant message from buffered events and appends to thread_data.
1961
+ const onRunComplete = async (run, threadId) => {
1962
+ if (!threadId)
1963
+ return;
1964
+ // Serialize the read-modify-write against the same thread's other
1965
+ // `thread_data` writers (setThreadQueuedMessages, setThreadEngineMeta,
1966
+ // the frontend-triggered saves below). Without the lock, a concurrent
1967
+ // queued-message save can clobber the assistant message we just
1968
+ // appended here, or vice versa.
1969
+ await withThreadDataLock(threadId, async () => {
1970
+ try {
1971
+ const thread = await getThread(threadId);
1972
+ if (!thread)
1973
+ return;
1974
+ const assistantMsg = buildAssistantMessage(run.events ?? [], run.runId);
1975
+ if (!assistantMsg) {
1976
+ // No content produced — just bump timestamp
1977
+ await updateThreadData(threadId, thread.threadData, thread.title, thread.preview, thread.messageCount);
1978
+ return;
1765
1979
  }
1766
- else if (event.type === "tool_start") {
1767
- console.log(`[A2A] Tool call: ${event.tool}`);
1980
+ // Parse existing thread_data, append assistant message only if
1981
+ // the frontend hasn't already saved it (avoids duplicates when
1982
+ // the client is still connected during a normal flow).
1983
+ let repo;
1984
+ try {
1985
+ repo = JSON.parse(thread.threadData || "{}");
1768
1986
  }
1769
- else if (event.type === "error") {
1770
- console.error(`[A2A] Error: ${event.error}`);
1987
+ catch {
1988
+ repo = {};
1771
1989
  }
1772
- else if (event.type === "done") {
1773
- console.log(`[A2A] Done. Response: ${accumulatedText.length} chars`);
1990
+ if (!Array.isArray(repo.messages))
1991
+ repo.messages = [];
1992
+ const lastMsg = repo.messages[repo.messages.length - 1];
1993
+ // Check both wrapped ({ message: { role } }) and unwrapped ({ role }) formats
1994
+ const lastRole = lastMsg?.message?.role ?? lastMsg?.role;
1995
+ const lastContent = lastMsg?.message?.content ?? lastMsg?.content;
1996
+ const lastContentIsEmpty = Array.isArray(lastContent)
1997
+ ? lastContent.length === 0
1998
+ : lastContent == null || lastContent === "";
1999
+ if (lastRole === "assistant" && !lastContentIsEmpty) {
2000
+ // Frontend already saved the assistant response — just bump timestamp
2001
+ await updateThreadData(threadId, thread.threadData, thread.title, thread.preview, thread.messageCount);
2002
+ return;
1774
2003
  }
1775
- },
1776
- signal: controller.signal,
1777
- });
1778
- console.log(`[A2A] Loop complete. Text: ${accumulatedText.slice(0, 100)}...`);
1779
- // Yield the final accumulated text
1780
- yield {
1781
- role: "agent",
1782
- parts: [
1783
- {
1784
- type: "text",
1785
- text: accumulatedText || "(no response)",
1786
- },
1787
- ],
1788
- };
1789
- },
1790
- });
1791
- // Generate an "Available Actions" section from template-specific actions
1792
- // so the agent knows to use them instead of raw SQL.
1793
- //
1794
- // Production: actions are native tools — emit `name(arg*: type) — desc`
1795
- // Dev: actions are invoked via shell emit `pnpm action name --arg <type>`
1796
- // and include discoveredActions too, since those are also missing
1797
- // from the dev tool registry.
1798
- const prodActionsPrompt = generateActionsPrompt(templateScripts, "tool");
1799
- const devActionsPrompt = generateActionsPrompt({ ...discoveredActions, ...templateScripts }, "cli");
1800
- // Build system prompts — dynamic functions that pre-load resources per-request.
1801
- // Production gets PROD_FRAMEWORK_PROMPT, dev gets DEV_FRAMEWORK_PROMPT.
1802
- // Custom systemPrompt from options overrides the framework default entirely.
1803
- const prodPrompt = (options?.systemPrompt ?? PROD_FRAMEWORK_PROMPT) + prodActionsPrompt;
1804
- const devPrompt = (options?.devSystemPrompt
1805
- ? options.devSystemPrompt +
1806
- (options?.systemPrompt ?? PROD_FRAMEWORK_PROMPT)
1807
- : DEV_FRAMEWORK_PROMPT) + devActionsPrompt;
1808
- // Keep legacy names for the composition below
1809
- const basePrompt = prodPrompt;
1810
- const devPrefix = options?.devSystemPrompt ?? DEFAULT_DEV_PROMPT;
1811
- // Mount MCP remote server — same action registry as A2A + agent chat
1812
- const { mountMCP } = await import("../mcp/server.js");
1813
- mountMCP(nitroApp, {
1814
- name: options?.appId
1815
- ? options.appId.charAt(0).toUpperCase() + options.appId.slice(1)
1816
- : "Agent",
1817
- description: `Agent-native ${options?.appId ?? "app"} agent`,
1818
- actions: allScripts,
1819
- askAgent: async (message) => {
1820
- const mcpEngine = await resolveEngine({
1821
- engineOption: options?.engine,
1822
- apiKey: options?.apiKey,
2004
+ if (lastRole === "assistant" && lastContentIsEmpty) {
2005
+ // The frontend wrote an empty assistant placeholder before the stream
2006
+ // had any content (common when the user reloads mid-run, and the 5s
2007
+ // periodic save raced with the first text chunk). Replace it with
2008
+ // the server's reconstructed message so the turn isn't lost.
2009
+ repo.messages.pop();
2010
+ }
2011
+ // Determine if repo uses wrapped format ({ message, parentId }) or flat format
2012
+ const isWrapped = lastMsg && "message" in lastMsg;
2013
+ if (isWrapped) {
2014
+ const parentId = repo.messages.length > 0
2015
+ ? (repo.messages[repo.messages.length - 1].message?.id ??
2016
+ null)
2017
+ : null;
2018
+ repo.messages.push({ message: assistantMsg, parentId });
2019
+ }
2020
+ else {
2021
+ repo.messages.push(assistantMsg);
2022
+ }
2023
+ const meta = extractThreadMeta(repo);
2024
+ await updateThreadData(threadId, JSON.stringify(repo), meta.title || thread.title, meta.preview || thread.preview, repo.messages.length);
2025
+ }
2026
+ catch {
2027
+ // Best-effort don't break cleanup
2028
+ }
1823
2029
  });
1824
- const model = options?.model ??
1825
- (canToggle ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001");
1826
- // Same actions as A2A without call-agent to prevent loops.
1827
- // In dev mode, template actions go through shell, not native tools.
1828
- const devActiveMcp = isDevMode();
1829
- const mcpActions = devActiveMcp
2030
+ // Emit agent.turn.completed for automation triggers
2031
+ try {
2032
+ const { emit } = await import("../event-bus/index.js");
2033
+ emit("agent.turn.completed", {
2034
+ threadId,
2035
+ model: resolvedModel,
2036
+ });
2037
+ }
2038
+ catch {
2039
+ // Event bus not available — skip
2040
+ }
2041
+ // Auto-checkpoint in dev mode after file-modifying agent turns
2042
+ if (isDevMode()) {
2043
+ try {
2044
+ const { createCheckpoint: gitCheckpoint, isGitRepo, hasUncommittedChanges, } = await import("../checkpoints/service.js");
2045
+ const cwd = process.cwd();
2046
+ if (isGitRepo(cwd) && hasUncommittedChanges(cwd)) {
2047
+ const toolNames = new Set();
2048
+ for (const { event } of run.events ?? []) {
2049
+ if (event.type === "tool_start" &&
2050
+ typeof event.tool === "string") {
2051
+ toolNames.add(event.tool);
2052
+ }
2053
+ }
2054
+ const summary = toolNames.size > 0
2055
+ ? `Used: ${[...toolNames].join(", ")}`
2056
+ : "Agent turn";
2057
+ const sha = gitCheckpoint(cwd, `[agent-native] ${summary}`);
2058
+ if (sha) {
2059
+ const { insertCheckpoint } = await import("../checkpoints/store.js");
2060
+ const cpId = `cp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
2061
+ await insertCheckpoint(cpId, threadId, run.runId, sha, summary);
2062
+ }
2063
+ }
2064
+ }
2065
+ catch {
2066
+ // Checkpointing is best-effort — never break the run
2067
+ }
2068
+ }
2069
+ };
2070
+ // ─── Agent Teams: per-run send reference ─────────────────────────
2071
+ // Team tools need to emit events to the parent chat's SSE stream.
2072
+ // Each run gets its own send function, keyed by threadId so concurrent
2073
+ // requests for different threads don't clobber each other.
2074
+ const _runSendByThread = new Map();
2075
+ let _currentRunUserApiKey;
2076
+ let _currentRunThreadId = "";
2077
+ let _currentRunSystemPrompt = basePrompt;
2078
+ // Default to Haiku in production mode to manage costs for hosted apps
2079
+ const resolvedModel = options?.model ??
2080
+ (canToggle ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001");
2081
+ const teamTools = createTeamTools({
2082
+ getOwner: () => _currentRunOwner,
2083
+ getSystemPrompt: () => _currentRunSystemPrompt,
2084
+ getActions: () => isDevMode()
1830
2085
  ? {
2086
+ // Sub-agents spawned in dev mode also invoke template actions
2087
+ // via shell, so omit them from the native tool registry.
1831
2088
  ...resourceScripts,
1832
2089
  ...docsScripts,
1833
2090
  ...chatScripts,
@@ -1841,326 +2098,97 @@ export function createAgentChatPlugin(options) {
1841
2098
  ...refreshScreenTool,
1842
2099
  ...urlTools,
1843
2100
  ...chatScripts,
1844
- };
1845
- const mcpTools = actionsToEngineTools(mcpActions);
1846
- const resources = await loadResourcesForPrompt("local@localhost");
1847
- const schemaBlock = await buildSchemaBlock("local@localhost", devActiveMcp);
1848
- const systemPrompt = devActiveMcp
1849
- ? devPrompt + resources + schemaBlock
1850
- : basePrompt + resources + schemaBlock;
1851
- let accumulatedText = "";
1852
- const controller = new AbortController();
1853
- await runAgentLoop({
1854
- engine: mcpEngine,
1855
- model,
1856
- systemPrompt,
1857
- tools: mcpTools,
1858
- messages: [
1859
- { role: "user", content: [{ type: "text", text: message }] },
1860
- ],
1861
- actions: mcpActions,
1862
- send: (event) => {
1863
- if (event.type === "text")
1864
- accumulatedText += event.text;
1865
2101
  },
1866
- signal: controller.signal,
1867
- });
1868
- return accumulatedText || "(no response)";
1869
- },
1870
- });
1871
- // Resolve owner from the H3 event's session — matches how resources are created
1872
- const getOwnerFromEvent = async (event) => {
2102
+ getEngine: () => createAnthropicEngine({
2103
+ // Sub-agents must inherit the parent run's resolved key so a
2104
+ // BYO-key user can't bypass the free-tier check on the parent
2105
+ // run and then have spawn-task delegations bill the platform key.
2106
+ apiKey: _currentRunUserApiKey ??
2107
+ options?.apiKey ??
2108
+ process.env.ANTHROPIC_API_KEY,
2109
+ }),
2110
+ getModel: () => resolvedModel,
2111
+ getParentThreadId: () => _currentRunThreadId,
2112
+ getSend: () => {
2113
+ // Return the send for the current run's thread
2114
+ const send = _runSendByThread.get(_currentRunThreadId);
2115
+ return send ?? null;
2116
+ },
2117
+ });
2118
+ // Hook into the run lifecycle to set/clear the send reference.
2119
+ // Job management tools (create-job, list-jobs, update-job)
2120
+ let jobTools = {};
1873
2121
  try {
1874
- const session = await getSession(event);
1875
- return session?.email || "local@localhost";
1876
- }
1877
- catch {
1878
- return "local@localhost";
2122
+ const { createJobTools } = await import("../jobs/tools.js");
2123
+ jobTools = createJobTools();
1879
2124
  }
1880
- };
1881
- // Auto-mount template actions as HTTP endpoints under /_agent-native/actions/
1882
- // Include engine management scripts so the UI can call list/set/test-agent-engine.
1883
- const httpActions = {
1884
- ...discoveredActions,
1885
- ...templateScripts,
1886
- ...engineScripts,
1887
- };
1888
- // Framework-level sharing actions — merged with skipExisting semantics so
1889
- // any template that provides a same-named action wins. When templates use
1890
- // `loadActionsFromStaticRegistry`, `autoDiscoverActions` never runs, so
1891
- // this is the single point that guarantees share-resource, unshare-resource,
1892
- // list-resource-shares, and set-resource-visibility are always mounted.
1893
- try {
1894
- const { mergeCoreSharingActions } = await import("./action-discovery.js");
1895
- await mergeCoreSharingActions(httpActions);
1896
- }
1897
- catch {
1898
- // Ignore — templates without sharing still work.
1899
- }
1900
- if (Object.keys(httpActions).length > 0) {
1901
- const { mountActionRoutes } = await import("./action-routes.js");
1902
- mountActionRoutes(nitroApp, httpActions, {
1903
- getOwnerFromEvent,
1904
- resolveOrgId: options?.resolveOrgId,
2125
+ catch { }
2126
+ const prodActions = {
2127
+ ...templateScripts,
2128
+ ...resourceScripts,
2129
+ ...docsScripts,
2130
+ ...dbScripts,
2131
+ ...refreshScreenTool,
2132
+ ...urlTools,
2133
+ ...chatScripts,
2134
+ ...callAgentScript,
2135
+ ...teamTools,
2136
+ ...jobTools,
2137
+ ...automationTools,
2138
+ ...notificationTools,
2139
+ ...progressTools,
2140
+ ...fetchTool,
2141
+ ...browserTools,
2142
+ ...mcpActionEntries,
2143
+ };
2144
+ // Keep the prod action dict's MCP entries in sync when the manager's
2145
+ // server set changes at runtime (e.g. a user adds a remote MCP server
2146
+ // through the settings UI). getEngineTools() in production-agent re-reads
2147
+ // the registry per request, so updates here propagate without restart.
2148
+ mcpManager.onChange(() => {
2149
+ syncMcpActionEntries(mcpManager, prodActions);
1905
2150
  });
1906
- }
1907
- // Callback to persist agent response when run finishes (even if client disconnected).
1908
- // Reconstructs the assistant message from buffered events and appends to thread_data.
1909
- const onRunComplete = async (run, threadId) => {
1910
- if (!threadId)
1911
- return;
1912
- // Serialize the read-modify-write against the same thread's other
1913
- // `thread_data` writers (setThreadQueuedMessages, setThreadEngineMeta,
1914
- // the frontend-triggered saves below). Without the lock, a concurrent
1915
- // queued-message save can clobber the assistant message we just
1916
- // appended here, or vice versa.
1917
- await withThreadDataLock(threadId, async () => {
2151
+ // Always build the production handler (includes resource tools + call-agent + team tools)
2152
+ // In production mode (!canToggle), enable usage tracking and limits
2153
+ const isHostedProd = !canToggle;
2154
+ const resolveExtraContext = async (event, owner) => {
2155
+ if (!options?.extraContext)
2156
+ return "";
1918
2157
  try {
1919
- const thread = await getThread(threadId);
1920
- if (!thread)
1921
- return;
1922
- const assistantMsg = buildAssistantMessage(run.events ?? [], run.runId);
1923
- if (!assistantMsg) {
1924
- // No content produced — just bump timestamp
1925
- await updateThreadData(threadId, thread.threadData, thread.title, thread.preview, thread.messageCount);
1926
- return;
1927
- }
1928
- // Parse existing thread_data, append assistant message only if
1929
- // the frontend hasn't already saved it (avoids duplicates when
1930
- // the client is still connected during a normal flow).
1931
- let repo;
1932
- try {
1933
- repo = JSON.parse(thread.threadData || "{}");
1934
- }
1935
- catch {
1936
- repo = {};
1937
- }
1938
- if (!Array.isArray(repo.messages))
1939
- repo.messages = [];
1940
- const lastMsg = repo.messages[repo.messages.length - 1];
1941
- // Check both wrapped ({ message: { role } }) and unwrapped ({ role }) formats
1942
- const lastRole = lastMsg?.message?.role ?? lastMsg?.role;
1943
- const lastContent = lastMsg?.message?.content ?? lastMsg?.content;
1944
- const lastContentIsEmpty = Array.isArray(lastContent)
1945
- ? lastContent.length === 0
1946
- : lastContent == null || lastContent === "";
1947
- if (lastRole === "assistant" && !lastContentIsEmpty) {
1948
- // Frontend already saved the assistant response — just bump timestamp
1949
- await updateThreadData(threadId, thread.threadData, thread.title, thread.preview, thread.messageCount);
1950
- return;
1951
- }
1952
- if (lastRole === "assistant" && lastContentIsEmpty) {
1953
- // The frontend wrote an empty assistant placeholder before the stream
1954
- // had any content (common when the user reloads mid-run, and the 5s
1955
- // periodic save raced with the first text chunk). Replace it with
1956
- // the server's reconstructed message so the turn isn't lost.
1957
- repo.messages.pop();
1958
- }
1959
- // Determine if repo uses wrapped format ({ message, parentId }) or flat format
1960
- const isWrapped = lastMsg && "message" in lastMsg;
1961
- if (isWrapped) {
1962
- const parentId = repo.messages.length > 0
1963
- ? (repo.messages[repo.messages.length - 1].message?.id ?? null)
1964
- : null;
1965
- repo.messages.push({ message: assistantMsg, parentId });
1966
- }
1967
- else {
1968
- repo.messages.push(assistantMsg);
1969
- }
1970
- const meta = extractThreadMeta(repo);
1971
- await updateThreadData(threadId, JSON.stringify(repo), meta.title || thread.title, meta.preview || thread.preview, repo.messages.length);
2158
+ const extra = await options.extraContext(event, owner);
2159
+ return extra ? `\n\n${extra}` : "";
1972
2160
  }
1973
- catch {
1974
- // Best-effort don't break cleanup
1975
- }
1976
- });
1977
- };
1978
- // ─── Agent Teams: per-run send reference ─────────────────────────
1979
- // Team tools need to emit events to the parent chat's SSE stream.
1980
- // Each run gets its own send function, keyed by threadId so concurrent
1981
- // requests for different threads don't clobber each other.
1982
- const _runSendByThread = new Map();
1983
- let _currentRunOwner = "local@localhost";
1984
- let _currentRunUserApiKey;
1985
- let _currentRunThreadId = "";
1986
- let _currentRunSystemPrompt = basePrompt;
1987
- // Default to Haiku in production mode to manage costs for hosted apps
1988
- const resolvedModel = options?.model ??
1989
- (canToggle ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001");
1990
- const teamTools = createTeamTools({
1991
- getOwner: () => _currentRunOwner,
1992
- getSystemPrompt: () => _currentRunSystemPrompt,
1993
- getActions: () => isDevMode()
1994
- ? {
1995
- // Sub-agents spawned in dev mode also invoke template actions
1996
- // via shell, so omit them from the native tool registry.
1997
- ...resourceScripts,
1998
- ...docsScripts,
1999
- ...chatScripts,
2000
- ...devScriptsForA2A,
2001
- }
2002
- : {
2003
- ...templateScripts,
2004
- ...resourceScripts,
2005
- ...docsScripts,
2006
- ...dbScripts,
2007
- ...refreshScreenTool,
2008
- ...urlTools,
2009
- ...chatScripts,
2010
- },
2011
- getEngine: () => createAnthropicEngine({
2012
- // Sub-agents must inherit the parent run's resolved key so a
2013
- // BYO-key user can't bypass the free-tier check on the parent
2014
- // run and then have spawn-task delegations bill the platform key.
2015
- apiKey: _currentRunUserApiKey ??
2016
- options?.apiKey ??
2017
- process.env.ANTHROPIC_API_KEY,
2018
- }),
2019
- getModel: () => resolvedModel,
2020
- getParentThreadId: () => _currentRunThreadId,
2021
- getSend: () => {
2022
- // Return the send for the current run's thread
2023
- const send = _runSendByThread.get(_currentRunThreadId);
2024
- return send ?? null;
2025
- },
2026
- });
2027
- // Hook into the run lifecycle to set/clear the send reference.
2028
- // Job management tools (create-job, list-jobs, update-job)
2029
- let jobTools = {};
2030
- try {
2031
- const { createJobTools } = await import("../jobs/tools.js");
2032
- jobTools = createJobTools();
2033
- }
2034
- catch { }
2035
- const prodActions = {
2036
- ...templateScripts,
2037
- ...resourceScripts,
2038
- ...docsScripts,
2039
- ...dbScripts,
2040
- ...refreshScreenTool,
2041
- ...urlTools,
2042
- ...chatScripts,
2043
- ...callAgentScript,
2044
- ...teamTools,
2045
- ...jobTools,
2046
- ...browserTools,
2047
- ...mcpActionEntries,
2048
- };
2049
- // Keep the prod action dict's MCP entries in sync when the manager's
2050
- // server set changes at runtime (e.g. a user adds a remote MCP server
2051
- // through the settings UI). getEngineTools() in production-agent re-reads
2052
- // the registry per request, so updates here propagate without restart.
2053
- mcpManager.onChange(() => {
2054
- syncMcpActionEntries(mcpManager, prodActions);
2055
- });
2056
- // Always build the production handler (includes resource tools + call-agent + team tools)
2057
- // In production mode (!canToggle), enable usage tracking and limits
2058
- const isHostedProd = !canToggle;
2059
- const resolveExtraContext = async (event, owner) => {
2060
- if (!options?.extraContext)
2061
- return "";
2062
- try {
2063
- const extra = await options.extraContext(event, owner);
2064
- return extra ? `\n\n${extra}` : "";
2065
- }
2066
- catch (err) {
2067
- console.warn("[agent-chat] extraContext threw:", err instanceof Error ? err.message : err);
2068
- return "";
2069
- }
2070
- };
2071
- const leanPrompt = options?.leanPrompt === true;
2072
- // Lean mode: use only the template's systemPrompt + actions list.
2073
- // Skip resource loading, schema block, and extraContext — those add
2074
- // DB round-trips and tokens that minimal/voice apps don't need.
2075
- const leanBasePrompt = (options?.systemPrompt ?? "") + prodActionsPrompt;
2076
- const prodHandler = createProductionAgentHandler({
2077
- actions: prodActions,
2078
- systemPrompt: async (event) => {
2079
- _currentRequestOrigin = getOrigin(event);
2080
- const owner = await getOwnerFromEvent(event);
2081
- _currentRunOwner = owner;
2082
- const { getOwnerAnthropicApiKey } = await import("../agent/production-agent.js");
2083
- _currentRunUserApiKey = await getOwnerAnthropicApiKey(owner);
2084
- if (leanPrompt) {
2085
- _currentRunSystemPrompt = leanBasePrompt;
2086
- return _currentRunSystemPrompt;
2161
+ catch (err) {
2162
+ console.warn("[agent-chat] extraContext threw:", err instanceof Error ? err.message : err);
2163
+ return "";
2087
2164
  }
2088
- const resources = await loadResourcesForPrompt(owner);
2089
- const schemaBlock = await buildSchemaBlock(owner, false);
2090
- const extra = await resolveExtraContext(event, owner);
2091
- _currentRunSystemPrompt = basePrompt + resources + schemaBlock + extra;
2092
- return _currentRunSystemPrompt;
2093
- },
2094
- model: options?.model ??
2095
- (isHostedProd ? "claude-haiku-4-5-20251001" : undefined),
2096
- apiKey: options?.apiKey,
2097
- skipFilesContext: leanPrompt,
2098
- onRunStart: (send, threadId) => {
2099
- _runSendByThread.set(threadId, send);
2100
- _currentRunThreadId = threadId;
2101
- },
2102
- onRunComplete: async (run, threadId) => {
2103
- if (threadId)
2104
- _runSendByThread.delete(threadId);
2105
- await onRunComplete(run, threadId);
2106
- },
2107
- // Usage tracking for hosted production deployments
2108
- trackUsage: isHostedProd,
2109
- resolveOwnerEmail: isHostedProd ? getOwnerFromEvent : undefined,
2110
- });
2111
- // Build the dev handler (with filesystem/shell/db tools) if environment allows toggling
2112
- let devHandler = null;
2113
- if (canToggle) {
2114
- const { createDevScriptRegistry } = await import("../scripts/dev/index.js");
2115
- // Dev mode: template actions (templateScripts and discoveredActions) are
2116
- // intentionally OMITTED from the native tool registry. The agent invokes
2117
- // them via `shell(command="pnpm action <name> ...")` instead. This mirrors
2118
- // how Claude Code works locally and dramatically reduces the rate of
2119
- // degenerate empty-object tool calls. The CLI syntax for each action is
2120
- // listed in the dev system prompt's "Available Actions" section.
2121
- // In lean mode, expose the template's actions directly as native tools
2122
- // instead of routing through shell — the lean system prompt has no
2123
- // shell-usage guidance, so shell-based action invocation would break.
2124
- const devActions = leanPrompt
2125
- ? prodActions
2126
- : {
2127
- ...resourceScripts,
2128
- ...docsScripts,
2129
- ...chatScripts,
2130
- ...callAgentScript,
2131
- ...teamTools,
2132
- ...jobTools,
2133
- ...browserTools,
2134
- ...mcpActionEntries,
2135
- ...(await createDevScriptRegistry()),
2136
- };
2137
- // Keep dev action dict in sync with runtime MCP additions. When
2138
- // leanPrompt is true, devActions === prodActions so the prod listener
2139
- // already covers it.
2140
- if (devActions !== prodActions) {
2141
- mcpManager.onChange(() => {
2142
- syncMcpActionEntries(mcpManager, devActions);
2143
- });
2144
- }
2145
- devHandler = createProductionAgentHandler({
2146
- actions: devActions,
2165
+ };
2166
+ const leanPrompt = options?.leanPrompt === true;
2167
+ // Lean mode: use only the template's systemPrompt + actions list.
2168
+ // Skip resource loading, schema block, and extraContext — those add
2169
+ // DB round-trips and tokens that minimal/voice apps don't need.
2170
+ const leanBasePrompt = (options?.systemPrompt ?? "") + prodActionsPrompt;
2171
+ const prodHandler = createProductionAgentHandler({
2172
+ actions: prodActions,
2147
2173
  systemPrompt: async (event) => {
2148
2174
  _currentRequestOrigin = getOrigin(event);
2149
2175
  const owner = await getOwnerFromEvent(event);
2150
2176
  _currentRunOwner = owner;
2151
- const { getOwnerAnthropicApiKey } = await import("../agent/production-agent.js");
2152
- _currentRunUserApiKey = await getOwnerAnthropicApiKey(owner);
2177
+ const { getOwnerActiveApiKey } = await import("../agent/production-agent.js");
2178
+ _currentRunUserApiKey = await getOwnerActiveApiKey(owner);
2153
2179
  if (leanPrompt) {
2154
2180
  _currentRunSystemPrompt = leanBasePrompt;
2155
2181
  return _currentRunSystemPrompt;
2156
2182
  }
2157
2183
  const resources = await loadResourcesForPrompt(owner);
2158
- const schemaBlock = await buildSchemaBlock(owner, true);
2184
+ const schemaBlock = await buildSchemaBlock(owner, false);
2159
2185
  const extra = await resolveExtraContext(event, owner);
2160
- _currentRunSystemPrompt = devPrompt + resources + schemaBlock + extra;
2186
+ _currentRunSystemPrompt =
2187
+ basePrompt + resources + schemaBlock + extra;
2161
2188
  return _currentRunSystemPrompt;
2162
2189
  },
2163
- model: options?.model,
2190
+ model: options?.model ??
2191
+ (isHostedProd ? "claude-haiku-4-5-20251001" : undefined),
2164
2192
  apiKey: options?.apiKey,
2165
2193
  skipFilesContext: leanPrompt,
2166
2194
  onRunStart: (send, threadId) => {
@@ -2172,763 +2200,982 @@ export function createAgentChatPlugin(options) {
2172
2200
  _runSendByThread.delete(threadId);
2173
2201
  await onRunComplete(run, threadId);
2174
2202
  },
2203
+ // Usage tracking for hosted production deployments
2204
+ trackUsage: isHostedProd,
2205
+ resolveOwnerEmail: isHostedProd ? getOwnerFromEvent : undefined,
2175
2206
  });
2176
- }
2177
- // Resolve mention providers
2178
- const rawProviders = options?.mentionProviders;
2179
- const mentionProviders = typeof rawProviders === "function"
2180
- ? await rawProviders()
2181
- : (rawProviders ?? {});
2182
- // currentDevMode + persistence were hoisted to the top of this function
2183
- // so every closure built below can close over the live flag.
2184
- // Mount mode endpoint GET returns current mode, POST toggles it (localhost only)
2185
- getH3App(nitroApp).use(`${routePath}/mode`, defineEventHandler(async (event) => {
2186
- if (getMethod(event) === "POST") {
2187
- if (!canToggle) {
2188
- setResponseStatus(event, 403);
2189
- return { error: "Mode switching not available in production" };
2190
- }
2191
- if (!isLocalhost(event)) {
2192
- setResponseStatus(event, 403);
2193
- return { error: "Mode switching only available on localhost" };
2207
+ // Build the dev handler (with filesystem/shell/db tools) if environment allows toggling
2208
+ let devHandler = null;
2209
+ if (canToggle) {
2210
+ const { createDevScriptRegistry } = await import("../scripts/dev/index.js");
2211
+ // Dev mode: template actions (templateScripts and discoveredActions) are
2212
+ // intentionally OMITTED from the native tool registry. The agent invokes
2213
+ // them via `shell(command="pnpm action <name> ...")` instead. This mirrors
2214
+ // how Claude Code works locally and dramatically reduces the rate of
2215
+ // degenerate empty-object tool calls. The CLI syntax for each action is
2216
+ // listed in the dev system prompt's "Available Actions" section.
2217
+ // In lean mode, expose the template's actions directly as native tools
2218
+ // instead of routing through shell — the lean system prompt has no
2219
+ // shell-usage guidance, so shell-based action invocation would break.
2220
+ const devActions = leanPrompt
2221
+ ? prodActions
2222
+ : {
2223
+ ...resourceScripts,
2224
+ ...docsScripts,
2225
+ ...chatScripts,
2226
+ ...callAgentScript,
2227
+ ...teamTools,
2228
+ ...jobTools,
2229
+ ...automationTools,
2230
+ ...notificationTools,
2231
+ ...progressTools,
2232
+ ...fetchTool,
2233
+ ...browserTools,
2234
+ ...mcpActionEntries,
2235
+ ...(await createDevScriptRegistry()),
2236
+ };
2237
+ // Keep dev action dict in sync with runtime MCP additions. When
2238
+ // leanPrompt is true, devActions === prodActions so the prod listener
2239
+ // already covers it.
2240
+ if (devActions !== prodActions) {
2241
+ mcpManager.onChange(() => {
2242
+ syncMcpActionEntries(mcpManager, devActions);
2243
+ });
2194
2244
  }
2195
- const body = await readBody(event);
2196
- if (typeof body?.devMode === "boolean") {
2197
- currentDevMode = body.devMode;
2245
+ devHandler = createProductionAgentHandler({
2246
+ actions: devActions,
2247
+ systemPrompt: async (event) => {
2248
+ _currentRequestOrigin = getOrigin(event);
2249
+ const owner = await getOwnerFromEvent(event);
2250
+ _currentRunOwner = owner;
2251
+ const { getOwnerActiveApiKey } = await import("../agent/production-agent.js");
2252
+ _currentRunUserApiKey = await getOwnerActiveApiKey(owner);
2253
+ if (leanPrompt) {
2254
+ _currentRunSystemPrompt = leanBasePrompt;
2255
+ return _currentRunSystemPrompt;
2256
+ }
2257
+ const resources = await loadResourcesForPrompt(owner);
2258
+ const schemaBlock = await buildSchemaBlock(owner, true);
2259
+ const extra = await resolveExtraContext(event, owner);
2260
+ _currentRunSystemPrompt =
2261
+ devPrompt + resources + schemaBlock + extra;
2262
+ return _currentRunSystemPrompt;
2263
+ },
2264
+ model: options?.model,
2265
+ apiKey: options?.apiKey,
2266
+ skipFilesContext: leanPrompt,
2267
+ onRunStart: (send, threadId) => {
2268
+ _runSendByThread.set(threadId, send);
2269
+ _currentRunThreadId = threadId;
2270
+ },
2271
+ onRunComplete: async (run, threadId) => {
2272
+ if (threadId)
2273
+ _runSendByThread.delete(threadId);
2274
+ await onRunComplete(run, threadId);
2275
+ },
2276
+ });
2277
+ }
2278
+ // Resolve mention providers
2279
+ const rawProviders = options?.mentionProviders;
2280
+ const mentionProviders = typeof rawProviders === "function"
2281
+ ? await rawProviders()
2282
+ : (rawProviders ?? {});
2283
+ // currentDevMode + persistence were hoisted to the top of this function
2284
+ // so every closure built below can close over the live flag.
2285
+ // Mount mode endpoint — GET returns current mode, POST toggles it (localhost only)
2286
+ getH3App(nitroApp).use(`${routePath}/mode`, defineEventHandler(async (event) => {
2287
+ if (getMethod(event) === "POST") {
2288
+ if (!canToggle) {
2289
+ setResponseStatus(event, 403);
2290
+ return { error: "Mode switching not available in production" };
2291
+ }
2292
+ if (!isLocalhost(event)) {
2293
+ setResponseStatus(event, 403);
2294
+ return { error: "Mode switching only available on localhost" };
2295
+ }
2296
+ const body = await readBody(event);
2297
+ if (typeof body?.devMode === "boolean") {
2298
+ currentDevMode = body.devMode;
2299
+ }
2300
+ else {
2301
+ currentDevMode = !currentDevMode;
2302
+ }
2303
+ try {
2304
+ await putSetting(AGENT_MODE_SETTING_KEY, {
2305
+ devMode: currentDevMode,
2306
+ });
2307
+ }
2308
+ catch {
2309
+ // Persistence is best-effort — in-memory flag still applies for
2310
+ // the lifetime of this process even if the settings write fails.
2311
+ }
2312
+ return { devMode: currentDevMode, canToggle };
2198
2313
  }
2199
- else {
2200
- currentDevMode = !currentDevMode;
2314
+ return { devMode: currentDevMode, canToggle };
2315
+ }));
2316
+ // Mount save-key BEFORE the prefix handler so it isn't shadowed.
2317
+ // Persists the user's key per-owner in the SQL settings table so it
2318
+ // survives across serverless invocations (where mutating process.env
2319
+ // and writing .env are both no-ops). Also updates process.env and
2320
+ // .env when running locally for fast pickup by other handlers.
2321
+ getH3App(nitroApp).use(`${routePath}/save-key`, defineEventHandler(async (event) => {
2322
+ if (getMethod(event) !== "POST") {
2323
+ setResponseStatus(event, 405);
2324
+ return { error: "Method not allowed" };
2201
2325
  }
2202
- try {
2203
- await putSetting(AGENT_MODE_SETTING_KEY, {
2204
- devMode: currentDevMode,
2205
- });
2326
+ const body = await readBody(event);
2327
+ const { key, provider: rawProvider } = body;
2328
+ const provider = rawProvider || "anthropic";
2329
+ if (!key || typeof key !== "string" || !key.trim()) {
2330
+ setResponseStatus(event, 400);
2331
+ return { error: "API key is required" };
2206
2332
  }
2207
- catch {
2208
- // Persistence is best-effort in-memory flag still applies for
2209
- // the lifetime of this process even if the settings write fails.
2333
+ const trimmedKey = key.trim();
2334
+ // Persist per-owner so the key survives cold starts in serverless
2335
+ // and so the user's key isn't shared across users on multi-tenant
2336
+ // hosted deployments. We require a real authenticated owner here —
2337
+ // `local@localhost` is the unauthenticated fallback and must never
2338
+ // become the shared key bucket on hosted deployments.
2339
+ const ownerEmail = await getOwnerFromEvent(event);
2340
+ if (isHostedProd &&
2341
+ (!ownerEmail || ownerEmail === "local@localhost")) {
2342
+ setResponseStatus(event, 401);
2343
+ return { error: "Authentication required" };
2210
2344
  }
2211
- return { devMode: currentDevMode, canToggle };
2212
- }
2213
- return { devMode: currentDevMode, canToggle };
2214
- }));
2215
- // Mount save-key BEFORE the prefix handler so it isn't shadowed.
2216
- // Persists the user's key per-owner in the SQL settings table so it
2217
- // survives across serverless invocations (where mutating process.env
2218
- // and writing .env are both no-ops). Also updates process.env and
2219
- // .env when running locally for fast pickup by other handlers.
2220
- getH3App(nitroApp).use(`${routePath}/save-key`, defineEventHandler(async (event) => {
2221
- if (getMethod(event) !== "POST") {
2222
- setResponseStatus(event, 405);
2223
- return { error: "Method not allowed" };
2224
- }
2225
- const body = await readBody(event);
2226
- const { key } = body;
2227
- if (!key || typeof key !== "string" || !key.trim()) {
2228
- setResponseStatus(event, 400);
2229
- return { error: "API key is required" };
2230
- }
2231
- const trimmedKey = key.trim();
2232
- // Persist per-owner so the key survives cold starts in serverless
2233
- // and so the user's key isn't shared across users on multi-tenant
2234
- // hosted deployments. We require a real authenticated owner here —
2235
- // `local@localhost` is the unauthenticated fallback and must never
2236
- // become the shared key bucket on hosted deployments.
2237
- const ownerEmail = await getOwnerFromEvent(event);
2238
- if (isHostedProd && (!ownerEmail || ownerEmail === "local@localhost")) {
2239
- setResponseStatus(event, 401);
2240
- return { error: "Authentication required" };
2241
- }
2242
- if (ownerEmail && ownerEmail !== "local@localhost") {
2243
- try {
2244
- await putSetting(`user-anthropic-api-key:${ownerEmail}`, {
2245
- key: trimmedKey,
2246
- });
2247
- // Verify the write actually landed — some managed DB drivers
2248
- // swallow errors on degraded connections. Without this the
2249
- // client sees "saved", reloads, and the usage-limit card
2250
- // re-appears on the next message because the key isn't
2251
- // really persisted.
2252
- const check = await getSetting(`user-anthropic-api-key:${ownerEmail}`);
2253
- if (!check ||
2254
- typeof check.key !== "string" ||
2255
- check.key !== trimmedKey) {
2256
- throw new Error("settings write did not persist");
2345
+ if (ownerEmail && ownerEmail !== "local@localhost") {
2346
+ try {
2347
+ await putSetting(`user-api-key:${provider}:${ownerEmail}`, {
2348
+ key: trimmedKey,
2349
+ });
2350
+ // Verify the write actually landed some managed DB drivers
2351
+ // swallow errors on degraded connections. Without this the
2352
+ // client sees "saved", reloads, and the usage-limit card
2353
+ // re-appears on the next message because the key isn't
2354
+ // really persisted.
2355
+ const check = await getSetting(`user-api-key:${provider}:${ownerEmail}`);
2356
+ if (!check ||
2357
+ typeof check.key !== "string" ||
2358
+ check.key !== trimmedKey) {
2359
+ throw new Error("settings write did not persist");
2360
+ }
2361
+ }
2362
+ catch (err) {
2363
+ if (isHostedProd) {
2364
+ console.error("[agent-chat] save-key persistence failed:", err instanceof Error ? err.message : err);
2365
+ setResponseStatus(event, 500);
2366
+ return {
2367
+ error: "Failed to persist API key. Please try again or contact support.",
2368
+ };
2369
+ }
2370
+ // Local dev falls through to the env-file path below.
2257
2371
  }
2258
2372
  }
2259
- catch (err) {
2260
- if (isHostedProd) {
2261
- console.error("[agent-chat] save-key persistence failed:", err instanceof Error ? err.message : err);
2262
- setResponseStatus(event, 500);
2263
- return {
2264
- error: "Failed to persist API key. Please try again or contact support.",
2265
- };
2373
+ // In hosted/multi-tenant mode we deliberately do NOT touch
2374
+ // process.env or .env: the per-owner SQL lookup above is the
2375
+ // single source of truth, and overwriting the shared env key
2376
+ // would leak one tenant's credentials into every subsequent
2377
+ // request that hit the same warm instance without its own key.
2378
+ if (!isHostedProd) {
2379
+ const providerToEnv = {
2380
+ anthropic: "ANTHROPIC_API_KEY",
2381
+ openai: "OPENAI_API_KEY",
2382
+ google: "GOOGLE_GENERATIVE_AI_API_KEY",
2383
+ groq: "GROQ_API_KEY",
2384
+ mistral: "MISTRAL_API_KEY",
2385
+ cohere: "COHERE_API_KEY",
2386
+ };
2387
+ const envVar = providerToEnv[provider] ?? `${provider.toUpperCase()}_API_KEY`;
2388
+ try {
2389
+ const path = await import("path");
2390
+ const { upsertEnvFile } = await import("./create-server.js");
2391
+ const envPath = path.join(process.cwd(), ".env");
2392
+ await upsertEnvFile(envPath, [
2393
+ { key: envVar, value: trimmedKey },
2394
+ ]);
2266
2395
  }
2267
- // Local dev falls through to the env-file path below.
2396
+ catch {
2397
+ // Edge runtime — can't write .env, but can still update process.env
2398
+ }
2399
+ // Update process.env so the agent works immediately in the
2400
+ // current local-dev invocation; the SQL persist above covers
2401
+ // future invocations.
2402
+ process.env[envVar] = trimmedKey;
2268
2403
  }
2269
- }
2270
- // In hosted/multi-tenant mode we deliberately do NOT touch
2271
- // process.env or .env: the per-owner SQL lookup above is the
2272
- // single source of truth, and overwriting the shared env key
2273
- // would leak one tenant's credentials into every subsequent
2274
- // request that hit the same warm instance without its own key.
2275
- if (!isHostedProd) {
2276
- try {
2277
- const path = await import("path");
2278
- const { upsertEnvFile } = await import("./create-server.js");
2279
- const envPath = path.join(process.cwd(), ".env");
2280
- await upsertEnvFile(envPath, [
2281
- { key: "ANTHROPIC_API_KEY", value: trimmedKey },
2282
- ]);
2404
+ return { ok: true };
2405
+ }));
2406
+ // Mount file search endpoint
2407
+ getH3App(nitroApp).use(`${routePath}/files`, defineEventHandler(async (event) => {
2408
+ if (getMethod(event) !== "GET") {
2409
+ setResponseStatus(event, 405);
2410
+ return { error: "Method not allowed" };
2283
2411
  }
2284
- catch {
2285
- // Edge runtime can't write .env, but can still update process.env
2412
+ const query = getQuery(event);
2413
+ const q = typeof query.q === "string" ? query.q.toLowerCase() : "";
2414
+ const files = [];
2415
+ const seen = new Set();
2416
+ // In dev mode, walk the filesystem
2417
+ if (currentDevMode) {
2418
+ const codebaseFiles = [];
2419
+ try {
2420
+ await collectFiles(process.cwd(), "", 0, codebaseFiles);
2421
+ }
2422
+ catch {
2423
+ // Filesystem access failed — skip
2424
+ }
2425
+ for (const f of codebaseFiles) {
2426
+ if (!seen.has(f.path)) {
2427
+ seen.add(f.path);
2428
+ files.push({
2429
+ path: f.path,
2430
+ name: f.name,
2431
+ source: "codebase",
2432
+ type: f.type,
2433
+ });
2434
+ }
2435
+ }
2286
2436
  }
2287
- // Update process.env so the agent works immediately in the
2288
- // current local-dev invocation; the SQL persist above covers
2289
- // future invocations.
2290
- process.env.ANTHROPIC_API_KEY = trimmedKey;
2291
- }
2292
- return { ok: true };
2293
- }));
2294
- // Mount file search endpoint
2295
- getH3App(nitroApp).use(`${routePath}/files`, defineEventHandler(async (event) => {
2296
- if (getMethod(event) !== "GET") {
2297
- setResponseStatus(event, 405);
2298
- return { error: "Method not allowed" };
2299
- }
2300
- const query = getQuery(event);
2301
- const q = typeof query.q === "string" ? query.q.toLowerCase() : "";
2302
- const files = [];
2303
- const seen = new Set();
2304
- // In dev mode, walk the filesystem
2305
- if (currentDevMode) {
2306
- const codebaseFiles = [];
2437
+ // Query resources
2307
2438
  try {
2308
- await collectFiles(process.cwd(), "", 0, codebaseFiles);
2439
+ const resources = currentDevMode
2440
+ ? await resourceListAccessible("local@localhost")
2441
+ : await resourceList(SHARED_OWNER);
2442
+ for (const r of resources) {
2443
+ if (!seen.has(r.path)) {
2444
+ seen.add(r.path);
2445
+ files.push({
2446
+ path: r.path,
2447
+ name: r.path.split("/").pop() || r.path,
2448
+ source: "resource",
2449
+ type: "file",
2450
+ });
2451
+ }
2452
+ }
2309
2453
  }
2310
2454
  catch {
2311
- // Filesystem access failed — skip
2455
+ // Resources not available — skip
2312
2456
  }
2313
- for (const f of codebaseFiles) {
2314
- if (!seen.has(f.path)) {
2315
- seen.add(f.path);
2316
- files.push({
2317
- path: f.path,
2318
- name: f.name,
2319
- source: "codebase",
2320
- type: f.type,
2321
- });
2322
- }
2457
+ // Filter by query and limit
2458
+ const filtered = q
2459
+ ? files.filter((f) => f.path.toLowerCase().includes(q))
2460
+ : files;
2461
+ return { files: filtered.slice(0, 30) };
2462
+ }));
2463
+ // Mount skills listing endpoint
2464
+ getH3App(nitroApp).use(`${routePath}/skills`, defineEventHandler(async (event) => {
2465
+ if (getMethod(event) !== "GET") {
2466
+ setResponseStatus(event, 405);
2467
+ return { error: "Method not allowed" };
2323
2468
  }
2324
- }
2325
- // Query resources
2326
- try {
2327
- const resources = currentDevMode
2328
- ? await resourceListAccessible("local@localhost")
2329
- : await resourceList(SHARED_OWNER);
2330
- for (const r of resources) {
2331
- if (!seen.has(r.path)) {
2332
- seen.add(r.path);
2333
- files.push({
2334
- path: r.path,
2335
- name: r.path.split("/").pop() || r.path,
2336
- source: "resource",
2337
- type: "file",
2469
+ const skills = [];
2470
+ const seenNames = new Set();
2471
+ // In dev mode, scan .agents/skills/ directory
2472
+ if (currentDevMode) {
2473
+ try {
2474
+ const _fs = await lazyFs();
2475
+ const skillsDir = nodePath.join(process.cwd(), ".agents", "skills");
2476
+ const entries = _fs.readdirSync(skillsDir, {
2477
+ withFileTypes: true,
2338
2478
  });
2479
+ for (const entry of entries) {
2480
+ // Support both flat .md files and subdirectory-based skills (dir/SKILL.md)
2481
+ let skillFilePath;
2482
+ let skillRelPath;
2483
+ if (entry.isDirectory()) {
2484
+ // Subdirectory layout: .agents/skills/<name>/SKILL.md
2485
+ const candidate = nodePath.join(skillsDir, entry.name, "SKILL.md");
2486
+ if (!_fs.existsSync(candidate))
2487
+ continue;
2488
+ skillFilePath = candidate;
2489
+ skillRelPath = `.agents/skills/${entry.name}/SKILL.md`;
2490
+ }
2491
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
2492
+ // Flat layout: .agents/skills/<name>.md
2493
+ skillFilePath = nodePath.join(skillsDir, entry.name);
2494
+ skillRelPath = `.agents/skills/${entry.name}`;
2495
+ }
2496
+ else {
2497
+ continue;
2498
+ }
2499
+ try {
2500
+ const content = _fs.readFileSync(skillFilePath, "utf-8");
2501
+ const fm = parseSkillFrontmatter(content);
2502
+ const skillName = fm.name || entry.name.replace(/\.md$/, "");
2503
+ if (!seenNames.has(skillName)) {
2504
+ seenNames.add(skillName);
2505
+ skills.push({
2506
+ name: skillName,
2507
+ description: fm.description,
2508
+ path: skillRelPath,
2509
+ source: "codebase",
2510
+ });
2511
+ }
2512
+ }
2513
+ catch {
2514
+ // Could not read individual skill file — skip
2515
+ }
2516
+ }
2517
+ }
2518
+ catch {
2519
+ // .agents/skills/ directory doesn't exist or not readable — skip
2339
2520
  }
2340
2521
  }
2341
- }
2342
- catch {
2343
- // Resources not available — skip
2344
- }
2345
- // Filter by query and limit
2346
- const filtered = q
2347
- ? files.filter((f) => f.path.toLowerCase().includes(q))
2348
- : files;
2349
- return { files: filtered.slice(0, 30) };
2350
- }));
2351
- // Mount skills listing endpoint
2352
- getH3App(nitroApp).use(`${routePath}/skills`, defineEventHandler(async (event) => {
2353
- if (getMethod(event) !== "GET") {
2354
- setResponseStatus(event, 405);
2355
- return { error: "Method not allowed" };
2356
- }
2357
- const skills = [];
2358
- const seenNames = new Set();
2359
- // In dev mode, scan .agents/skills/ directory
2360
- if (currentDevMode) {
2522
+ // Query resources with skills/ prefix
2361
2523
  try {
2362
- const _fs = await lazyFs();
2363
- const skillsDir = nodePath.join(process.cwd(), ".agents", "skills");
2364
- const entries = _fs.readdirSync(skillsDir, {
2365
- withFileTypes: true,
2366
- });
2367
- for (const entry of entries) {
2368
- // Support both flat .md files and subdirectory-based skills (dir/SKILL.md)
2369
- let skillFilePath;
2370
- let skillRelPath;
2371
- if (entry.isDirectory()) {
2372
- // Subdirectory layout: .agents/skills/<name>/SKILL.md
2373
- const candidate = nodePath.join(skillsDir, entry.name, "SKILL.md");
2374
- if (!_fs.existsSync(candidate))
2375
- continue;
2376
- skillFilePath = candidate;
2377
- skillRelPath = `.agents/skills/${entry.name}/SKILL.md`;
2378
- }
2379
- else if (entry.isFile() && entry.name.endsWith(".md")) {
2380
- // Flat layout: .agents/skills/<name>.md
2381
- skillFilePath = nodePath.join(skillsDir, entry.name);
2382
- skillRelPath = `.agents/skills/${entry.name}`;
2383
- }
2384
- else {
2385
- continue;
2386
- }
2524
+ const resourceSkills = currentDevMode
2525
+ ? await resourceListAccessible("local@localhost", "skills/")
2526
+ : await resourceList(SHARED_OWNER, "skills/");
2527
+ for (const r of resourceSkills) {
2528
+ // Try to get content to parse frontmatter
2529
+ let skillName = r.path.split("/").pop()?.replace(/\.md$/, "") || r.path;
2530
+ let description;
2387
2531
  try {
2388
- const content = _fs.readFileSync(skillFilePath, "utf-8");
2389
- const fm = parseSkillFrontmatter(content);
2390
- const skillName = fm.name || entry.name.replace(/\.md$/, "");
2391
- if (!seenNames.has(skillName)) {
2392
- seenNames.add(skillName);
2393
- skills.push({
2394
- name: skillName,
2395
- description: fm.description,
2396
- path: skillRelPath,
2397
- source: "codebase",
2398
- });
2532
+ const full = await resourceGet(r.id);
2533
+ if (full) {
2534
+ const fm = parseSkillFrontmatter(full.content);
2535
+ if (fm.name)
2536
+ skillName = fm.name;
2537
+ description = fm.description;
2399
2538
  }
2400
2539
  }
2401
2540
  catch {
2402
- // Could not read individual skill file skip
2541
+ // Could not read resource contentuse path-based name
2542
+ }
2543
+ if (!seenNames.has(skillName)) {
2544
+ seenNames.add(skillName);
2545
+ skills.push({
2546
+ name: skillName,
2547
+ description,
2548
+ path: r.path,
2549
+ source: "resource",
2550
+ });
2403
2551
  }
2404
2552
  }
2405
2553
  }
2406
2554
  catch {
2407
- // .agents/skills/ directory doesn't exist or not readable — skip
2555
+ // Resources not available — skip
2408
2556
  }
2409
- }
2410
- // Query resources with skills/ prefix
2411
- try {
2412
- const resourceSkills = currentDevMode
2413
- ? await resourceListAccessible("local@localhost", "skills/")
2414
- : await resourceList(SHARED_OWNER, "skills/");
2415
- for (const r of resourceSkills) {
2416
- // Try to get content to parse frontmatter
2417
- let skillName = r.path.split("/").pop()?.replace(/\.md$/, "") || r.path;
2418
- let description;
2419
- try {
2420
- const full = await resourceGet(r.id);
2421
- if (full) {
2422
- const fm = parseSkillFrontmatter(full.content);
2423
- if (fm.name)
2424
- skillName = fm.name;
2425
- description = fm.description;
2426
- }
2427
- }
2428
- catch {
2429
- // Could not read resource content — use path-based name
2430
- }
2431
- if (!seenNames.has(skillName)) {
2432
- seenNames.add(skillName);
2433
- skills.push({
2434
- name: skillName,
2435
- description,
2436
- path: r.path,
2437
- source: "resource",
2438
- });
2439
- }
2557
+ const result = { skills };
2558
+ if (skills.length === 0) {
2559
+ result.hint =
2560
+ "No skills found. Add skill files under skills/ in Resources. Learn more: https://agent-native.com/docs/resources#skills";
2440
2561
  }
2441
- }
2442
- catch {
2443
- // Resources not available skip
2444
- }
2445
- const result = { skills };
2446
- if (skills.length === 0) {
2447
- result.hint =
2448
- "No skills found. Add skill files under skills/ in Resources. Learn more: https://agent-native.com/docs/resources#skills";
2449
- }
2450
- return result;
2451
- }));
2452
- // Mount unified mentions endpoint (files + resources + custom providers)
2453
- getH3App(nitroApp).use(`${routePath}/mentions`, defineEventHandler(async (event) => {
2454
- if (getMethod(event) !== "GET") {
2455
- setResponseStatus(event, 405);
2456
- return { error: "Method not allowed" };
2457
- }
2458
- const query = getQuery(event);
2459
- const q = typeof query.q === "string" ? query.q.toLowerCase() : "";
2460
- const matchesQuery = (item) => !q ||
2461
- item.label.toLowerCase().includes(q) ||
2462
- (item.description?.toLowerCase().includes(q) ?? false);
2463
- const enc = new TextEncoder();
2464
- // Stream NDJSON — each source flushes its batch as soon as it's ready.
2465
- setResponseHeader(event, "Content-Type", "application/x-ndjson");
2466
- setResponseHeader(event, "Cache-Control", "no-cache");
2467
- const stream = new ReadableStream({
2468
- async start(controller) {
2469
- const MAX_RESULTS = 50;
2470
- let totalSent = 0;
2471
- let cancelled = false;
2472
- const flush = (batch) => {
2473
- if (cancelled)
2474
- return;
2475
- const filtered = batch.filter(matchesQuery);
2476
- if (filtered.length === 0)
2477
- return;
2478
- const remaining = MAX_RESULTS - totalSent;
2479
- const toSend = filtered.slice(0, remaining);
2480
- if (toSend.length > 0) {
2481
- totalSent += toSend.length;
2482
- try {
2483
- controller.enqueue(enc.encode(JSON.stringify({ items: toSend }) + "\n"));
2562
+ return result;
2563
+ }));
2564
+ // Mount unified mentions endpoint (files + resources + custom providers)
2565
+ getH3App(nitroApp).use(`${routePath}/mentions`, defineEventHandler(async (event) => {
2566
+ if (getMethod(event) !== "GET") {
2567
+ setResponseStatus(event, 405);
2568
+ return { error: "Method not allowed" };
2569
+ }
2570
+ const query = getQuery(event);
2571
+ const q = typeof query.q === "string" ? query.q.toLowerCase() : "";
2572
+ const matchesQuery = (item) => !q ||
2573
+ item.label.toLowerCase().includes(q) ||
2574
+ (item.description?.toLowerCase().includes(q) ?? false);
2575
+ const enc = new TextEncoder();
2576
+ // Stream NDJSON — each source flushes its batch as soon as it's ready.
2577
+ setResponseHeader(event, "Content-Type", "application/x-ndjson");
2578
+ setResponseHeader(event, "Cache-Control", "no-cache");
2579
+ const stream = new ReadableStream({
2580
+ async start(controller) {
2581
+ const MAX_RESULTS = 50;
2582
+ let totalSent = 0;
2583
+ let cancelled = false;
2584
+ const flush = (batch) => {
2585
+ if (cancelled)
2586
+ return;
2587
+ const filtered = batch.filter(matchesQuery);
2588
+ if (filtered.length === 0)
2589
+ return;
2590
+ const remaining = MAX_RESULTS - totalSent;
2591
+ const toSend = filtered.slice(0, remaining);
2592
+ if (toSend.length > 0) {
2593
+ totalSent += toSend.length;
2594
+ try {
2595
+ controller.enqueue(enc.encode(JSON.stringify({ items: toSend }) + "\n"));
2596
+ }
2597
+ catch {
2598
+ // Stream was closed by client
2599
+ cancelled = true;
2600
+ }
2484
2601
  }
2485
- catch {
2486
- // Stream was closed by client
2487
- cancelled = true;
2602
+ };
2603
+ // All sources run in parallel; each flushes independently.
2604
+ const sources = [];
2605
+ // 1. Resources from SQL (fast — flush first)
2606
+ sources.push((async () => {
2607
+ try {
2608
+ const resources = currentDevMode
2609
+ ? await resourceListAccessible("local@localhost")
2610
+ : await resourceList(SHARED_OWNER);
2611
+ flush(resources.map((r) => {
2612
+ const isShared = r.owner === SHARED_OWNER;
2613
+ return {
2614
+ id: `resource:${r.path}`,
2615
+ label: r.path.split("/").pop() || r.path,
2616
+ description: r.path,
2617
+ icon: "file",
2618
+ source: isShared
2619
+ ? "resource:shared"
2620
+ : "resource:private",
2621
+ refType: "file",
2622
+ refPath: r.path,
2623
+ section: "Files",
2624
+ };
2625
+ }));
2488
2626
  }
2489
- }
2490
- };
2491
- // All sources run in parallel; each flushes independently.
2492
- const sources = [];
2493
- // 1. Resources from SQL (fast flush first)
2494
- sources.push((async () => {
2495
- try {
2496
- const resources = currentDevMode
2497
- ? await resourceListAccessible("local@localhost")
2498
- : await resourceList(SHARED_OWNER);
2499
- flush(resources.map((r) => {
2500
- const isShared = r.owner === SHARED_OWNER;
2501
- return {
2502
- id: `resource:${r.path}`,
2503
- label: r.path.split("/").pop() || r.path,
2504
- description: r.path,
2505
- icon: "file",
2506
- source: isShared
2507
- ? "resource:shared"
2508
- : "resource:private",
2627
+ catch { }
2628
+ })());
2629
+ // 2. Codebase files (dev mode only can be slow on large repos)
2630
+ if (currentDevMode) {
2631
+ sources.push((async () => {
2632
+ const codebaseFiles = [];
2633
+ try {
2634
+ await collectFiles(process.cwd(), "", 0, codebaseFiles);
2635
+ }
2636
+ catch { }
2637
+ flush(codebaseFiles.map((f) => ({
2638
+ id: `codebase:${f.path}`,
2639
+ label: f.name,
2640
+ description: f.path !== f.name ? f.path : undefined,
2641
+ icon: f.type,
2642
+ source: "codebase",
2509
2643
  refType: "file",
2510
- refPath: r.path,
2644
+ refPath: f.path,
2511
2645
  section: "Files",
2512
- };
2513
- }));
2646
+ })));
2647
+ })());
2514
2648
  }
2515
- catch { }
2516
- })());
2517
- // 2. Codebase files (dev mode only — can be slow on large repos)
2518
- if (currentDevMode) {
2649
+ // 3. Custom mention providers (each flushes independently)
2650
+ for (const [key, provider] of Object.entries(mentionProviders)) {
2651
+ sources.push((async () => {
2652
+ try {
2653
+ const providerItems = await provider.search(q, event);
2654
+ flush(providerItems.map((item) => ({
2655
+ id: item.id,
2656
+ label: item.label,
2657
+ description: item.description,
2658
+ icon: item.icon || provider.icon || "file",
2659
+ source: key,
2660
+ refType: item.refType,
2661
+ refPath: item.refPath,
2662
+ refId: item.refId,
2663
+ section: provider.label,
2664
+ })));
2665
+ }
2666
+ catch (e) {
2667
+ console.error(`[agent-native] Mention provider "${key}" failed:`, e);
2668
+ }
2669
+ })());
2670
+ }
2671
+ // 4. Custom workspace agents
2519
2672
  sources.push((async () => {
2520
- const codebaseFiles = [];
2521
2673
  try {
2522
- await collectFiles(process.cwd(), "", 0, codebaseFiles);
2674
+ const owner = await getOwnerFromEvent(event);
2675
+ const { listAccessibleCustomAgents } = await import("../resources/agents.js");
2676
+ const agents = await listAccessibleCustomAgents(owner);
2677
+ flush(agents.map((agent) => ({
2678
+ id: `custom-agent:${agent.id}`,
2679
+ label: agent.name,
2680
+ description: agent.description || agent.path,
2681
+ icon: "agent",
2682
+ source: "agent:custom",
2683
+ refType: "custom-agent",
2684
+ refPath: agent.path,
2685
+ refId: agent.id,
2686
+ section: "Agents",
2687
+ })));
2688
+ }
2689
+ catch (e) {
2690
+ console.error("[agent-native] Custom agent discovery failed:", e);
2523
2691
  }
2524
- catch { }
2525
- flush(codebaseFiles.map((f) => ({
2526
- id: `codebase:${f.path}`,
2527
- label: f.name,
2528
- description: f.path !== f.name ? f.path : undefined,
2529
- icon: f.type,
2530
- source: "codebase",
2531
- refType: "file",
2532
- refPath: f.path,
2533
- section: "Files",
2534
- })));
2535
2692
  })());
2536
- }
2537
- // 3. Custom mention providers (each flushes independently)
2538
- for (const [key, provider] of Object.entries(mentionProviders)) {
2693
+ // 5. Peer agent discovery (network call — often slowest)
2539
2694
  sources.push((async () => {
2540
2695
  try {
2541
- const providerItems = await provider.search(q, event);
2542
- flush(providerItems.map((item) => ({
2543
- id: item.id,
2544
- label: item.label,
2545
- description: item.description,
2546
- icon: item.icon || provider.icon || "file",
2547
- source: key,
2548
- refType: item.refType,
2549
- refPath: item.refPath,
2550
- refId: item.refId,
2551
- section: provider.label,
2696
+ const agents = await discoverAgents(options?.appId);
2697
+ flush(agents.map((agent) => ({
2698
+ id: `agent:${agent.id}`,
2699
+ label: agent.name,
2700
+ description: agent.description,
2701
+ icon: "agent",
2702
+ source: "agent",
2703
+ refType: "agent",
2704
+ refPath: agent.url,
2705
+ refId: agent.id,
2706
+ section: "Connected Agents",
2552
2707
  })));
2553
2708
  }
2554
2709
  catch (e) {
2555
- console.error(`[agent-native] Mention provider "${key}" failed:`, e);
2710
+ console.error("[agent-native] Agent discovery failed:", e);
2556
2711
  }
2557
2712
  })());
2558
- }
2559
- // 4. Custom workspace agents
2560
- sources.push((async () => {
2561
- try {
2562
- const owner = await getOwnerFromEvent(event);
2563
- const { listAccessibleCustomAgents } = await import("../resources/agents.js");
2564
- const agents = await listAccessibleCustomAgents(owner);
2565
- flush(agents.map((agent) => ({
2566
- id: `custom-agent:${agent.id}`,
2567
- label: agent.name,
2568
- description: agent.description || agent.path,
2569
- icon: "agent",
2570
- source: "agent:custom",
2571
- refType: "custom-agent",
2572
- refPath: agent.path,
2573
- refId: agent.id,
2574
- section: "Agents",
2575
- })));
2576
- }
2577
- catch (e) {
2578
- console.error("[agent-native] Custom agent discovery failed:", e);
2579
- }
2580
- })());
2581
- // 5. Peer agent discovery (network call — often slowest)
2582
- sources.push((async () => {
2583
- try {
2584
- const agents = await discoverAgents(options?.appId);
2585
- flush(agents.map((agent) => ({
2586
- id: `agent:${agent.id}`,
2587
- label: agent.name,
2588
- description: agent.description,
2589
- icon: "agent",
2590
- source: "agent",
2591
- refType: "agent",
2592
- refPath: agent.url,
2593
- refId: agent.id,
2594
- section: "Connected Agents",
2595
- })));
2596
- }
2597
- catch (e) {
2598
- console.error("[agent-native] Agent discovery failed:", e);
2599
- }
2600
- })());
2601
- await Promise.all(sources);
2602
- if (!cancelled)
2603
- controller.close();
2604
- },
2605
- cancel() {
2606
- // Client disconnected — stop enqueuing
2607
- },
2608
- });
2609
- return stream;
2610
- }));
2611
- // ─── Generate thread title ──────────────────────────────────────────
2612
- getH3App(nitroApp).use(`${routePath}/generate-title`, defineEventHandler(async (event) => {
2613
- if (getMethod(event) !== "POST") {
2614
- setResponseStatus(event, 405);
2615
- return { error: "Method not allowed" };
2616
- }
2617
- const ownerEmail = await getOwnerFromEvent(event);
2618
- const body = await readBody(event);
2619
- const message = body?.message;
2620
- if (!message || typeof message !== "string") {
2621
- setResponseStatus(event, 400);
2622
- return { error: "message is required" };
2623
- }
2624
- // Strip mention markup: @[Name|type] → @Name
2625
- const cleanMessage = message.replace(/@\[([^\]|]+)\|[^\]]*\]/g, "@$1");
2626
- // Mirror the chat-run resolution so BYO-key users have title
2627
- // generation billed to their own key instead of the platform key.
2628
- const { getOwnerAnthropicApiKey } = await import("../agent/production-agent.js");
2629
- const userApiKey = await getOwnerAnthropicApiKey(ownerEmail);
2630
- const apiKey = userApiKey ?? process.env.ANTHROPIC_API_KEY;
2631
- if (!apiKey) {
2632
- // Fallback: truncate the message
2633
- return { title: cleanMessage.trim().slice(0, 60) };
2634
- }
2635
- try {
2636
- const res = await fetch("https://api.anthropic.com/v1/messages", {
2637
- method: "POST",
2638
- headers: {
2639
- "Content-Type": "application/json",
2640
- "x-api-key": apiKey,
2641
- "anthropic-version": "2023-06-01",
2713
+ await Promise.all(sources);
2714
+ if (!cancelled)
2715
+ controller.close();
2716
+ },
2717
+ cancel() {
2718
+ // Client disconnected stop enqueuing
2642
2719
  },
2643
- body: JSON.stringify({
2644
- model: "claude-haiku-4-5-20251001",
2645
- max_tokens: 30,
2646
- messages: [
2647
- {
2648
- role: "user",
2649
- content: `Generate a very short title (3-6 words, no quotes) for a chat that starts with this message:\n\n${cleanMessage.slice(0, 500)}`,
2650
- },
2651
- ],
2652
- }),
2653
2720
  });
2654
- if (!res.ok) {
2721
+ return stream;
2722
+ }));
2723
+ // ─── Generate thread title ──────────────────────────────────────────
2724
+ getH3App(nitroApp).use(`${routePath}/generate-title`, defineEventHandler(async (event) => {
2725
+ if (getMethod(event) !== "POST") {
2726
+ setResponseStatus(event, 405);
2727
+ return { error: "Method not allowed" };
2728
+ }
2729
+ const ownerEmail = await getOwnerFromEvent(event);
2730
+ const body = await readBody(event);
2731
+ const message = body?.message;
2732
+ if (!message || typeof message !== "string") {
2733
+ setResponseStatus(event, 400);
2734
+ return { error: "message is required" };
2735
+ }
2736
+ // Strip mention markup: @[Name|type] → @Name
2737
+ const cleanMessage = message.replace(/@\[([^\]|]+)\|[^\]]*\]/g, "@$1");
2738
+ // Mirror the chat-run resolution so BYO-key users have title
2739
+ // generation billed to their own key instead of the platform key.
2740
+ const { getOwnerActiveApiKey } = await import("../agent/production-agent.js");
2741
+ const userApiKey = await getOwnerActiveApiKey(ownerEmail);
2742
+ const apiKey = userApiKey ?? process.env.ANTHROPIC_API_KEY;
2743
+ if (!apiKey) {
2744
+ // Fallback: truncate the message
2655
2745
  return { title: cleanMessage.trim().slice(0, 60) };
2656
2746
  }
2657
- const data = (await res.json());
2658
- const text = data.content?.[0]?.text?.trim();
2659
- return { title: text || cleanMessage.trim().slice(0, 60) };
2660
- }
2661
- catch {
2662
- return { title: cleanMessage.trim().slice(0, 60) };
2663
- }
2664
- }));
2665
- // ─── Run management endpoints (for hot-reload resilience) ─────────────
2666
- // GET /runs/active?threadId=X — check if there's an active run for a thread
2667
- getH3App(nitroApp).use(`${routePath}/runs`, defineEventHandler(async (event) => {
2668
- // Auth check — ensure the user is authenticated
2669
- await getOwnerFromEvent(event);
2670
- const method = getMethod(event);
2671
- const url = event.node?.req?.url || event.path || "";
2672
- // Route: POST /runs/:id/abort
2673
- // Match both full URL (/runs/{id}/abort) and h3 prefix-stripped (/{id}/abort)
2674
- const abortMatch = url.match(/\/runs\/([^/?]+)\/abort/) ||
2675
- url.match(/^\/([^/?]+)\/abort/);
2676
- if (abortMatch && method === "POST") {
2677
- const runId = decodeURIComponent(abortMatch[1]);
2678
- abortRun(runId); // Aborts in-memory + marks aborted in SQL
2679
- return { ok: true };
2680
- }
2681
- // Route: GET /runs/:id/events?after=N
2682
- // Match both full URL (/runs/{id}/events) and h3 prefix-stripped (/{id}/events)
2683
- const eventsMatch = url.match(/\/runs\/([^/?]+)\/events/) ||
2684
- url.match(/^\/([^/?]+)\/events/);
2685
- if (eventsMatch && method === "GET") {
2686
- const runId = decodeURIComponent(eventsMatch[1]);
2687
- const query = getQuery(event);
2688
- const after = parseInt(String(query.after ?? "0"), 10) || 0;
2689
- const stream = subscribeToRun(runId, after);
2690
- if (!stream) {
2691
- setResponseStatus(event, 404);
2692
- return { error: "Run not found" };
2747
+ try {
2748
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
2749
+ method: "POST",
2750
+ headers: {
2751
+ "Content-Type": "application/json",
2752
+ "x-api-key": apiKey,
2753
+ "anthropic-version": "2023-06-01",
2754
+ },
2755
+ body: JSON.stringify({
2756
+ model: "claude-haiku-4-5-20251001",
2757
+ max_tokens: 30,
2758
+ messages: [
2759
+ {
2760
+ role: "user",
2761
+ content: `Generate a very short title (3-6 words, no quotes) for a chat that starts with this message:\n\n${cleanMessage.slice(0, 500)}`,
2762
+ },
2763
+ ],
2764
+ }),
2765
+ });
2766
+ if (!res.ok) {
2767
+ return { title: cleanMessage.trim().slice(0, 60) };
2768
+ }
2769
+ const data = (await res.json());
2770
+ const text = data.content?.[0]?.text?.trim();
2771
+ return { title: text || cleanMessage.trim().slice(0, 60) };
2693
2772
  }
2694
- setResponseHeader(event, "Content-Type", "text/event-stream");
2695
- setResponseHeader(event, "Cache-Control", "no-cache");
2696
- setResponseHeader(event, "Connection", "keep-alive");
2697
- return stream;
2698
- }
2699
- // Route: GET /runs/active?threadId=X
2700
- if (method === "GET") {
2701
- const query = getQuery(event);
2702
- const threadId = query.threadId ? String(query.threadId) : null;
2703
- if (!threadId) {
2704
- setResponseStatus(event, 400);
2705
- return { error: "threadId query parameter is required" };
2773
+ catch {
2774
+ return { title: cleanMessage.trim().slice(0, 60) };
2706
2775
  }
2707
- // Check in-memory first, then SQL (cross-isolate on Workers)
2708
- const run = await getActiveRunForThreadAsync(threadId);
2709
- if (!run) {
2710
- setResponseStatus(event, 404);
2711
- return { error: "No active run for this thread" };
2776
+ }));
2777
+ // ─── Run management endpoints (for hot-reload resilience) ─────────────
2778
+ // GET /runs/active?threadId=X — check if there's an active run for a thread
2779
+ getH3App(nitroApp).use(`${routePath}/runs`, defineEventHandler(async (event) => {
2780
+ // Auth check ensure the user is authenticated
2781
+ await getOwnerFromEvent(event);
2782
+ const method = getMethod(event);
2783
+ const url = event.node?.req?.url || event.path || "";
2784
+ // Route: POST /runs/:id/abort
2785
+ // Match both full URL (/runs/{id}/abort) and h3 prefix-stripped (/{id}/abort)
2786
+ const abortMatch = url.match(/\/runs\/([^/?]+)\/abort/) ||
2787
+ url.match(/^\/([^/?]+)\/abort/);
2788
+ if (abortMatch && method === "POST") {
2789
+ const runId = decodeURIComponent(abortMatch[1]);
2790
+ abortRun(runId); // Aborts in-memory + marks aborted in SQL
2791
+ return { ok: true };
2712
2792
  }
2713
- return {
2714
- runId: run.runId,
2715
- threadId: run.threadId,
2716
- status: run.status,
2717
- heartbeatAt: run.heartbeatAt,
2718
- };
2719
- }
2720
- setResponseStatus(event, 405);
2721
- return { error: "Method not allowed" };
2722
- }));
2723
- // ─── Thread management endpoints ──────────────────────────────────────
2724
- // Single handler for /threads and /threads/:id — h3's use() does prefix
2725
- // matching so we can't reliably split them into separate handlers.
2726
- getH3App(nitroApp).use(`${routePath}/threads`, defineEventHandler(async (event) => {
2727
- const owner = await getOwnerFromEvent(event);
2728
- const method = getMethod(event);
2729
- // Determine if this is a specific-thread request.
2730
- // h3's use() strips the mount prefix, so event.path contains
2731
- // only the remainder after /threads — e.g., "/thread-abc" or "/".
2732
- // We also check the original URL as a fallback.
2733
- const remainder = (event.path || "").replace(/^\/+/, "");
2734
- const fromUrl = (event.node?.req?.url || "").match(/\/threads\/([^/?]+)/);
2735
- const threadId = remainder
2736
- ? decodeURIComponent(remainder.split("?")[0].split("/")[0])
2737
- : fromUrl
2738
- ? decodeURIComponent(fromUrl[1])
2739
- : null;
2740
- // ── Specific thread: GET/PUT/DELETE /threads/:id ──
2741
- if (threadId) {
2793
+ // Route: GET /runs/:id/events?after=N
2794
+ // Match both full URL (/runs/{id}/events) and h3 prefix-stripped (/{id}/events)
2795
+ const eventsMatch = url.match(/\/runs\/([^/?]+)\/events/) ||
2796
+ url.match(/^\/([^/?]+)\/events/);
2797
+ if (eventsMatch && method === "GET") {
2798
+ const runId = decodeURIComponent(eventsMatch[1]);
2799
+ const query = getQuery(event);
2800
+ const after = parseInt(String(query.after ?? "0"), 10) || 0;
2801
+ const stream = subscribeToRun(runId, after);
2802
+ if (!stream) {
2803
+ setResponseStatus(event, 404);
2804
+ return { error: "Run not found" };
2805
+ }
2806
+ setResponseHeader(event, "Content-Type", "text/event-stream");
2807
+ setResponseHeader(event, "Cache-Control", "no-cache");
2808
+ setResponseHeader(event, "Connection", "keep-alive");
2809
+ return stream;
2810
+ }
2811
+ // Route: GET /runs/active?threadId=X
2742
2812
  if (method === "GET") {
2813
+ const query = getQuery(event);
2814
+ const threadId = query.threadId ? String(query.threadId) : null;
2815
+ if (!threadId) {
2816
+ setResponseStatus(event, 400);
2817
+ return { error: "threadId query parameter is required" };
2818
+ }
2819
+ // Check in-memory first, then SQL (cross-isolate on Workers)
2820
+ const run = await getActiveRunForThreadAsync(threadId);
2821
+ if (!run) {
2822
+ setResponseStatus(event, 404);
2823
+ return { error: "No active run for this thread" };
2824
+ }
2825
+ return {
2826
+ runId: run.runId,
2827
+ threadId: run.threadId,
2828
+ status: run.status,
2829
+ heartbeatAt: run.heartbeatAt,
2830
+ };
2831
+ }
2832
+ setResponseStatus(event, 405);
2833
+ return { error: "Method not allowed" };
2834
+ }));
2835
+ // ─── Checkpoint endpoints ──────────────────────────────────────────────
2836
+ getH3App(nitroApp).use(`${routePath}/checkpoints`, defineEventHandler(async (event) => {
2837
+ const method = getMethod(event);
2838
+ // GET /checkpoints?threadId=... — list checkpoints for a thread
2839
+ if (method === "GET") {
2840
+ if (!canToggle) {
2841
+ setResponseStatus(event, 403);
2842
+ return { error: "Checkpoints only available in dev mode" };
2843
+ }
2844
+ if (!isLocalhost(event)) {
2845
+ setResponseStatus(event, 403);
2846
+ return { error: "Checkpoints only available on localhost" };
2847
+ }
2848
+ const query = getQuery(event);
2849
+ const threadId = String(query.threadId || "");
2850
+ if (!threadId) {
2851
+ setResponseStatus(event, 400);
2852
+ return { error: "threadId query parameter is required" };
2853
+ }
2854
+ const owner = await getOwnerFromEvent(event);
2743
2855
  const thread = await getThread(threadId);
2744
2856
  if (!thread || thread.ownerEmail !== owner) {
2745
2857
  setResponseStatus(event, 404);
2746
2858
  return { error: "Thread not found" };
2747
2859
  }
2748
- return thread;
2860
+ try {
2861
+ const { getCheckpointsByThread } = await import("../checkpoints/store.js");
2862
+ return await getCheckpointsByThread(threadId);
2863
+ }
2864
+ catch {
2865
+ return [];
2866
+ }
2867
+ }
2868
+ // POST /checkpoints — restore to a checkpoint
2869
+ // h3 prefix-matches, so /checkpoints/restore hits this handler with
2870
+ // event.path containing "/restore".
2871
+ const remainder = (event.path || "").replace(/^\/+/, "");
2872
+ if (method === "POST" && remainder.startsWith("restore")) {
2873
+ if (!canToggle) {
2874
+ setResponseStatus(event, 403);
2875
+ return { error: "Checkpoints only available in dev mode" };
2876
+ }
2877
+ if (!isLocalhost(event)) {
2878
+ setResponseStatus(event, 403);
2879
+ return { error: "Restore only available on localhost" };
2880
+ }
2881
+ const body = await readBody(event);
2882
+ const checkpointId = body?.checkpointId;
2883
+ if (!checkpointId) {
2884
+ setResponseStatus(event, 400);
2885
+ return { error: "checkpointId is required" };
2886
+ }
2887
+ try {
2888
+ const { getCheckpointById } = await import("../checkpoints/store.js");
2889
+ const checkpoint = await getCheckpointById(checkpointId);
2890
+ if (!checkpoint) {
2891
+ setResponseStatus(event, 404);
2892
+ return { error: "Checkpoint not found" };
2893
+ }
2894
+ const owner = await getOwnerFromEvent(event);
2895
+ const thread = await getThread(checkpoint.threadId);
2896
+ if (!thread || thread.ownerEmail !== owner) {
2897
+ setResponseStatus(event, 404);
2898
+ return { error: "Checkpoint not found" };
2899
+ }
2900
+ const { createCheckpoint: gitCheckpoint, restoreToCheckpoint, hasUncommittedChanges, isGitRepo, } = await import("../checkpoints/service.js");
2901
+ const cwd = process.cwd();
2902
+ if (!isGitRepo(cwd)) {
2903
+ setResponseStatus(event, 400);
2904
+ return { error: "Not a git repository" };
2905
+ }
2906
+ // Save current state before restoring so user can undo the undo
2907
+ if (hasUncommittedChanges(cwd)) {
2908
+ gitCheckpoint(cwd, "[agent-native] Pre-restore checkpoint");
2909
+ }
2910
+ const restored = restoreToCheckpoint(cwd, checkpoint.commitSha);
2911
+ if (!restored) {
2912
+ setResponseStatus(event, 500);
2913
+ return { error: "Failed to restore checkpoint" };
2914
+ }
2915
+ // Trigger UI refresh
2916
+ try {
2917
+ const { recordChange } = await import("./poll.js");
2918
+ recordChange({
2919
+ source: "checkpoint",
2920
+ type: "change",
2921
+ key: "*",
2922
+ });
2923
+ }
2924
+ catch { }
2925
+ return { success: true, commitSha: checkpoint.commitSha };
2926
+ }
2927
+ catch (err) {
2928
+ setResponseStatus(event, 500);
2929
+ return { error: err?.message ?? "Restore failed" };
2930
+ }
2749
2931
  }
2750
- if (method === "PUT") {
2751
- // Hold the thread_data lock for the full read-modify-write so
2752
- // periodic saves from the frontend don't race with
2753
- // onRunComplete / setThreadQueuedMessages / setThreadEngineMeta.
2754
- // Without the lock, a client save that lands during an agent
2755
- // run could clobber the assistant message the server just
2756
- // appended (and vice versa).
2757
- return await withThreadDataLock(threadId, async () => {
2932
+ setResponseStatus(event, 405);
2933
+ return { error: "Method not allowed" };
2934
+ }));
2935
+ // ─── Thread management endpoints ──────────────────────────────────────
2936
+ // Single handler for /threads and /threads/:id h3's use() does prefix
2937
+ // matching so we can't reliably split them into separate handlers.
2938
+ getH3App(nitroApp).use(`${routePath}/threads`, defineEventHandler(async (event) => {
2939
+ const owner = await getOwnerFromEvent(event);
2940
+ const method = getMethod(event);
2941
+ // Determine if this is a specific-thread request.
2942
+ // h3's use() strips the mount prefix, so event.path contains
2943
+ // only the remainder after /threads — e.g., "/thread-abc" or "/".
2944
+ // We also check the original URL as a fallback.
2945
+ const remainder = (event.path || "").replace(/^\/+/, "");
2946
+ const fromUrl = (event.node?.req?.url || "").match(/\/threads\/([^/?]+)/);
2947
+ const threadId = remainder
2948
+ ? decodeURIComponent(remainder.split("?")[0].split("/")[0])
2949
+ : fromUrl
2950
+ ? decodeURIComponent(fromUrl[1])
2951
+ : null;
2952
+ // ── Specific thread: GET/PUT/DELETE /threads/:id ──
2953
+ if (threadId) {
2954
+ if (method === "GET") {
2758
2955
  const thread = await getThread(threadId);
2759
2956
  if (!thread || thread.ownerEmail !== owner) {
2760
2957
  setResponseStatus(event, 404);
2761
2958
  return { error: "Thread not found" };
2762
2959
  }
2763
- const body = await readBody(event);
2764
- let newThreadData = body.threadData || thread.threadData;
2765
- // Preserve queuedMessages from the existing thread_data when the
2766
- // incoming blob doesn't include it. Periodic full-thread saves
2767
- // (exported via threadRuntime.export) don't carry the queue, and
2768
- // we don't want them to clobber queued-message state persisted
2769
- // via POST /threads/:id/queued.
2770
- if (body.threadData) {
2771
- try {
2772
- const existing = JSON.parse(thread.threadData);
2773
- if (existing.queuedMessages !== undefined) {
2774
- const incoming = JSON.parse(newThreadData);
2775
- if (incoming.queuedMessages === undefined) {
2776
- incoming.queuedMessages = existing.queuedMessages;
2777
- newThreadData = JSON.stringify(incoming);
2960
+ return thread;
2961
+ }
2962
+ if (method === "PUT") {
2963
+ // Hold the thread_data lock for the full read-modify-write so
2964
+ // periodic saves from the frontend don't race with
2965
+ // onRunComplete / setThreadQueuedMessages / setThreadEngineMeta.
2966
+ // Without the lock, a client save that lands during an agent
2967
+ // run could clobber the assistant message the server just
2968
+ // appended (and vice versa).
2969
+ return await withThreadDataLock(threadId, async () => {
2970
+ const thread = await getThread(threadId);
2971
+ if (!thread || thread.ownerEmail !== owner) {
2972
+ setResponseStatus(event, 404);
2973
+ return { error: "Thread not found" };
2974
+ }
2975
+ const body = await readBody(event);
2976
+ let newThreadData = body.threadData || thread.threadData;
2977
+ // Preserve queuedMessages from the existing thread_data when the
2978
+ // incoming blob doesn't include it. Periodic full-thread saves
2979
+ // (exported via threadRuntime.export) don't carry the queue, and
2980
+ // we don't want them to clobber queued-message state persisted
2981
+ // via POST /threads/:id/queued.
2982
+ if (body.threadData) {
2983
+ try {
2984
+ const existing = JSON.parse(thread.threadData);
2985
+ if (existing.queuedMessages !== undefined) {
2986
+ const incoming = JSON.parse(newThreadData);
2987
+ if (incoming.queuedMessages === undefined) {
2988
+ incoming.queuedMessages = existing.queuedMessages;
2989
+ newThreadData = JSON.stringify(incoming);
2990
+ }
2778
2991
  }
2779
2992
  }
2993
+ catch {
2994
+ // Invalid JSON in either side — fall back to raw body blob.
2995
+ }
2780
2996
  }
2781
- catch {
2782
- // Invalid JSON in either side — fall back to raw body blob.
2783
- }
2997
+ await updateThreadData(threadId, newThreadData, body.title ?? thread.title, body.preview ?? thread.preview, body.messageCount || thread.messageCount);
2998
+ return { ok: true };
2999
+ });
3000
+ }
3001
+ // POST /threads/:id/queued — debounced writes from the client
3002
+ // when the user adds/removes/dequeues a queued message. Keeps
3003
+ // queued messages durable across reloads without piggybacking
3004
+ // on full-thread saves.
3005
+ if (method === "POST" &&
3006
+ /\/threads\/[^/?]+\/queued/.test(event.node?.req?.url || event.path || "")) {
3007
+ const thread = await getThread(threadId);
3008
+ if (!thread || thread.ownerEmail !== owner) {
3009
+ setResponseStatus(event, 404);
3010
+ return { error: "Thread not found" };
2784
3011
  }
2785
- await updateThreadData(threadId, newThreadData, body.title ?? thread.title, body.preview ?? thread.preview, body.messageCount || thread.messageCount);
3012
+ const body = await readBody(event);
3013
+ const queued = Array.isArray(body?.queuedMessages)
3014
+ ? body.queuedMessages
3015
+ : [];
3016
+ await setThreadQueuedMessages(threadId, queued);
2786
3017
  return { ok: true };
2787
- });
2788
- }
2789
- // POST /threads/:id/queued — debounced writes from the client
2790
- // when the user adds/removes/dequeues a queued message. Keeps
2791
- // queued messages durable across reloads without piggybacking
2792
- // on full-thread saves.
2793
- if (method === "POST" &&
2794
- /\/threads\/[^/?]+\/queued/.test(event.node?.req?.url || event.path || "")) {
2795
- const thread = await getThread(threadId);
2796
- if (!thread || thread.ownerEmail !== owner) {
2797
- setResponseStatus(event, 404);
2798
- return { error: "Thread not found" };
2799
3018
  }
2800
- const body = await readBody(event);
2801
- const queued = Array.isArray(body?.queuedMessages)
2802
- ? body.queuedMessages
2803
- : [];
2804
- await setThreadQueuedMessages(threadId, queued);
2805
- return { ok: true };
3019
+ if (method === "DELETE") {
3020
+ const thread = await getThread(threadId);
3021
+ if (!thread || thread.ownerEmail !== owner) {
3022
+ setResponseStatus(event, 404);
3023
+ return { error: "Thread not found" };
3024
+ }
3025
+ await deleteThread(threadId);
3026
+ return { ok: true };
3027
+ }
3028
+ setResponseStatus(event, 405);
3029
+ return { error: "Method not allowed" };
2806
3030
  }
2807
- if (method === "DELETE") {
2808
- const thread = await getThread(threadId);
2809
- if (!thread || thread.ownerEmail !== owner) {
2810
- setResponseStatus(event, 404);
2811
- return { error: "Thread not found" };
3031
+ // ── Thread list: GET/POST /threads ──
3032
+ if (method === "GET") {
3033
+ const query = getQuery(event);
3034
+ const limit = Math.min(parseInt(String(query.limit ?? "50"), 10) || 50, 200);
3035
+ const q = query.q ? String(query.q).trim() : "";
3036
+ if (q) {
3037
+ const threads = await searchThreads(owner, q, limit);
3038
+ return { threads };
2812
3039
  }
2813
- await deleteThread(threadId);
2814
- return { ok: true };
3040
+ const offset = parseInt(String(query.offset ?? "0"), 10) || 0;
3041
+ const threads = await listThreads(owner, limit, offset);
3042
+ return { threads };
3043
+ }
3044
+ if (method === "POST") {
3045
+ const body = await readBody(event);
3046
+ const thread = await createThread(owner, {
3047
+ id: body?.id,
3048
+ title: body?.title ?? "",
3049
+ });
3050
+ return thread;
2815
3051
  }
2816
3052
  setResponseStatus(event, 405);
2817
3053
  return { error: "Method not allowed" };
2818
- }
2819
- // ── Thread list: GET/POST /threads ──
2820
- if (method === "GET") {
2821
- const query = getQuery(event);
2822
- const limit = Math.min(parseInt(String(query.limit ?? "50"), 10) || 50, 200);
2823
- const q = query.q ? String(query.q).trim() : "";
2824
- if (q) {
2825
- const threads = await searchThreads(owner, q, limit);
2826
- return { threads };
3054
+ }));
3055
+ // Mount the main chat handler — delegates to dev or prod handler based on current mode.
3056
+ // This is mounted last because h3's use() is prefix-based, meaning /_agent-native/agent-chat
3057
+ // also matches /_agent-native/agent-chat/threads/... — we skip sub-path requests here so the
3058
+ // earlier-mounted handlers (mode, save-key, files, skills, mentions, threads) handle them.
3059
+ getH3App(nitroApp).use(routePath, defineEventHandler(async (event) => {
3060
+ // Skip sub-path requests — they're handled by earlier-mounted handlers
3061
+ const url = event.node?.req?.url || event.path || "";
3062
+ const afterBase = url.slice(url.indexOf(routePath) + routePath.length);
3063
+ if (afterBase && afterBase !== "/" && !afterBase.startsWith("?")) {
3064
+ // Not for us — return 404 so h3 doesn't swallow the request
3065
+ setResponseStatus(event, 404);
3066
+ return { error: "Not found" };
2827
3067
  }
2828
- const offset = parseInt(String(query.offset ?? "0"), 10) || 0;
2829
- const threads = await listThreads(owner, limit, offset);
2830
- return { threads };
2831
- }
2832
- if (method === "POST") {
2833
- const body = await readBody(event);
2834
- const thread = await createThread(owner, {
2835
- id: body?.id,
2836
- title: body?.title ?? "",
2837
- });
2838
- return thread;
2839
- }
2840
- setResponseStatus(event, 405);
2841
- return { error: "Method not allowed" };
2842
- }));
2843
- // Mount the main chat handler — delegates to dev or prod handler based on current mode.
2844
- // This is mounted last because h3's use() is prefix-based, meaning /_agent-native/agent-chat
2845
- // also matches /_agent-native/agent-chat/threads/... — we skip sub-path requests here so the
2846
- // earlier-mounted handlers (mode, save-key, files, skills, mentions, threads) handle them.
2847
- getH3App(nitroApp).use(routePath, defineEventHandler(async (event) => {
2848
- // Skip sub-path requests — they're handled by earlier-mounted handlers
2849
- const url = event.node?.req?.url || event.path || "";
2850
- const afterBase = url.slice(url.indexOf(routePath) + routePath.length);
2851
- if (afterBase && afterBase !== "/" && !afterBase.startsWith("?")) {
2852
- // Not for us — return 404 so h3 doesn't swallow the request
2853
- setResponseStatus(event, 404);
2854
- return { error: "Not found" };
2855
- }
2856
- // Resolve per-request auth context
2857
- const owner = await getOwnerFromEvent(event);
2858
- // Resolve org ID: explicit callback > session.orgId from Better Auth
2859
- let resolvedOrgId;
2860
- if (options?.resolveOrgId) {
2861
- resolvedOrgId = (await options.resolveOrgId(event)) ?? undefined;
2862
- }
2863
- else {
2864
- try {
2865
- const session = await getSession(event);
2866
- resolvedOrgId = session?.orgId ?? undefined;
3068
+ // Resolve per-request auth context
3069
+ const owner = await getOwnerFromEvent(event);
3070
+ // Resolve org ID: explicit callback > session.orgId from Better Auth
3071
+ let resolvedOrgId;
3072
+ if (options?.resolveOrgId) {
3073
+ resolvedOrgId = (await options.resolveOrgId(event)) ?? undefined;
2867
3074
  }
2868
- catch {
2869
- // Session not available
3075
+ else {
3076
+ try {
3077
+ const session = await getSession(event);
3078
+ resolvedOrgId = session?.orgId ?? undefined;
3079
+ }
3080
+ catch {
3081
+ // Session not available
3082
+ }
2870
3083
  }
3084
+ // Also set process.env for backwards compat (CLI scripts, legacy readers)
3085
+ process.env.AGENT_USER_EMAIL = owner;
3086
+ if (resolvedOrgId) {
3087
+ process.env.AGENT_ORG_ID = resolvedOrgId;
3088
+ }
3089
+ else {
3090
+ delete process.env.AGENT_ORG_ID;
3091
+ }
3092
+ // Propagate the caller's IANA timezone from `x-user-timezone` so that
3093
+ // tool calls made by the agent (e.g. log-meal with no explicit date)
3094
+ // resolve "today" in the user's local timezone instead of server UTC.
3095
+ const tzRaw = getHeader(event, "x-user-timezone");
3096
+ const timezone = typeof tzRaw === "string" &&
3097
+ tzRaw.trim().length > 0 &&
3098
+ tzRaw.trim().length < 64
3099
+ ? tzRaw.trim()
3100
+ : undefined;
3101
+ if (timezone)
3102
+ process.env.AGENT_USER_TIMEZONE = timezone;
3103
+ return runWithRequestContext({ userEmail: owner, orgId: resolvedOrgId, timezone }, () => {
3104
+ const handler = currentDevMode && devHandler ? devHandler : prodHandler;
3105
+ return handler(event);
3106
+ });
3107
+ }));
3108
+ // ─── Recurring Jobs Scheduler ──────────────────────────────────────
3109
+ // Poll every 60 seconds for due recurring jobs and execute them.
3110
+ // Uses setInterval so it works in all deployment environments without
3111
+ // requiring Nitro experimental tasks configuration.
3112
+ try {
3113
+ const { processRecurringJobs } = await import("../jobs/scheduler.js");
3114
+ const schedulerDeps = {
3115
+ getActions: () => ({
3116
+ ...templateScripts,
3117
+ ...resourceScripts,
3118
+ ...docsScripts,
3119
+ ...chatScripts,
3120
+ ...jobTools,
3121
+ ...automationTools,
3122
+ ...notificationTools,
3123
+ ...progressTools,
3124
+ ...fetchTool,
3125
+ }),
3126
+ getSystemPrompt: async (owner) => {
3127
+ const resources = await loadResourcesForPrompt(owner);
3128
+ const schemaBlock = await buildSchemaBlock(owner, false);
3129
+ return basePrompt + resources + schemaBlock;
3130
+ },
3131
+ apiKey: options?.apiKey ?? process.env.ANTHROPIC_API_KEY,
3132
+ model: resolvedModel,
3133
+ };
3134
+ // Start after a 10-second delay to let the server fully initialize
3135
+ setTimeout(() => {
3136
+ setInterval(() => {
3137
+ processRecurringJobs(schedulerDeps).catch((err) => {
3138
+ console.error("[recurring-jobs] Scheduler error:", err?.message);
3139
+ });
3140
+ }, 60_000);
3141
+ if (process.env.DEBUG)
3142
+ console.log("[recurring-jobs] Scheduler started (60s interval)");
3143
+ }, 10_000);
2871
3144
  }
2872
- // Also set process.env for backwards compat (CLI scripts, legacy readers)
2873
- process.env.AGENT_USER_EMAIL = owner;
2874
- if (resolvedOrgId) {
2875
- process.env.AGENT_ORG_ID = resolvedOrgId;
2876
- }
2877
- else {
2878
- delete process.env.AGENT_ORG_ID;
3145
+ catch (err) {
3146
+ // Jobs module not available — skip silently
2879
3147
  }
2880
- // Propagate the caller's IANA timezone from `x-user-timezone` so that
2881
- // tool calls made by the agent (e.g. log-meal with no explicit date)
2882
- // resolve "today" in the user's local timezone instead of server UTC.
2883
- const tzRaw = getHeader(event, "x-user-timezone");
2884
- const timezone = typeof tzRaw === "string" &&
2885
- tzRaw.trim().length > 0 &&
2886
- tzRaw.trim().length < 64
2887
- ? tzRaw.trim()
2888
- : undefined;
2889
- if (timezone)
2890
- process.env.AGENT_USER_TIMEZONE = timezone;
2891
- return runWithRequestContext({ userEmail: owner, orgId: resolvedOrgId, timezone }, () => {
2892
- const handler = currentDevMode && devHandler ? devHandler : prodHandler;
2893
- return handler(event);
2894
- });
2895
- }));
2896
- // ─── Recurring Jobs Scheduler ──────────────────────────────────────
2897
- // Poll every 60 seconds for due recurring jobs and execute them.
2898
- // Uses setInterval so it works in all deployment environments without
2899
- // requiring Nitro experimental tasks configuration.
2900
- try {
2901
- const { processRecurringJobs } = await import("../jobs/scheduler.js");
2902
- const schedulerDeps = {
2903
- getActions: () => ({
2904
- ...templateScripts,
2905
- ...resourceScripts,
2906
- ...docsScripts,
2907
- ...chatScripts,
2908
- ...jobTools,
2909
- }),
2910
- getSystemPrompt: async (owner) => {
2911
- const resources = await loadResourcesForPrompt(owner);
2912
- const schemaBlock = await buildSchemaBlock(owner, false);
2913
- return basePrompt + resources + schemaBlock;
2914
- },
2915
- apiKey: options?.apiKey ?? process.env.ANTHROPIC_API_KEY,
2916
- model: resolvedModel,
2917
- };
2918
- // Start after a 10-second delay to let the server fully initialize
2919
- setTimeout(() => {
2920
- setInterval(() => {
2921
- processRecurringJobs(schedulerDeps).catch((err) => {
2922
- console.error("[recurring-jobs] Scheduler error:", err?.message);
2923
- });
2924
- }, 60_000);
3148
+ // ─── Trigger Dispatcher (event-based automations) ─────────────────
3149
+ try {
3150
+ const { initTriggerDispatcher } = await import("../triggers/dispatcher.js");
3151
+ await initTriggerDispatcher({
3152
+ getActions: () => ({
3153
+ ...templateScripts,
3154
+ ...resourceScripts,
3155
+ ...docsScripts,
3156
+ ...chatScripts,
3157
+ ...jobTools,
3158
+ ...automationTools,
3159
+ ...notificationTools,
3160
+ ...progressTools,
3161
+ ...fetchTool,
3162
+ }),
3163
+ getSystemPrompt: async (owner) => {
3164
+ const resources = await loadResourcesForPrompt(owner);
3165
+ const schemaBlock = await buildSchemaBlock(owner, false);
3166
+ return basePrompt + resources + schemaBlock;
3167
+ },
3168
+ apiKey: options?.apiKey ?? process.env.ANTHROPIC_API_KEY,
3169
+ model: resolvedModel,
3170
+ });
2925
3171
  if (process.env.DEBUG)
2926
- console.log("[recurring-jobs] Scheduler started (60s interval)");
2927
- }, 10_000);
2928
- }
2929
- catch (err) {
2930
- // Jobs module not available — skip silently
2931
- }
3172
+ console.log("[triggers] Trigger dispatcher initialized");
3173
+ }
3174
+ catch (err) {
3175
+ // Triggers module not available — skip silently
3176
+ }
3177
+ })();
3178
+ trackPluginInit(nitroApp, initPromise);
2932
3179
  };
2933
3180
  }
2934
3181
  /**