@swarmclawai/swarmclaw 0.8.4 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (394) hide show
  1. package/README.md +9 -9
  2. package/bin/swarmclaw.js +5 -1
  3. package/bin/worker-cmd.js +73 -0
  4. package/package.json +2 -1
  5. package/src/app/api/agents/[id]/route.ts +17 -7
  6. package/src/app/api/agents/route.ts +21 -8
  7. package/src/app/api/approvals/route.test.ts +6 -6
  8. package/src/app/api/approvals/route.ts +2 -1
  9. package/src/app/api/auth/route.ts +2 -3
  10. package/src/app/api/chatrooms/[id]/chat/route.test.ts +299 -0
  11. package/src/app/api/chatrooms/[id]/chat/route.ts +3 -2
  12. package/src/app/api/chatrooms/[id]/route.ts +7 -6
  13. package/src/app/api/chats/[id]/chat/route.test.ts +496 -0
  14. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  15. package/src/app/api/chats/[id]/clear/route.ts +9 -9
  16. package/src/app/api/chats/[id]/devserver/route.ts +2 -1
  17. package/src/app/api/chats/[id]/edit-resend/route.ts +3 -4
  18. package/src/app/api/chats/[id]/fork/route.ts +3 -5
  19. package/src/app/api/chats/[id]/restore/route.ts +6 -7
  20. package/src/app/api/chats/[id]/retry/route.ts +3 -4
  21. package/src/app/api/chats/[id]/route.ts +61 -62
  22. package/src/app/api/chats/route.ts +7 -1
  23. package/src/app/api/connectors/[id]/route.ts +7 -8
  24. package/src/app/api/connectors/route.ts +5 -4
  25. package/src/app/api/eval/run/route.ts +2 -1
  26. package/src/app/api/eval/suite/route.ts +2 -1
  27. package/src/app/api/external-agents/route.test.ts +1 -1
  28. package/src/app/api/external-agents/route.ts +2 -2
  29. package/src/app/api/files/serve/route.ts +1 -1
  30. package/src/app/api/gateways/[id]/route.ts +7 -5
  31. package/src/app/api/gateways/route.ts +1 -1
  32. package/src/app/api/knowledge/upload/route.ts +1 -1
  33. package/src/app/api/logs/route.ts +5 -7
  34. package/src/app/api/memory-images/[filename]/route.ts +2 -3
  35. package/src/app/api/openclaw/agent-files/route.ts +4 -3
  36. package/src/app/api/openclaw/approvals/route.ts +3 -4
  37. package/src/app/api/openclaw/config-sync/route.ts +3 -2
  38. package/src/app/api/openclaw/cron/route.ts +3 -2
  39. package/src/app/api/openclaw/dotenv-keys/route.ts +2 -1
  40. package/src/app/api/openclaw/exec-config/route.ts +3 -2
  41. package/src/app/api/openclaw/gateway/route.ts +5 -4
  42. package/src/app/api/openclaw/history/route.ts +3 -2
  43. package/src/app/api/openclaw/media/route.ts +2 -1
  44. package/src/app/api/openclaw/permissions/route.ts +3 -2
  45. package/src/app/api/openclaw/sandbox-env/route.ts +3 -2
  46. package/src/app/api/openclaw/skills/install/route.ts +2 -1
  47. package/src/app/api/openclaw/skills/remove/route.ts +2 -1
  48. package/src/app/api/openclaw/skills/route.ts +3 -2
  49. package/src/app/api/orchestrator/run/route.ts +5 -14
  50. package/src/app/api/perf/route.ts +43 -0
  51. package/src/app/api/plugins/dependencies/route.ts +2 -1
  52. package/src/app/api/plugins/install/route.ts +2 -1
  53. package/src/app/api/plugins/marketplace/route.ts +3 -2
  54. package/src/app/api/plugins/settings/route.ts +2 -1
  55. package/src/app/api/preview-server/route.ts +11 -10
  56. package/src/app/api/projects/[id]/route.ts +1 -1
  57. package/src/app/api/schedules/[id]/route.test.ts +128 -0
  58. package/src/app/api/schedules/[id]/route.ts +43 -43
  59. package/src/app/api/schedules/[id]/run/route.ts +11 -62
  60. package/src/app/api/schedules/route.ts +21 -87
  61. package/src/app/api/settings/route.ts +2 -0
  62. package/src/app/api/setup/doctor/route.ts +9 -8
  63. package/src/app/api/tasks/[id]/approve/route.ts +33 -30
  64. package/src/app/api/tasks/[id]/route.ts +12 -35
  65. package/src/app/api/tasks/import/github/route.ts +2 -1
  66. package/src/app/api/tasks/route.ts +79 -91
  67. package/src/app/api/wallets/[id]/approve/route.ts +2 -1
  68. package/src/app/api/wallets/[id]/route.ts +13 -19
  69. package/src/app/api/wallets/[id]/send/route.ts +2 -1
  70. package/src/app/api/wallets/route.ts +2 -1
  71. package/src/app/api/webhooks/[id]/route.ts +2 -1
  72. package/src/app/api/webhooks/route.test.ts +3 -1
  73. package/src/app/page.tsx +23 -331
  74. package/src/cli/index.js +19 -0
  75. package/src/cli/index.ts +38 -7
  76. package/src/cli/spec.js +9 -0
  77. package/src/components/activity/activity-feed.tsx +7 -4
  78. package/src/components/agents/agent-card.tsx +32 -6
  79. package/src/components/agents/agent-chat-list.tsx +55 -22
  80. package/src/components/agents/agent-files-editor.tsx +3 -2
  81. package/src/components/agents/agent-sheet.tsx +123 -22
  82. package/src/components/agents/inspector-panel.tsx +1 -1
  83. package/src/components/agents/openclaw-skills-panel.tsx +2 -1
  84. package/src/components/agents/trash-list.tsx +1 -1
  85. package/src/components/auth/access-key-gate.tsx +8 -2
  86. package/src/components/auth/setup-wizard.tsx +10 -9
  87. package/src/components/auth/user-picker.tsx +3 -2
  88. package/src/components/chat/chat-area.tsx +20 -1
  89. package/src/components/chat/chat-card.tsx +18 -3
  90. package/src/components/chat/chat-header.tsx +24 -4
  91. package/src/components/chat/chat-list.tsx +2 -11
  92. package/src/components/chat/heartbeat-history-panel.tsx +2 -1
  93. package/src/components/chat/message-bubble.tsx +45 -6
  94. package/src/components/chat/message-list.tsx +280 -145
  95. package/src/components/chat/streaming-bubble.tsx +217 -60
  96. package/src/components/chat/swarm-panel.test.ts +274 -0
  97. package/src/components/chat/swarm-panel.tsx +410 -0
  98. package/src/components/chat/swarm-status-card.tsx +346 -0
  99. package/src/components/chat/tool-call-bubble.tsx +48 -23
  100. package/src/components/chatrooms/chatroom-list.tsx +8 -5
  101. package/src/components/chatrooms/chatroom-message.tsx +10 -7
  102. package/src/components/chatrooms/chatroom-view.tsx +12 -9
  103. package/src/components/connectors/connector-health.tsx +6 -4
  104. package/src/components/connectors/connector-list.tsx +16 -11
  105. package/src/components/connectors/connector-sheet.tsx +12 -6
  106. package/src/components/home/home-view.tsx +38 -24
  107. package/src/components/input/chat-input.tsx +10 -1
  108. package/src/components/layout/app-layout.tsx +2 -38
  109. package/src/components/layout/sheet-layer.tsx +50 -0
  110. package/src/components/mcp-servers/mcp-server-list.tsx +37 -5
  111. package/src/components/mcp-servers/mcp-server-sheet.tsx +12 -2
  112. package/src/components/plugins/plugin-list.tsx +8 -4
  113. package/src/components/plugins/plugin-sheet.tsx +2 -1
  114. package/src/components/providers/provider-list.tsx +3 -2
  115. package/src/components/providers/provider-sheet.tsx +2 -1
  116. package/src/components/runs/run-list.tsx +11 -7
  117. package/src/components/schedules/schedule-card.tsx +5 -3
  118. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  119. package/src/components/shared/attachment-chip.tsx +19 -3
  120. package/src/components/shared/notification-center.tsx +6 -3
  121. package/src/components/shared/settings/plugin-manager.tsx +3 -2
  122. package/src/components/shared/settings/section-embedding.tsx +2 -1
  123. package/src/components/shared/settings/section-orchestrator.tsx +2 -1
  124. package/src/components/shared/settings/section-user-preferences.tsx +107 -0
  125. package/src/components/shared/settings/settings-page.tsx +13 -9
  126. package/src/components/skills/clawhub-browser.tsx +15 -4
  127. package/src/components/skills/skill-list.tsx +15 -4
  128. package/src/components/tasks/approvals-panel.tsx +2 -1
  129. package/src/components/tasks/task-board.tsx +35 -37
  130. package/src/components/tasks/task-sheet.tsx +4 -3
  131. package/src/components/ui/full-screen-loader.tsx +164 -0
  132. package/src/components/wallets/wallet-approval-dialog.tsx +2 -1
  133. package/src/components/wallets/wallet-panel.tsx +6 -5
  134. package/src/components/wallets/wallet-section.tsx +3 -2
  135. package/src/components/webhooks/webhook-list.tsx +4 -5
  136. package/src/components/webhooks/webhook-sheet.tsx +6 -6
  137. package/src/hooks/use-app-bootstrap.ts +202 -0
  138. package/src/hooks/use-mounted-ref.ts +14 -0
  139. package/src/hooks/use-now.ts +31 -0
  140. package/src/hooks/use-openclaw-gateway.ts +2 -1
  141. package/src/instrumentation.ts +20 -8
  142. package/src/lib/agent-default-tools.test.ts +52 -0
  143. package/src/lib/agent-default-tools.ts +40 -0
  144. package/src/lib/api-client.test.ts +21 -0
  145. package/src/lib/api-client.ts +6 -11
  146. package/src/lib/canvas-content.test.ts +360 -0
  147. package/src/lib/chat-streaming-state.test.ts +49 -2
  148. package/src/lib/chat-streaming-state.ts +26 -10
  149. package/src/lib/fetch-timeout.test.ts +54 -0
  150. package/src/lib/fetch-timeout.ts +60 -3
  151. package/src/lib/live-tool-events.test.ts +77 -0
  152. package/src/lib/live-tool-events.ts +73 -0
  153. package/src/lib/local-observability.test.ts +2 -2
  154. package/src/lib/openclaw-endpoint.test.ts +1 -1
  155. package/src/lib/providers/anthropic.ts +12 -16
  156. package/src/lib/providers/index.ts +4 -2
  157. package/src/lib/providers/ollama.ts +9 -6
  158. package/src/lib/providers/openai.ts +11 -14
  159. package/src/lib/runtime-env.test.ts +8 -8
  160. package/src/lib/schedule-dedupe-advanced.test.ts +2 -2
  161. package/src/lib/schedule-dedupe.test.ts +1 -1
  162. package/src/lib/schedule-dedupe.ts +3 -2
  163. package/src/lib/server/agent-thread-session.test.ts +6 -6
  164. package/src/lib/server/agent-thread-session.ts +6 -9
  165. package/src/lib/server/alert-dispatch.ts +2 -1
  166. package/src/lib/server/api-routes.test.ts +6 -6
  167. package/src/lib/server/approval-connector-notify.test.ts +4 -4
  168. package/src/lib/server/approvals-auto-approve.test.ts +29 -29
  169. package/src/lib/server/approvals.test.ts +317 -0
  170. package/src/lib/server/approvals.ts +5 -4
  171. package/src/lib/server/autonomy-runtime.test.ts +11 -11
  172. package/src/lib/server/browser-state.ts +2 -2
  173. package/src/lib/server/capability-router.test.ts +1 -1
  174. package/src/lib/server/capability-router.ts +3 -2
  175. package/src/lib/server/chat-execution-advanced.test.ts +15 -2
  176. package/src/lib/server/chat-execution-connector-delivery.ts +67 -0
  177. package/src/lib/server/chat-execution-disabled.test.ts +3 -3
  178. package/src/lib/server/chat-execution-eval-history.test.ts +3 -3
  179. package/src/lib/server/chat-execution-heartbeat.test.ts +42 -1
  180. package/src/lib/server/chat-execution-session-sync.test.ts +119 -0
  181. package/src/lib/server/chat-execution-tool-events.ts +116 -0
  182. package/src/lib/server/chat-execution-utils.test.ts +479 -0
  183. package/src/lib/server/chat-execution-utils.ts +533 -0
  184. package/src/lib/server/chat-execution.ts +153 -748
  185. package/src/lib/server/chat-streaming-utils.ts +174 -0
  186. package/src/lib/server/chat-turn-tool-routing.ts +310 -0
  187. package/src/lib/server/chatroom-session-persistence.test.ts +2 -2
  188. package/src/lib/server/clawhub-client.ts +2 -1
  189. package/src/lib/server/collection-helpers.test.ts +92 -0
  190. package/src/lib/server/collection-helpers.ts +25 -3
  191. package/src/lib/server/connectors/access.ts +146 -0
  192. package/src/lib/server/connectors/bluebubbles.test.ts +1 -1
  193. package/src/lib/server/connectors/bluebubbles.ts +4 -4
  194. package/src/lib/server/connectors/commands.ts +367 -0
  195. package/src/lib/server/connectors/connector-routing.test.ts +4 -4
  196. package/src/lib/server/connectors/delivery.ts +142 -0
  197. package/src/lib/server/connectors/discord.ts +37 -40
  198. package/src/lib/server/connectors/email.ts +11 -10
  199. package/src/lib/server/connectors/googlechat.ts +4 -4
  200. package/src/lib/server/connectors/inbound-audio-transcription.ts +2 -1
  201. package/src/lib/server/connectors/ingress-delivery.ts +23 -0
  202. package/src/lib/server/connectors/manager-roundtrip.test.ts +300 -0
  203. package/src/lib/server/connectors/manager.test.ts +352 -77
  204. package/src/lib/server/connectors/manager.ts +134 -673
  205. package/src/lib/server/connectors/matrix.ts +4 -4
  206. package/src/lib/server/connectors/message-sentinel.ts +7 -0
  207. package/src/lib/server/connectors/openclaw.test.ts +1 -1
  208. package/src/lib/server/connectors/openclaw.ts +8 -10
  209. package/src/lib/server/connectors/outbox.test.ts +192 -0
  210. package/src/lib/server/connectors/outbox.ts +369 -0
  211. package/src/lib/server/connectors/pairing.test.ts +18 -1
  212. package/src/lib/server/connectors/pairing.ts +49 -4
  213. package/src/lib/server/connectors/policy.ts +9 -3
  214. package/src/lib/server/connectors/reconnect-state.ts +71 -0
  215. package/src/lib/server/connectors/response-media.ts +256 -0
  216. package/src/lib/server/connectors/runtime-state.ts +67 -0
  217. package/src/lib/server/connectors/session.test.ts +357 -0
  218. package/src/lib/server/connectors/session.ts +422 -0
  219. package/src/lib/server/connectors/signal.ts +7 -7
  220. package/src/lib/server/connectors/slack.ts +43 -43
  221. package/src/lib/server/connectors/teams.ts +4 -4
  222. package/src/lib/server/connectors/telegram.ts +37 -43
  223. package/src/lib/server/connectors/types.ts +31 -1
  224. package/src/lib/server/connectors/whatsapp.test.ts +108 -0
  225. package/src/lib/server/connectors/whatsapp.ts +106 -34
  226. package/src/lib/server/context-manager.test.ts +409 -0
  227. package/src/lib/server/cost.test.ts +1 -1
  228. package/src/lib/server/daemon-policy.ts +78 -0
  229. package/src/lib/server/daemon-state-connectors.test.ts +167 -0
  230. package/src/lib/server/daemon-state.test.ts +283 -55
  231. package/src/lib/server/daemon-state.ts +106 -109
  232. package/src/lib/server/data-dir.test.ts +5 -5
  233. package/src/lib/server/data-dir.ts +4 -0
  234. package/src/lib/server/delegation-jobs-advanced.test.ts +1 -1
  235. package/src/lib/server/delegation-jobs.test.ts +87 -0
  236. package/src/lib/server/delegation-jobs.ts +42 -48
  237. package/src/lib/server/devserver-launch.ts +1 -1
  238. package/src/lib/server/document-utils.ts +7 -9
  239. package/src/lib/server/elevenlabs.ts +2 -1
  240. package/src/lib/server/embeddings.test.ts +105 -0
  241. package/src/lib/server/ethereum.ts +3 -2
  242. package/src/lib/server/eval/agent-regression.ts +3 -2
  243. package/src/lib/server/eval/runner.ts +2 -1
  244. package/src/lib/server/eval/scorer.ts +2 -1
  245. package/src/lib/server/evm-swap.ts +2 -1
  246. package/src/lib/server/gateway/protocol.test.ts +1 -1
  247. package/src/lib/server/guardian.ts +2 -1
  248. package/src/lib/server/heartbeat-blocked-suppression.test.ts +151 -0
  249. package/src/lib/server/heartbeat-service-timer.test.ts +6 -6
  250. package/src/lib/server/heartbeat-service.test.ts +406 -0
  251. package/src/lib/server/heartbeat-service.ts +54 -7
  252. package/src/lib/server/heartbeat-wake.test.ts +19 -0
  253. package/src/lib/server/heartbeat-wake.ts +17 -16
  254. package/src/lib/server/integrity-monitor.test.ts +149 -0
  255. package/src/lib/server/json-utils.ts +22 -0
  256. package/src/lib/server/knowledge-db.test.ts +13 -13
  257. package/src/lib/server/link-understanding.ts +2 -1
  258. package/src/lib/server/llm-response-cache.test.ts +1 -1
  259. package/src/lib/server/main-agent-loop-advanced.test.ts +65 -3
  260. package/src/lib/server/main-agent-loop.test.ts +6 -6
  261. package/src/lib/server/main-agent-loop.ts +21 -7
  262. package/src/lib/server/mcp-client.test.ts +1 -1
  263. package/src/lib/server/mcp-conformance.test.ts +1 -1
  264. package/src/lib/server/mcp-conformance.ts +3 -2
  265. package/src/lib/server/memory-consolidation.ts +2 -1
  266. package/src/lib/server/memory-db.test.ts +485 -0
  267. package/src/lib/server/memory-db.ts +39 -26
  268. package/src/lib/server/memory-graph.test.ts +2 -2
  269. package/src/lib/server/memory-policy.test.ts +7 -7
  270. package/src/lib/server/memory-retrieval.test.ts +1 -1
  271. package/src/lib/server/openclaw-config-sync.ts +2 -1
  272. package/src/lib/server/openclaw-deploy.test.ts +1 -1
  273. package/src/lib/server/openclaw-deploy.ts +8 -12
  274. package/src/lib/server/openclaw-exec-config.ts +2 -1
  275. package/src/lib/server/openclaw-gateway.ts +6 -7
  276. package/src/lib/server/openclaw-skills-normalize.ts +2 -1
  277. package/src/lib/server/openclaw-sync.ts +7 -5
  278. package/src/lib/server/orchestrator-lg-structure.test.ts +17 -0
  279. package/src/lib/server/orchestrator-lg.ts +199 -327
  280. package/src/lib/server/path-utils.ts +31 -0
  281. package/src/lib/server/perf.ts +161 -0
  282. package/src/lib/server/plugins-approval-guidance.ts +115 -0
  283. package/src/lib/server/plugins.test.ts +1 -1
  284. package/src/lib/server/plugins.ts +22 -132
  285. package/src/lib/server/process-manager.ts +5 -8
  286. package/src/lib/server/provider-health.test.ts +137 -0
  287. package/src/lib/server/provider-health.ts +3 -3
  288. package/src/lib/server/provider-model-discovery.ts +3 -12
  289. package/src/lib/server/queue-followups.test.ts +9 -9
  290. package/src/lib/server/queue-reconcile.test.ts +2 -2
  291. package/src/lib/server/queue-recovery.test.ts +269 -0
  292. package/src/lib/server/queue.test.ts +570 -0
  293. package/src/lib/server/queue.ts +62 -455
  294. package/src/lib/server/resolve-image.ts +30 -0
  295. package/src/lib/server/runtime-settings.test.ts +4 -4
  296. package/src/lib/server/runtime-storage-write-paths.test.ts +60 -0
  297. package/src/lib/server/schedule-normalization.test.ts +279 -0
  298. package/src/lib/server/schedule-service.ts +263 -0
  299. package/src/lib/server/scheduler.ts +17 -74
  300. package/src/lib/server/session-mailbox.test.ts +191 -0
  301. package/src/lib/server/session-run-manager.test.ts +640 -0
  302. package/src/lib/server/session-run-manager.ts +59 -15
  303. package/src/lib/server/session-tools/autonomy-tools.test.ts +20 -20
  304. package/src/lib/server/session-tools/calendar.ts +2 -1
  305. package/src/lib/server/session-tools/canvas.ts +2 -1
  306. package/src/lib/server/session-tools/chatroom.ts +2 -1
  307. package/src/lib/server/session-tools/connector.ts +26 -28
  308. package/src/lib/server/session-tools/context-mgmt.ts +3 -2
  309. package/src/lib/server/session-tools/crawl.ts +4 -3
  310. package/src/lib/server/session-tools/crud.ts +105 -324
  311. package/src/lib/server/session-tools/delegate-fallback.test.ts +9 -9
  312. package/src/lib/server/session-tools/delegate.ts +6 -8
  313. package/src/lib/server/session-tools/discovery-approvals.test.ts +15 -15
  314. package/src/lib/server/session-tools/discovery.ts +4 -3
  315. package/src/lib/server/session-tools/document.ts +2 -1
  316. package/src/lib/server/session-tools/email.ts +2 -1
  317. package/src/lib/server/session-tools/extract.ts +2 -1
  318. package/src/lib/server/session-tools/file.ts +4 -3
  319. package/src/lib/server/session-tools/http.ts +2 -1
  320. package/src/lib/server/session-tools/human-loop.ts +2 -1
  321. package/src/lib/server/session-tools/image-gen.ts +4 -3
  322. package/src/lib/server/session-tools/index.ts +26 -30
  323. package/src/lib/server/session-tools/mailbox.ts +2 -1
  324. package/src/lib/server/session-tools/manage-connectors.test.ts +4 -4
  325. package/src/lib/server/session-tools/manage-schedules.test.ts +12 -12
  326. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +5 -5
  327. package/src/lib/server/session-tools/manage-tasks.test.ts +2 -2
  328. package/src/lib/server/session-tools/monitor.ts +2 -1
  329. package/src/lib/server/session-tools/platform.ts +2 -1
  330. package/src/lib/server/session-tools/plugin-creator.ts +2 -1
  331. package/src/lib/server/session-tools/replicate.ts +3 -2
  332. package/src/lib/server/session-tools/session-tools-wiring.test.ts +6 -6
  333. package/src/lib/server/session-tools/shell.ts +4 -9
  334. package/src/lib/server/session-tools/subagent.ts +322 -170
  335. package/src/lib/server/session-tools/table.ts +6 -5
  336. package/src/lib/server/session-tools/wallet-tool.test.ts +3 -3
  337. package/src/lib/server/session-tools/wallet.ts +7 -6
  338. package/src/lib/server/session-tools/web-browser-config.test.ts +1 -0
  339. package/src/lib/server/session-tools/web-utils.ts +317 -0
  340. package/src/lib/server/session-tools/web.ts +62 -328
  341. package/src/lib/server/skill-prompt-budget.test.ts +1 -1
  342. package/src/lib/server/skills-normalize.ts +2 -1
  343. package/src/lib/server/storage-item-access.test.ts +302 -0
  344. package/src/lib/server/storage.ts +366 -314
  345. package/src/lib/server/stream-agent-chat.test.ts +82 -3
  346. package/src/lib/server/stream-agent-chat.ts +146 -510
  347. package/src/lib/server/stream-continuation.ts +412 -0
  348. package/src/lib/server/subagent-lineage.test.ts +647 -0
  349. package/src/lib/server/subagent-lineage.ts +435 -0
  350. package/src/lib/server/subagent-runtime.test.ts +484 -0
  351. package/src/lib/server/subagent-runtime.ts +419 -0
  352. package/src/lib/server/subagent-swarm.test.ts +391 -0
  353. package/src/lib/server/subagent-swarm.ts +564 -0
  354. package/src/lib/server/system-events.ts +3 -3
  355. package/src/lib/server/task-followups.test.ts +491 -0
  356. package/src/lib/server/task-followups.ts +391 -0
  357. package/src/lib/server/task-lifecycle.test.ts +205 -0
  358. package/src/lib/server/task-lifecycle.ts +200 -0
  359. package/src/lib/server/task-quality-gate.test.ts +1 -1
  360. package/src/lib/server/task-resume.ts +208 -0
  361. package/src/lib/server/task-service.test.ts +108 -0
  362. package/src/lib/server/task-service.ts +264 -0
  363. package/src/lib/server/task-validation.test.ts +1 -1
  364. package/src/lib/server/test-utils/run-with-temp-data-dir.ts +42 -0
  365. package/src/lib/server/tool-capability-policy.test.ts +2 -2
  366. package/src/lib/server/tool-capability-policy.ts +3 -2
  367. package/src/lib/server/tool-planning.ts +2 -1
  368. package/src/lib/server/tool-retry.ts +2 -3
  369. package/src/lib/server/wake-dispatcher.test.ts +303 -0
  370. package/src/lib/server/wake-dispatcher.ts +318 -0
  371. package/src/lib/server/wake-mode.test.ts +161 -0
  372. package/src/lib/server/wake-mode.ts +174 -0
  373. package/src/lib/server/wallet-service.ts +8 -9
  374. package/src/lib/server/watch-jobs.ts +2 -1
  375. package/src/lib/server/workspace-context.ts +2 -2
  376. package/src/lib/shared-utils.test.ts +142 -0
  377. package/src/lib/shared-utils.ts +62 -0
  378. package/src/lib/tool-event-summary.ts +2 -1
  379. package/src/lib/view-routes.test.ts +100 -0
  380. package/src/lib/wallet.test.ts +322 -6
  381. package/src/proxy.test.ts +4 -4
  382. package/src/proxy.ts +2 -3
  383. package/src/stores/set-if-changed.ts +40 -0
  384. package/src/stores/slices/agent-slice.ts +111 -0
  385. package/src/stores/slices/auth-slice.ts +25 -0
  386. package/src/stores/slices/data-slice.ts +301 -0
  387. package/src/stores/slices/index.ts +7 -0
  388. package/src/stores/slices/session-slice.ts +112 -0
  389. package/src/stores/slices/task-slice.ts +63 -0
  390. package/src/stores/slices/ui-slice.ts +192 -0
  391. package/src/stores/use-app-store.ts +17 -822
  392. package/src/stores/use-approval-store.ts +2 -1
  393. package/src/stores/use-chat-store.ts +8 -1
  394. package/src/types/index.ts +10 -0
@@ -2,7 +2,7 @@ import fs from 'fs'
2
2
  import path from 'path'
3
3
  import { DATA_DIR } from '../data-dir'
4
4
  import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
5
- import { isNoMessage } from './manager'
5
+ import { resolveConnectorIngressReply } from './ingress-delivery'
6
6
 
7
7
  const matrix: PlatformConnector = {
8
8
  async start(connector, botToken, onMessage): Promise<ConnectorInstance> {
@@ -48,9 +48,9 @@ const matrix: PlatformConnector = {
48
48
  }
49
49
 
50
50
  try {
51
- const response = await onMessage(inbound)
52
- if (isNoMessage(response)) return
53
- await client.sendText(roomId, response)
51
+ const reply = await resolveConnectorIngressReply(onMessage, inbound)
52
+ if (!reply) return
53
+ await client.sendText(roomId, reply.visibleText)
54
54
  } catch (err: any) {
55
55
  console.error(`[matrix] Error handling message:`, err.message)
56
56
  try {
@@ -0,0 +1,7 @@
1
+ /** Sentinel value agents return when no outbound reply should be sent */
2
+ export const NO_MESSAGE_SENTINEL = 'NO_MESSAGE'
3
+
4
+ /** Check if an agent response is the NO_MESSAGE sentinel (case-insensitive, trimmed) */
5
+ export function isNoMessage(text: string): boolean {
6
+ return text.trim().toUpperCase() === NO_MESSAGE_SENTINEL
7
+ }
@@ -3,7 +3,7 @@ import fs from 'node:fs'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
5
  import { test } from 'node:test'
6
- import openclaw from './openclaw.ts'
6
+ import openclaw from './openclaw'
7
7
 
8
8
  type WsFrame = Record<string, unknown>
9
9
 
@@ -1,8 +1,10 @@
1
1
  import crypto from 'node:crypto'
2
2
  import fs from 'node:fs'
3
3
  import path from 'node:path'
4
- import { DATA_DIR } from '../data-dir'
4
+ import { OPENCLAW_DATA_DIR } from '../data-dir'
5
+ import { safeJsonParse } from '../json-utils'
5
6
  import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
7
+ import { resolveConnectorIngressReply } from './ingress-delivery'
6
8
  import {
7
9
  createGatewayRequestFrame,
8
10
  parseGatewayFrame,
@@ -38,7 +40,6 @@ const DEFAULT_CHAT_HISTORY_LIMIT = 40
38
40
  const MIN_CHAT_HISTORY_LIMIT = 5
39
41
  const MAX_CHAT_HISTORY_LIMIT = 200
40
42
  const MAX_INLINE_ATTACHMENT_BYTES = 5_000_000
41
- const NO_MESSAGE_SENTINEL = 'NO_MESSAGE'
42
43
  const MAX_SEEN_HISTORY_MESSAGES = 4_096
43
44
  const RECENT_HISTORY_DUPLICATE_WINDOW_MS = 20_000
44
45
  const HISTORY_ERROR_LOG_INTERVAL_MS = 30_000
@@ -126,10 +127,6 @@ function isSecureWsUrl(url: string): boolean {
126
127
  return false
127
128
  }
128
129
 
129
- function isNoMessage(text: string): boolean {
130
- return text.trim().toUpperCase() === NO_MESSAGE_SENTINEL
131
- }
132
-
133
130
  function base64UrlEncode(buf: Buffer): string {
134
131
  return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
135
132
  }
@@ -185,7 +182,7 @@ function signDevicePayload(privateKeyPem: string, payload: string): string {
185
182
  }
186
183
 
187
184
  function resolveIdentityPath(connectorId: string): string {
188
- return path.join(DATA_DIR, 'openclaw', `${connectorId}-device.json`)
185
+ return path.join(OPENCLAW_DATA_DIR, `${connectorId}-device.json`)
189
186
  }
190
187
 
191
188
  function persistIdentity(filePath: string, identity: DeviceIdentity) {
@@ -205,7 +202,7 @@ function persistIdentity(filePath: string, identity: DeviceIdentity) {
205
202
  function loadOrCreateIdentity(filePath: string): DeviceIdentity {
206
203
  try {
207
204
  if (fs.existsSync(filePath)) {
208
- const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as StoredIdentity
205
+ const parsed = safeJsonParse<StoredIdentity | null>(fs.readFileSync(filePath, 'utf8'), null)
209
206
  if (
210
207
  parsed?.version === 1
211
208
  && typeof parsed.deviceId === 'string'
@@ -960,8 +957,9 @@ const openclaw: PlatformConnector = {
960
957
  markRecentInbound(inbound, now)
961
958
 
962
959
  try {
963
- const response = await onMessage(inbound)
964
- if (!isNoMessage(response)) await sendChat(inbound.channelId, response)
960
+ const reply = await resolveConnectorIngressReply(onMessage, inbound)
961
+ if (!reply) return
962
+ await sendChat(inbound.channelId, reply.visibleText)
965
963
  } catch (err: unknown) {
966
964
  const message = getErrorMessage(err)
967
965
  console.error('[openclaw] Error routing inbound chat event:', message)
@@ -0,0 +1,192 @@
1
+ import assert from 'node:assert/strict'
2
+ import { describe, it } from 'node:test'
3
+ import { runWithTempDataDir } from '../test-utils/run-with-temp-data-dir'
4
+
5
+ describe('connector outbox', () => {
6
+ it('delivers scheduled follow-ups through the durable outbox worker', () => {
7
+ const output = runWithTempDataDir(`
8
+ const storageMod = await import('./src/lib/server/storage')
9
+ const managerMod = await import('./src/lib/server/connectors/manager')
10
+ const outboxMod = await import('./src/lib/server/connectors/outbox')
11
+ const pluginsMod = await import('./src/lib/server/plugins')
12
+ const storage = storageMod.default || storageMod
13
+ const manager = managerMod.default || managerMod
14
+ const outbox = outboxMod.default || outboxMod
15
+ const plugins = pluginsMod.default || pluginsMod
16
+
17
+ const attempts = []
18
+ plugins.getPluginManager().registerBuiltin('test-outbox-plugin', {
19
+ name: 'Test Outbox Plugin',
20
+ connectors: [{
21
+ id: 'test-outbox',
22
+ name: 'Test Outbox',
23
+ description: 'Test connector for outbox delivery',
24
+ startListener: async () => async () => {},
25
+ sendMessage: async (channelId, text) => {
26
+ attempts.push({ channelId, text })
27
+ return { messageId: 'outbox-msg-1' }
28
+ },
29
+ }],
30
+ })
31
+
32
+ const now = Date.now()
33
+ storage.saveSettings({})
34
+ storage.saveConnectors({
35
+ conn_1: {
36
+ id: 'conn_1',
37
+ name: 'Outbox Connector',
38
+ platform: 'test-outbox',
39
+ agentId: 'agent_1',
40
+ credentialId: null,
41
+ config: { botToken: 'test-token' },
42
+ isEnabled: true,
43
+ status: 'stopped',
44
+ createdAt: now,
45
+ updatedAt: now,
46
+ },
47
+ })
48
+
49
+ await manager.startConnector('conn_1')
50
+ const scheduled = manager.scheduleConnectorFollowUp({
51
+ connectorId: 'conn_1',
52
+ channelId: '15550001111',
53
+ text: 'Follow up later',
54
+ delaySec: 1,
55
+ })
56
+ const before = storage.loadConnectorOutbox()[scheduled.followUpId]
57
+ await outbox.runConnectorOutboxNow({ now: scheduled.sendAt + 5 })
58
+ const after = storage.loadConnectorOutbox()[scheduled.followUpId]
59
+ console.log(JSON.stringify({ scheduled, before, after, attempts }))
60
+ `, { prefix: 'swarmclaw-outbox-test-' })
61
+
62
+ assert.equal(output.before.status, 'pending')
63
+ assert.equal(output.after.status, 'sent')
64
+ assert.equal(output.after.lastMessageId, 'outbox-msg-1')
65
+ assert.deepEqual(output.attempts, [{ channelId: '15550001111', text: 'Follow up later' }])
66
+ })
67
+
68
+ it('retries failed outbox sends with backoff and eventually marks them sent', () => {
69
+ const output = runWithTempDataDir(`
70
+ const storageMod = await import('./src/lib/server/storage')
71
+ const managerMod = await import('./src/lib/server/connectors/manager')
72
+ const outboxMod = await import('./src/lib/server/connectors/outbox')
73
+ const pluginsMod = await import('./src/lib/server/plugins')
74
+ const storage = storageMod.default || storageMod
75
+ const manager = managerMod.default || managerMod
76
+ const outbox = outboxMod.default || outboxMod
77
+ const plugins = pluginsMod.default || pluginsMod
78
+
79
+ let sendCount = 0
80
+ plugins.getPluginManager().registerBuiltin('test-outbox-retry-plugin', {
81
+ name: 'Test Outbox Retry Plugin',
82
+ connectors: [{
83
+ id: 'test-outbox-retry',
84
+ name: 'Test Outbox Retry',
85
+ description: 'Test connector for outbox retries',
86
+ startListener: async () => async () => {},
87
+ sendMessage: async (channelId, text) => {
88
+ sendCount += 1
89
+ if (sendCount === 1) throw new Error('temporary send failure')
90
+ return { messageId: 'retry-msg-2' }
91
+ },
92
+ }],
93
+ })
94
+
95
+ const now = Date.now()
96
+ storage.saveSettings({})
97
+ storage.saveConnectors({
98
+ conn_retry: {
99
+ id: 'conn_retry',
100
+ name: 'Retry Connector',
101
+ platform: 'test-outbox-retry',
102
+ agentId: 'agent_1',
103
+ credentialId: null,
104
+ config: { botToken: 'test-token' },
105
+ isEnabled: true,
106
+ status: 'stopped',
107
+ createdAt: now,
108
+ updatedAt: now,
109
+ },
110
+ })
111
+
112
+ await manager.startConnector('conn_retry')
113
+ const queued = outbox.enqueueConnectorOutbox({
114
+ connectorId: 'conn_retry',
115
+ channelId: 'retry-channel',
116
+ text: 'Retry me',
117
+ sendAt: now,
118
+ maxAttempts: 3,
119
+ })
120
+ await outbox.runConnectorOutboxNow({ now: queued.sendAt + 1 })
121
+ const afterFirst = storage.loadConnectorOutbox()[queued.outboxId]
122
+ await outbox.runConnectorOutboxNow({ now: afterFirst.sendAt + 1 })
123
+ const afterSecond = storage.loadConnectorOutbox()[queued.outboxId]
124
+ console.log(JSON.stringify({ afterFirst, afterSecond, sendCount }))
125
+ `, { prefix: 'swarmclaw-outbox-test-' })
126
+
127
+ assert.equal(output.afterFirst.status, 'pending')
128
+ assert.equal(output.afterFirst.attemptCount, 1)
129
+ assert.match(output.afterFirst.lastError, /temporary send failure/i)
130
+ assert.equal(output.afterSecond.status, 'sent')
131
+ assert.equal(output.afterSecond.attemptCount, 2)
132
+ assert.equal(output.afterSecond.lastMessageId, 'retry-msg-2')
133
+ assert.equal(output.sendCount, 2)
134
+ })
135
+
136
+ it('dedupes and replaces scheduled follow-ups durably', () => {
137
+ const output = runWithTempDataDir(`
138
+ const storageMod = await import('./src/lib/server/storage')
139
+ const managerMod = await import('./src/lib/server/connectors/manager')
140
+ const storage = storageMod.default || storageMod
141
+ const manager = managerMod.default || managerMod
142
+
143
+ const now = Date.now()
144
+ storage.saveSettings({})
145
+ storage.saveConnectors({
146
+ conn_1: {
147
+ id: 'conn_1',
148
+ name: 'Outbox Connector',
149
+ platform: 'whatsapp',
150
+ agentId: 'agent_1',
151
+ credentialId: null,
152
+ config: {},
153
+ isEnabled: true,
154
+ status: 'stopped',
155
+ createdAt: now,
156
+ updatedAt: now,
157
+ },
158
+ })
159
+
160
+ const first = manager.scheduleConnectorFollowUp({
161
+ connectorId: 'conn_1',
162
+ channelId: '15550001111@s.whatsapp.net',
163
+ text: 'First follow-up',
164
+ delaySec: 30,
165
+ dedupeKey: 'dup-1',
166
+ })
167
+ const second = manager.scheduleConnectorFollowUp({
168
+ connectorId: 'conn_1',
169
+ channelId: '15550001111@s.whatsapp.net',
170
+ text: 'Second follow-up same dedupe',
171
+ delaySec: 30,
172
+ dedupeKey: 'dup-1',
173
+ })
174
+ const third = manager.scheduleConnectorFollowUp({
175
+ connectorId: 'conn_1',
176
+ channelId: '15550001111@s.whatsapp.net',
177
+ text: 'Replacement follow-up',
178
+ delaySec: 30,
179
+ dedupeKey: 'dup-1',
180
+ replaceExisting: true,
181
+ })
182
+ const outbox = storage.loadConnectorOutbox()
183
+ console.log(JSON.stringify({ first, second, third, outbox }))
184
+ `, { prefix: 'swarmclaw-outbox-test-' })
185
+
186
+ assert.equal(output.first.followUpId, output.second.followUpId)
187
+ assert.notEqual(output.third.followUpId, output.first.followUpId)
188
+ assert.equal(output.outbox[output.first.followUpId].status, 'cancelled')
189
+ assert.equal(output.outbox[output.third.followUpId].status, 'pending')
190
+ assert.equal(output.outbox[output.third.followUpId].text, 'Replacement follow-up')
191
+ })
192
+ })
@@ -0,0 +1,369 @@
1
+ import { genId } from '@/lib/id'
2
+ import {
3
+ loadConnectorOutbox,
4
+ patchStoredItem,
5
+ upsertConnectorOutboxItem,
6
+ } from '../storage'
7
+ import { notify } from '../ws-hub'
8
+ import { errorMessage, hmrSingleton } from '@/lib/shared-utils'
9
+
10
+
11
+ export type ConnectorOutboxStatus =
12
+ | 'pending'
13
+ | 'processing'
14
+ | 'sent'
15
+ | 'suppressed'
16
+ | 'failed'
17
+ | 'cancelled'
18
+
19
+ export interface ConnectorOutboxEntry extends Record<string, unknown> {
20
+ id: string
21
+ status: ConnectorOutboxStatus
22
+ sendAt: number
23
+ createdAt: number
24
+ updatedAt: number
25
+ attemptCount: number
26
+ maxAttempts: number
27
+ /** Destination fields (set by enqueueConnectorOutbox) */
28
+ connectorId?: string
29
+ platform?: string
30
+ channelId?: string
31
+ text?: string
32
+ sessionId?: string | null
33
+ imageUrl?: string
34
+ fileUrl?: string
35
+ mediaPath?: string
36
+ mimeType?: string
37
+ fileName?: string
38
+ caption?: string
39
+ replyToMessageId?: string
40
+ threadId?: string
41
+ ptt?: boolean
42
+ dedupeKey?: string | null
43
+ lastError?: string | null
44
+ deliveredAt?: number | null
45
+ lastMessageId?: string | null
46
+ processingLeaseId?: string | null
47
+ processingStartedAt?: number | null
48
+ }
49
+
50
+ const RETRY_BASE_MS = 5_000
51
+ const RETRY_MAX_MS = 5 * 60_000
52
+ const DEFAULT_MAX_ATTEMPTS = 6
53
+ const CLAIM_STALE_MS = 60_000
54
+ const MAX_BATCH_SIZE = 10
55
+
56
+ type OutboxState = {
57
+ timer: ReturnType<typeof setTimeout> | null
58
+ dueAt: number | null
59
+ running: boolean
60
+ pendingKick: boolean
61
+ }
62
+
63
+ const outboxState: OutboxState = hmrSingleton<OutboxState>('__swarmclaw_connector_outbox_state__', () => ({
64
+ timer: null,
65
+ dueAt: null,
66
+ running: false,
67
+ pendingKick: false,
68
+ }))
69
+
70
+ function normalizeEntry(value: unknown): ConnectorOutboxEntry | null {
71
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
72
+ const row = value as Record<string, unknown>
73
+ const id = typeof row.id === 'string' ? row.id.trim() : ''
74
+ const channelId = typeof row.channelId === 'string' ? row.channelId : ''
75
+ if (!id || !channelId) return null
76
+ return {
77
+ id,
78
+
79
+
80
+ channelId,
81
+ text: typeof row.text === 'string' ? row.text : '',
82
+ sessionId: typeof row.sessionId === 'string' ? row.sessionId : null,
83
+ imageUrl: typeof row.imageUrl === 'string' ? row.imageUrl : undefined,
84
+ fileUrl: typeof row.fileUrl === 'string' ? row.fileUrl : undefined,
85
+ mediaPath: typeof row.mediaPath === 'string' ? row.mediaPath : undefined,
86
+ mimeType: typeof row.mimeType === 'string' ? row.mimeType : undefined,
87
+ fileName: typeof row.fileName === 'string' ? row.fileName : undefined,
88
+ caption: typeof row.caption === 'string' ? row.caption : undefined,
89
+ replyToMessageId: typeof row.replyToMessageId === 'string' ? row.replyToMessageId : undefined,
90
+ threadId: typeof row.threadId === 'string' ? row.threadId : undefined,
91
+ ptt: row.ptt === true,
92
+ status: normalizeStatus(row.status),
93
+ sendAt: typeof row.sendAt === 'number' ? row.sendAt : 0,
94
+ createdAt: typeof row.createdAt === 'number' ? row.createdAt : 0,
95
+ updatedAt: typeof row.updatedAt === 'number' ? row.updatedAt : 0,
96
+ attemptCount: typeof row.attemptCount === 'number' ? row.attemptCount : 0,
97
+ maxAttempts: typeof row.maxAttempts === 'number' ? row.maxAttempts : DEFAULT_MAX_ATTEMPTS,
98
+ dedupeKey: typeof row.dedupeKey === 'string' ? row.dedupeKey : null,
99
+ lastError: typeof row.lastError === 'string' ? row.lastError : null,
100
+ deliveredAt: typeof row.deliveredAt === 'number' ? row.deliveredAt : null,
101
+ lastMessageId: typeof row.lastMessageId === 'string' ? row.lastMessageId : null,
102
+ processingLeaseId: typeof row.processingLeaseId === 'string' ? row.processingLeaseId : null,
103
+ processingStartedAt: typeof row.processingStartedAt === 'number' ? row.processingStartedAt : null,
104
+ }
105
+ }
106
+
107
+ function normalizeStatus(value: unknown): ConnectorOutboxStatus {
108
+ switch (value) {
109
+ case 'processing':
110
+ case 'sent':
111
+ case 'suppressed':
112
+ case 'failed':
113
+ case 'cancelled':
114
+ return value
115
+ default:
116
+ return 'pending'
117
+ }
118
+ }
119
+
120
+ function isTerminalStatus(status: ConnectorOutboxStatus): boolean {
121
+ return status === 'sent' || status === 'suppressed' || status === 'failed' || status === 'cancelled'
122
+ }
123
+
124
+ function isClaimEligible(entry: ConnectorOutboxEntry, now: number): boolean {
125
+ if (entry.status === 'pending') return entry.sendAt <= now
126
+ if (entry.status !== 'processing') return false
127
+ const startedAt = entry.processingStartedAt || entry.updatedAt || entry.sendAt || 0
128
+ return now - startedAt >= CLAIM_STALE_MS
129
+ }
130
+
131
+ function listEntries(): ConnectorOutboxEntry[] {
132
+ return Object.values(loadConnectorOutbox())
133
+ .map((value) => normalizeEntry(value))
134
+ .filter((value): value is ConnectorOutboxEntry => !!value)
135
+ }
136
+
137
+ function nextRetryAt(now: number, attemptCount: number): number {
138
+ const backoff = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * (2 ** Math.max(0, attemptCount - 1)))
139
+ return now + backoff
140
+ }
141
+
142
+ function scheduleTimer(delayMs: number): void {
143
+ const nextDueAt = Date.now() + Math.max(0, delayMs)
144
+ if (outboxState.timer && outboxState.dueAt !== null && outboxState.dueAt <= nextDueAt) return
145
+ if (outboxState.timer) {
146
+ clearTimeout(outboxState.timer)
147
+ outboxState.timer = null
148
+ }
149
+ outboxState.dueAt = nextDueAt
150
+ outboxState.timer = setTimeout(() => {
151
+ outboxState.timer = null
152
+ outboxState.dueAt = null
153
+ void runConnectorOutboxNow().catch((err: unknown) => {
154
+ console.warn(`[connector-outbox] Worker tick failed: ${errorMessage(err)}`)
155
+ })
156
+ }, Math.max(0, delayMs))
157
+ outboxState.timer.unref?.()
158
+ }
159
+
160
+ function rescheduleFromStorage(now = Date.now()): void {
161
+ const active = listEntries()
162
+ .filter((entry) => !isTerminalStatus(entry.status))
163
+ .sort((a, b) => a.sendAt - b.sendAt || a.createdAt - b.createdAt)
164
+ if (!active.length) {
165
+ if (outboxState.timer) {
166
+ clearTimeout(outboxState.timer)
167
+ outboxState.timer = null
168
+ }
169
+ outboxState.dueAt = null
170
+ return
171
+ }
172
+ const nextDue = active[0].sendAt
173
+ scheduleTimer(Math.max(0, nextDue - now))
174
+ }
175
+
176
+ function claimEntry(id: string, now: number): ConnectorOutboxEntry | null {
177
+ const leaseId = `${now}:${Math.random().toString(16).slice(2, 10)}`
178
+ const claimed = patchStoredItem<ConnectorOutboxEntry>('connector_outbox', id, (current) => {
179
+ const entry = normalizeEntry(current)
180
+ if (!entry) return current
181
+ if (!isClaimEligible(entry, now)) return entry
182
+ return {
183
+ ...entry,
184
+ status: 'processing',
185
+ processingLeaseId: leaseId,
186
+ processingStartedAt: now,
187
+ updatedAt: now,
188
+ }
189
+ })
190
+ const normalized = normalizeEntry(claimed)
191
+ if (!normalized) return null
192
+ if (normalized.status !== 'processing' || normalized.processingLeaseId !== leaseId) return null
193
+ return normalized
194
+ }
195
+
196
+ async function processEntry(id: string, now: number): Promise<ConnectorOutboxEntry | null> {
197
+ const claimed = claimEntry(id, now)
198
+ if (!claimed) return null
199
+
200
+ try {
201
+ const { sendConnectorMessage } = await import('./manager')
202
+ // Outbox entries always have channelId+text from enqueueConnectorOutbox
203
+ const result = await sendConnectorMessage(claimed as ConnectorOutboxEntry & { channelId: string; text: string })
204
+ const deliveredAt = Date.now()
205
+ const next: ConnectorOutboxEntry = {
206
+ ...claimed,
207
+ connectorId: result.connectorId,
208
+ platform: result.platform,
209
+ channelId: result.channelId,
210
+ status: result.suppressed ? 'suppressed' : 'sent',
211
+ attemptCount: claimed.attemptCount + 1,
212
+ updatedAt: deliveredAt,
213
+ deliveredAt,
214
+ lastMessageId: result.messageId || null,
215
+ lastError: null,
216
+ processingLeaseId: null,
217
+ processingStartedAt: null,
218
+ }
219
+ upsertConnectorOutboxItem(next.id, next)
220
+ notify('connector_outbox')
221
+ return next
222
+ } catch (err: unknown) {
223
+ const failedAt = Date.now()
224
+ const nextAttemptCount = claimed.attemptCount + 1
225
+ const permanent = nextAttemptCount >= claimed.maxAttempts
226
+ const next: ConnectorOutboxEntry = {
227
+ ...claimed,
228
+ status: permanent ? 'failed' : 'pending',
229
+ attemptCount: nextAttemptCount,
230
+ updatedAt: failedAt,
231
+ sendAt: permanent ? claimed.sendAt : nextRetryAt(failedAt, nextAttemptCount),
232
+ lastError: errorMessage(err),
233
+ processingLeaseId: null,
234
+ processingStartedAt: null,
235
+ }
236
+ upsertConnectorOutboxItem(next.id, next)
237
+ notify('connector_outbox')
238
+ return next
239
+ }
240
+ }
241
+
242
+ export function enqueueConnectorOutbox(
243
+ input: Record<string, unknown> & {
244
+ sendAt?: number
245
+ maxAttempts?: number
246
+ dedupeKey?: string | null
247
+ },
248
+ options?: { replaceExisting?: boolean },
249
+ ): { outboxId: string; sendAt: number } {
250
+ const now = Date.now()
251
+ const requestedSendAt = typeof input.sendAt === 'number' ? input.sendAt : now
252
+ const sendAt = Math.max(now, requestedSendAt)
253
+ const dedupeKey = input.dedupeKey?.trim() || null
254
+
255
+ if (dedupeKey) {
256
+ const existing = findPendingConnectorOutboxByDedupe(dedupeKey, now)
257
+ if (existing && existing.sendAt > now && !options?.replaceExisting) {
258
+ return { outboxId: existing.id, sendAt: existing.sendAt }
259
+ }
260
+ if (existing && options?.replaceExisting) {
261
+ patchStoredItem<ConnectorOutboxEntry>('connector_outbox', existing.id, (current) => {
262
+ const entry = normalizeEntry(current)
263
+ if (!entry || isTerminalStatus(entry.status)) return entry
264
+ return {
265
+ ...entry,
266
+ status: 'cancelled',
267
+ updatedAt: now,
268
+ processingLeaseId: null,
269
+ processingStartedAt: null,
270
+ }
271
+ })
272
+ }
273
+ }
274
+
275
+ const entry: ConnectorOutboxEntry = {
276
+ ...input,
277
+ id: genId(),
278
+ status: 'pending',
279
+ sendAt,
280
+ createdAt: now,
281
+ updatedAt: now,
282
+ attemptCount: 0,
283
+ maxAttempts: Math.max(1, Math.trunc(input.maxAttempts || DEFAULT_MAX_ATTEMPTS)),
284
+ dedupeKey,
285
+ lastError: null,
286
+ deliveredAt: null,
287
+ lastMessageId: null,
288
+ processingLeaseId: null,
289
+ processingStartedAt: null,
290
+ }
291
+ upsertConnectorOutboxItem(entry.id, entry)
292
+ notify('connector_outbox')
293
+ rescheduleFromStorage(now)
294
+ return { outboxId: entry.id, sendAt: entry.sendAt }
295
+ }
296
+
297
+ export function findPendingConnectorOutboxByDedupe(dedupeKey: string, now = Date.now()): ConnectorOutboxEntry | null {
298
+ const normalizedKey = dedupeKey.trim()
299
+ if (!normalizedKey) return null
300
+ return listEntries()
301
+ .filter((entry) =>
302
+ entry.dedupeKey === normalizedKey
303
+ && !isTerminalStatus(entry.status)
304
+ && (entry.sendAt > now || entry.status === 'processing'),
305
+ )
306
+ .sort((a, b) => a.sendAt - b.sendAt || a.createdAt - b.createdAt)[0] || null
307
+ }
308
+
309
+ export async function runConnectorOutboxNow(options?: {
310
+ now?: number
311
+ maxItems?: number
312
+ onlyIds?: string[]
313
+ }): Promise<ConnectorOutboxEntry[]> {
314
+ if (outboxState.running) {
315
+ outboxState.pendingKick = true
316
+ return []
317
+ }
318
+
319
+ outboxState.running = true
320
+ const now = options?.now ?? Date.now()
321
+ try {
322
+ const onlyIds = new Set((options?.onlyIds || []).filter(Boolean))
323
+ const candidates = listEntries()
324
+ .filter((entry) => (onlyIds.size === 0 || onlyIds.has(entry.id)) && isClaimEligible(entry, now))
325
+ .sort((a, b) => a.sendAt - b.sendAt || a.createdAt - b.createdAt)
326
+ .slice(0, Math.max(1, options?.maxItems || MAX_BATCH_SIZE))
327
+
328
+ const processed: ConnectorOutboxEntry[] = []
329
+ for (const entry of candidates) {
330
+ const next = await processEntry(entry.id, now)
331
+ if (next) processed.push(next)
332
+ }
333
+ return processed
334
+ } finally {
335
+ outboxState.running = false
336
+ if (outboxState.pendingKick) {
337
+ outboxState.pendingKick = false
338
+ scheduleTimer(0)
339
+ } else {
340
+ rescheduleFromStorage()
341
+ }
342
+ }
343
+ }
344
+
345
+ export function startConnectorOutboxWorker(): void {
346
+ rescheduleFromStorage()
347
+ }
348
+
349
+ export function stopConnectorOutboxWorker(): void {
350
+ if (outboxState.timer) {
351
+ clearTimeout(outboxState.timer)
352
+ outboxState.timer = null
353
+ }
354
+ outboxState.dueAt = null
355
+ outboxState.pendingKick = false
356
+ }
357
+
358
+ export function getConnectorOutboxStatus(): {
359
+ queued: number
360
+ running: boolean
361
+ nextDueAt: number | null
362
+ } {
363
+ const queued = listEntries().filter((entry) => !isTerminalStatus(entry.status)).length
364
+ return {
365
+ queued,
366
+ running: outboxState.running,
367
+ nextDueAt: outboxState.dueAt,
368
+ }
369
+ }
@@ -8,12 +8,14 @@ import {
8
8
  approvePairingCode,
9
9
  clearConnectorPairingState,
10
10
  createOrTouchPairingRequest,
11
+ getWhatsAppApprovedSenderIds,
11
12
  isSenderAllowed,
12
13
  listPendingPairingRequests,
13
14
  listStoredAllowedSenders,
15
+ normalizeWhatsAppApprovedContacts,
14
16
  parseAllowFromCsv,
15
17
  parsePairingPolicy,
16
- } from './pairing.ts'
18
+ } from './pairing'
17
19
 
18
20
  function withTempDataDir<T>(fn: (dir: string) => T): T {
19
21
  const original = process.env.DATA_DIR
@@ -97,3 +99,18 @@ test('addAllowedSender deduplicates and normalizes sender ids', () => {
97
99
  assert.deepEqual(listStoredAllowedSenders(connectorId), ['test@example.com'])
98
100
  })
99
101
  })
102
+
103
+ test('normalizeWhatsAppApprovedContacts trims entries and deduplicates equivalent phone identifiers', () => {
104
+ const contacts = normalizeWhatsAppApprovedContacts([
105
+ { id: 'one', label: ' Alice ', phone: ' +1 (555) 000-1111 ' },
106
+ { id: 'two', label: 'Alice JID', phone: '15550001111@s.whatsapp.net' },
107
+ { id: '', label: '', phone: '16660002222@s.whatsapp.net' },
108
+ { id: 'empty', label: 'Ignored', phone: ' ' },
109
+ ])
110
+
111
+ assert.deepEqual(contacts, [
112
+ { id: 'one', label: 'Alice', phone: '+1 (555) 000-1111' },
113
+ { id: 'wa-contact-2', label: '16660002222@s.whatsapp.net', phone: '16660002222@s.whatsapp.net' },
114
+ ])
115
+ assert.deepEqual(getWhatsAppApprovedSenderIds(contacts), ['+1 (555) 000-1111', '16660002222@s.whatsapp.net'])
116
+ })