@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
@@ -6,9 +6,8 @@ import {
6
6
  upsertConnectorHealthEvent,
7
7
  } from '../storage'
8
8
  import type { ConnectorHealthEventType } from '@/types'
9
+ import { dedup, errorMessage, sleep } from '@/lib/shared-utils'
9
10
  import { WORKSPACE_DIR } from '../data-dir'
10
- import { UPLOAD_DIR } from '../storage'
11
- import fs from 'fs'
12
11
  import path from 'path'
13
12
  import { streamAgentChat } from '../stream-agent-chat'
14
13
  import { notify } from '../ws-hub'
@@ -33,15 +32,13 @@ import { buildIdentityContinuityContext } from '../identity-continuity'
33
32
  import { ensureAgentThreadSession } from '../agent-thread-session'
34
33
  import { getProvider } from '@/lib/providers'
35
34
  import type { Agent, Connector, MessageSource, Chatroom, ChatroomMessage, Session } from '@/types'
36
- import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
35
+ import type { ConnectorInstance, InboundMessage } from './types'
37
36
  import {
38
37
  addAllowedSender,
39
38
  approvePairingCode,
40
39
  createOrTouchPairingRequest,
41
- isSenderAllowed,
42
40
  listPendingPairingRequests,
43
41
  listStoredAllowedSenders,
44
- parseAllowFromCsv,
45
42
  parsePairingPolicy,
46
43
  type PairingPolicy,
47
44
  } from './pairing'
@@ -61,7 +58,66 @@ import {
61
58
  } from './policy'
62
59
  import { buildConnectorThreadContextBlock, resolveThreadPersonaLabel } from './thread-context'
63
60
  import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from '../assistant-control'
64
- import { requestApprovalMaybeAutoApprove } from '../approvals'
61
+ import {
62
+ buildInboundApprovalSubject as buildInboundApprovalSubjectHelper,
63
+ enforceInboundAccessPolicy as enforceInboundAccessPolicyHelper,
64
+ resolveInboundApprovalSenderId as resolveInboundApprovalSenderIdHelper,
65
+ resolvePairingAccess as resolvePairingAccessHelper,
66
+ } from './access'
67
+ import {
68
+ findDirectSessionForInbound as findDirectSessionForInboundHelper,
69
+ pushSessionMessage as pushSessionMessageHelper,
70
+ resolveDirectSession as resolveDirectSessionHelper,
71
+ } from './session'
72
+ import { NO_MESSAGE_SENTINEL, isNoMessage } from './message-sentinel'
73
+ import {
74
+ buildInboundAttachmentPaths,
75
+ connectorSupportsBinaryMedia,
76
+ extractEmbeddedMedia,
77
+ formatInboundUserText,
78
+ formatMediaLine,
79
+ normalizeWhatsappTarget,
80
+ parseConnectorToolInput,
81
+ parseConnectorToolResult,
82
+ parseSseDataEvents,
83
+ selectOutboundMediaFiles,
84
+ uploadApiUrlFromPath,
85
+ visibleConnectorToolText,
86
+ } from './response-media'
87
+ import {
88
+ getConnectorReplySendOptions,
89
+ maybeSendStatusReaction,
90
+ recordConnectorOutboundDelivery,
91
+ } from './delivery'
92
+ import { enqueueConnectorOutbox } from './outbox'
93
+ import {
94
+ advanceConnectorReconnectState,
95
+ clearReconnectState,
96
+ connectorReconnectStateStore,
97
+ createConnectorReconnectState,
98
+ getAllReconnectStates,
99
+ getReconnectState,
100
+ setReconnectState,
101
+ type ConnectorReconnectState,
102
+ } from './reconnect-state'
103
+ import { connectorRuntimeState, runningConnectors } from './runtime-state'
104
+
105
+ export {
106
+ advanceConnectorReconnectState,
107
+ clearReconnectState,
108
+ createConnectorReconnectState,
109
+ extractEmbeddedMedia,
110
+ formatInboundUserText,
111
+ formatMediaLine,
112
+ getAllReconnectStates,
113
+ getConnectorReplySendOptions,
114
+ getReconnectState,
115
+ isNoMessage,
116
+ recordConnectorOutboundDelivery,
117
+ selectOutboundMediaFiles,
118
+ setReconnectState,
119
+ }
120
+ export type { ConnectorReconnectState }
65
121
 
66
122
  let streamAgentChatImpl = streamAgentChat
67
123
 
@@ -71,363 +127,21 @@ export function setStreamAgentChatForTest(
71
127
  streamAgentChatImpl = handler || streamAgentChat
72
128
  }
73
129
 
74
- function resolveUploadPathFromUrl(rawUrl: string): string | null {
75
- if (!rawUrl) return null
76
- const normalized = rawUrl.trim()
77
- const match = normalized.match(/\/api\/uploads\/([^?#)\s]+)/)
78
- if (!match) return null
79
- let decoded: string
80
- try { decoded = decodeURIComponent(match[1]) } catch { decoded = match[1] }
81
- const safeName = decoded.replace(/[^a-zA-Z0-9._-]/g, '')
82
- if (!safeName) return null
83
- const filePath = path.join(UPLOAD_DIR, safeName)
84
- return fs.existsSync(filePath) ? filePath : null
85
- }
86
-
87
- function uploadApiUrlFromPath(filePath: string): string | null {
88
- const rel = path.relative(UPLOAD_DIR, filePath)
89
- if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null
90
- const fileName = path.basename(rel)
91
- return `/api/uploads/${encodeURIComponent(fileName)}`
92
- }
93
-
94
- function parseSseDataEvents(raw: string): Array<Record<string, unknown>> {
95
- if (!raw) return []
96
- const events: Array<Record<string, unknown>> = []
97
- const lines = raw.split('\n')
98
- for (const line of lines) {
99
- if (!line.startsWith('data: ')) continue
100
- try {
101
- const parsed = JSON.parse(line.slice(6).trim())
102
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
103
- events.push(parsed as Record<string, unknown>)
104
- }
105
- } catch { /* ignore malformed event lines */ }
106
- }
107
- return events
108
- }
109
-
110
- function parseConnectorToolResult(toolOutput: string): { status?: string; to?: string; followUpId?: string; messageId?: string } | null {
111
- const raw = toolOutput.trim()
112
- if (!raw) return null
113
- try {
114
- const parsed = JSON.parse(raw)
115
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
116
- const record = parsed as Record<string, unknown>
117
- const status = typeof record.status === 'string' ? String(record.status) : undefined
118
- const to = typeof record.to === 'string' ? String(record.to) : undefined
119
- const followUpId = typeof record.followUpId === 'string' ? String(record.followUpId) : undefined
120
- const messageId = typeof record.messageId === 'string' ? String(record.messageId) : undefined
121
- return { status, to, followUpId, messageId }
122
- } catch {
123
- return null
124
- }
125
- }
126
-
127
- function parseConnectorToolInput(toolInput: string): Record<string, unknown> | null {
128
- const raw = toolInput.trim()
129
- if (!raw) return null
130
- try {
131
- const parsed = JSON.parse(raw)
132
- return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
133
- ? parsed as Record<string, unknown>
134
- : null
135
- } catch {
136
- return null
137
- }
138
- }
139
-
140
- function visibleConnectorToolText(input: Record<string, unknown> | null): string {
141
- if (!input) return ''
142
- const voiceText = typeof input.voiceText === 'string' ? input.voiceText.trim() : ''
143
- if (voiceText) return voiceText
144
- const message = typeof input.message === 'string' ? input.message.trim() : ''
145
- if (message) return message
146
- const caption = typeof input.caption === 'string' ? input.caption.trim() : ''
147
- if (caption) return caption
148
- const text = typeof input.text === 'string' ? input.text.trim() : ''
149
- if (text) return text
150
- return ''
151
- }
152
-
153
- function canonicalUploadMediaKey(filePath: string): string {
154
- const base = path.basename(filePath)
155
- const ext = path.extname(base).toLowerCase()
156
- const normalized = base
157
- .replace(/^\d{10,16}-/, '')
158
- .replace(/^(?:browser|screenshot)-\d{10,16}(?:-\d+)?\./, `playwright-capture.`)
159
- .toLowerCase()
160
- return normalized || `unknown${ext}`
161
- }
162
-
163
- function shouldAllowMultipleMediaSends(userText: string): boolean {
164
- const text = (userText || '').toLowerCase()
165
- return /\b(all|both|multiple|several|many|every|each|two|three|4|four|screenshots|images|photos|files|documents)\b/.test(text)
166
- }
167
-
168
- function preferSingleBestMediaFile(files: Array<{ path: string; alt: string }>): Array<{ path: string; alt: string }> {
169
- if (files.length <= 1) return files
170
- const ranked = [...files].sort((a, b) => {
171
- const score = (entry: { path: string }) => {
172
- const base = path.basename(entry.path).toLowerCase()
173
- let value = 0
174
- if (/^\d{10,16}-/.test(base)) value += 20
175
- if (!base.startsWith('browser-') && !base.startsWith('screenshot-')) value += 10
176
- if (base.endsWith('.pdf')) value += 8
177
- if (base.endsWith('.png') || base.endsWith('.jpg') || base.endsWith('.jpeg') || base.endsWith('.webp')) value += 6
178
- try {
179
- const stat = fs.statSync(entry.path)
180
- value += Math.min(5, Math.round((stat.mtimeMs % 10_000) / 2_000))
181
- } catch { /* ignore stat errors */ }
182
- return value
183
- }
184
- return score(b) - score(a)
185
- })
186
- return [ranked[0]]
187
- }
188
-
189
- export function selectOutboundMediaFiles(
190
- files: Array<{ path: string; alt: string }>,
191
- userText: string,
192
- ): Array<{ path: string; alt: string }> {
193
- if (files.length === 0) return []
194
- const mergedFiles: Array<{ path: string; alt: string }> = []
195
- const seenMediaKeys = new Set<string>()
196
- for (const candidate of files) {
197
- const mediaKey = canonicalUploadMediaKey(candidate.path)
198
- if (seenMediaKeys.has(mediaKey)) continue
199
- seenMediaKeys.add(mediaKey)
200
- mergedFiles.push(candidate)
201
- }
202
- return shouldAllowMultipleMediaSends(userText || '')
203
- ? mergedFiles
204
- : preferSingleBestMediaFile(mergedFiles)
205
- }
206
-
207
- /**
208
- * Extract embedded media references from agent response text.
209
- * Supports markdown images/links and bare upload URLs.
210
- */
211
- export function extractEmbeddedMedia(text: string): { cleanText: string; files: Array<{ path: string; alt: string }> } {
212
- const files: Array<{ path: string; alt: string }> = []
213
- const seen = new Set<string>()
214
- let cleanText = text
215
-
216
- const pushFile = (filePath: string, alt: string) => {
217
- if (!filePath || seen.has(filePath)) return
218
- seen.add(filePath)
219
- files.push({ path: filePath, alt: alt.trim() })
220
- }
221
-
222
- const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
223
- cleanText = cleanText.replace(imageRegex, (full, altRaw, urlRaw) => {
224
- const filePath = resolveUploadPathFromUrl(String(urlRaw || ''))
225
- if (!filePath) return full
226
- pushFile(filePath, String(altRaw || ''))
227
- return ''
228
- })
229
-
230
- const linkRegex = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g
231
- cleanText = cleanText.replace(linkRegex, (full, altRaw, urlRaw) => {
232
- const filePath = resolveUploadPathFromUrl(String(urlRaw || ''))
233
- if (!filePath) return full
234
- pushFile(filePath, String(altRaw || ''))
235
- return ''
236
- })
237
-
238
- const bareUploadUrlRegex = /(?:https?:\/\/[^\s)]+)?\/api\/uploads\/[^\s)\]]+/g
239
- cleanText = cleanText.replace(bareUploadUrlRegex, (full) => {
240
- const filePath = resolveUploadPathFromUrl(full)
241
- if (!filePath) return full
242
- pushFile(filePath, '')
243
- return ''
244
- })
245
-
246
- if (files.length === 0) return { cleanText: text, files }
247
- cleanText = cleanText.replace(/\n{3,}/g, '\n\n').trim()
248
- return { cleanText, files }
249
- }
250
-
251
- function buildInboundAttachmentPaths(msg: InboundMessage): string[] {
252
- if (!Array.isArray(msg.media) || msg.media.length === 0) return []
253
- const paths: string[] = []
254
- const seen = new Set<string>()
255
- for (const media of msg.media) {
256
- const localPath = typeof media.localPath === 'string' ? media.localPath.trim() : ''
257
- if (!localPath || seen.has(localPath)) continue
258
- if (!fs.existsSync(localPath)) continue
259
- seen.add(localPath)
260
- paths.push(localPath)
261
- }
262
- return paths
263
- }
264
-
265
- function normalizeWhatsappTarget(raw: string): string {
266
- const trimmed = raw.trim()
267
- if (!trimmed) return trimmed
268
- if (trimmed.includes('@')) return trimmed
269
- let cleaned = trimmed.replace(/[^\d+]/g, '')
270
- if (cleaned.startsWith('+')) cleaned = cleaned.slice(1)
271
- if (cleaned.startsWith('0') && cleaned.length >= 10) {
272
- cleaned = `44${cleaned.slice(1)}`
273
- }
274
- cleaned = cleaned.replace(/[^\d]/g, '')
275
- return cleaned ? `${cleaned}@s.whatsapp.net` : trimmed
276
- }
277
-
278
- function connectorSupportsBinaryMedia(platform: string): boolean {
279
- return platform === 'whatsapp'
280
- || platform === 'telegram'
281
- || platform === 'slack'
282
- || platform === 'discord'
283
- || platform === 'openclaw'
284
- }
285
-
286
- /** Sentinel value agents return when no outbound reply should be sent */
287
- export const NO_MESSAGE_SENTINEL = 'NO_MESSAGE'
288
-
289
- /** Check if an agent response is the NO_MESSAGE sentinel (case-insensitive, trimmed) */
290
- export function isNoMessage(text: string): boolean {
291
- return text.trim().toUpperCase() === NO_MESSAGE_SENTINEL
292
- }
293
-
294
- /** Map of running connector instances by connector ID.
295
- * Stored on globalThis to survive HMR reloads in dev mode —
296
- * prevents duplicate sockets fighting for the same WhatsApp session. */
297
- const globalKey = '__swarmclaw_running_connectors__' as const
298
- const g = globalThis as typeof globalThis & Record<string, unknown>
299
-
300
- function getOrInitGlobalValue<T>(key: string, factory: () => T): T {
301
- const existing = g[key]
302
- if (existing !== undefined) return existing as T
303
- const created = factory()
304
- g[key] = created
305
- return created
306
- }
307
-
308
130
  type ConnectorSession = Session
309
131
  type ConnectorAgent = Agent
310
132
 
311
- const running: Map<string, ConnectorInstance> =
312
- getOrInitGlobalValue(globalKey, () => new Map<string, ConnectorInstance>())
313
-
314
- /** Most recent inbound channel per connector (used for proactive replies/default outbound target) */
315
- const lastInboundKey = '__swarmclaw_connector_last_inbound__' as const
316
- const lastInboundChannelByConnector: Map<string, string> =
317
- getOrInitGlobalValue(lastInboundKey, () => new Map<string, string>())
318
-
319
- /** Last inbound message timestamp per connector (for presence indicators) */
320
- const lastInboundTimeKey = '__swarmclaw_connector_last_inbound_time__' as const
321
- const lastInboundTimeByConnector: Map<string, number> =
322
- getOrInitGlobalValue(lastInboundTimeKey, () => new Map<string, number>())
323
-
324
- /** Per-connector lock to prevent concurrent start/stop operations */
325
- const lockKey = '__swarmclaw_connector_locks__' as const
326
- const locks: Map<string, Promise<void>> =
327
- getOrInitGlobalValue(lockKey, () => new Map<string, Promise<void>>())
328
-
329
- /** Generation counter per connector — used to detect stale lifecycle events after restart */
330
- const genCounterKey = '__swarmclaw_connector_gen__' as const
331
- const generationCounter: Map<string, number> =
332
- getOrInitGlobalValue(genCounterKey, () => new Map<string, number>())
333
-
334
- type ScheduledConnectorFollowup = {
335
- id: string
336
- connectorId?: string
337
- platform?: string
338
- channelId: string
339
- sendAt: number
340
- timer: ReturnType<typeof setTimeout>
341
- }
342
-
343
- const followupKey = '__swarmclaw_connector_followups__' as const
344
- const scheduledFollowups: Map<string, ScheduledConnectorFollowup> =
345
- getOrInitGlobalValue(followupKey, () => new Map<string, ScheduledConnectorFollowup>())
346
-
347
- const inboundDedupeKey = '__swarmclaw_connector_inbound_dedupe__' as const
348
- const recentInboundByKey: Map<string, number> =
349
- getOrInitGlobalValue(inboundDedupeKey, () => new Map<string, number>())
350
-
351
- type DebouncedInboundEntry = {
352
- connector: Connector
353
- messages: InboundMessage[]
354
- timer: ReturnType<typeof setTimeout>
355
- }
356
-
357
- const inboundDebounceKey = '__swarmclaw_connector_inbound_debounce__' as const
358
- const pendingInboundDebounce: Map<string, DebouncedInboundEntry> =
359
- getOrInitGlobalValue(inboundDebounceKey, () => new Map<string, DebouncedInboundEntry>())
360
-
361
- const followupDedupeKey = '__swarmclaw_connector_followup_dedupe__' as const
362
- const scheduledFollowupByDedupe: Map<string, { id: string; sendAt: number }> =
363
- getOrInitGlobalValue(followupDedupeKey, () => new Map<string, { id: string; sendAt: number }>())
364
-
365
- /** Reconnect state per connector — tracks backoff and retry attempts for crash recovery */
366
- export interface ConnectorReconnectState {
367
- attempts: number
368
- lastAttemptAt: number
369
- nextRetryAt: number
370
- backoffMs: number
371
- error: string
372
- exhausted: boolean
373
- }
374
-
375
- const reconnectStateKey = '__swarmclaw_connector_reconnect_state__' as const
376
- const reconnectState: Map<string, ConnectorReconnectState> =
377
- getOrInitGlobalValue(reconnectStateKey, () => new Map<string, ConnectorReconnectState>())
378
-
379
- const RECONNECT_INITIAL_BACKOFF_MS = 1_000
380
- const RECONNECT_MAX_BACKOFF_MS = 5 * 60 * 1_000
381
- const RECONNECT_MAX_ATTEMPTS = 10
382
-
383
- interface ConnectorReconnectPolicy {
384
- initialBackoffMs?: number
385
- maxBackoffMs?: number
386
- maxAttempts?: number
387
- }
388
-
389
- export function createConnectorReconnectState(
390
- init: Partial<ConnectorReconnectState> = {},
391
- policy: ConnectorReconnectPolicy = {},
392
- ): ConnectorReconnectState {
393
- return {
394
- attempts: init.attempts ?? 0,
395
- lastAttemptAt: init.lastAttemptAt ?? 0,
396
- nextRetryAt: init.nextRetryAt ?? 0,
397
- backoffMs: init.backoffMs ?? policy.initialBackoffMs ?? RECONNECT_INITIAL_BACKOFF_MS,
398
- error: init.error ?? '',
399
- exhausted: init.exhausted ?? false,
400
- }
401
- }
402
-
403
- export function advanceConnectorReconnectState(
404
- previous: ConnectorReconnectState,
405
- error: string,
406
- now = Date.now(),
407
- policy: ConnectorReconnectPolicy = {},
408
- ): ConnectorReconnectState {
409
- const initialBackoffMs = policy.initialBackoffMs ?? RECONNECT_INITIAL_BACKOFF_MS
410
- const maxBackoffMs = policy.maxBackoffMs ?? RECONNECT_MAX_BACKOFF_MS
411
- const maxAttempts = policy.maxAttempts ?? RECONNECT_MAX_ATTEMPTS
412
- const attempts = previous.attempts + 1
413
- const backoffMs = Math.min(maxBackoffMs, initialBackoffMs * (2 ** Math.max(0, attempts - 1)))
414
- return {
415
- attempts,
416
- lastAttemptAt: now,
417
- nextRetryAt: now + backoffMs,
418
- backoffMs,
419
- error,
420
- exhausted: attempts >= maxAttempts,
421
- }
422
- }
423
-
424
- export function clearReconnectState(connectorId: string): void {
425
- reconnectState.delete(connectorId)
426
- }
427
-
428
- export function setReconnectState(connectorId: string, state: ConnectorReconnectState): void {
429
- reconnectState.set(connectorId, state)
430
- }
133
+ const running = runningConnectors
134
+ const {
135
+ lastInboundChannelByConnector,
136
+ lastInboundTimeByConnector,
137
+ locks,
138
+ generationCounter,
139
+ scheduledFollowups,
140
+ recentInboundByKey,
141
+ pendingInboundDebounce,
142
+ scheduledFollowupByDedupe,
143
+ routeMessageHandlerRef,
144
+ } = connectorRuntimeState
431
145
 
432
146
  /** Record a health event for a connector (persisted to connector_health collection) */
433
147
  function recordHealthEvent(connectorId: string, event: ConnectorHealthEventType, message?: string): void {
@@ -441,17 +155,6 @@ function recordHealthEvent(connectorId: string, event: ConnectorHealthEventType,
441
155
  })
442
156
  }
443
157
 
444
- function statusReactionForPlatform(platform: string, state: 'processing' | 'sent' | 'silent'): string {
445
- if (platform === 'slack') {
446
- if (state === 'processing') return 'eyes'
447
- if (state === 'sent') return 'white_check_mark'
448
- return 'zipper_mouth_face'
449
- }
450
- if (state === 'processing') return '👀'
451
- if (state === 'sent') return '✅'
452
- return '🤐'
453
- }
454
-
455
158
  function pruneTransientConnectorState(now = Date.now()): void {
456
159
  for (const [key, seenAt] of recentInboundByKey.entries()) {
457
160
  if (now - seenAt > 120_000) recentInboundByKey.delete(key)
@@ -470,41 +173,7 @@ function rememberRecentInbound(key: string, now = Date.now(), ttlMs = 120_000):
470
173
  }
471
174
 
472
175
  function findDirectSessionForInbound(connector: Connector, msg: InboundMessage): ConnectorSession | null {
473
- if (connector.chatroomId) return null
474
- const effectiveAgentId = msg.agentIdOverride || connector.agentId
475
- const channelIds = new Set([msg.channelId, msg.channelIdAlt].filter(Boolean))
476
- const senderIds = new Set([msg.senderId, msg.senderIdAlt].filter(Boolean))
477
- const sessions = Object.values(loadSessions() as Record<string, ConnectorSession>)
478
- const candidates = sessions.filter((session) =>
479
- session?.agentId === effectiveAgentId
480
- && session?.connectorContext?.connectorId === connector.id
481
- && channelIds.has(session?.connectorContext?.channelId || ''),
482
- )
483
- if (msg.threadId) {
484
- const threadExact = candidates.find((session) => session?.connectorContext?.threadId === msg.threadId)
485
- if (threadExact) return threadExact
486
- }
487
- const senderExact = candidates.find((session) => senderIds.has(session?.connectorContext?.senderId || ''))
488
- if (senderExact) return senderExact
489
- return candidates[0] || null
490
- }
491
-
492
- async function maybeSendStatusReaction(
493
- connector: Connector,
494
- msg: InboundMessage,
495
- state: 'processing' | 'sent' | 'silent',
496
- ): Promise<void> {
497
- if (!msg.messageId) return
498
- const session = findDirectSessionForInbound(connector, msg)
499
- const policy = resolveConnectorSessionPolicy(connector, msg, session)
500
- if (!policy.statusReactions) return
501
- const instance = running.get(connector.id)
502
- if (!instance?.sendReaction) return
503
- try {
504
- await instance.sendReaction(msg.channelId, msg.messageId, statusReactionForPlatform(connector.platform, state))
505
- } catch {
506
- // Ignore reaction failures — connectors vary widely here.
507
- }
176
+ return findDirectSessionForInboundHelper(connector, msg)
508
177
  }
509
178
 
510
179
  function startConnectorTypingLoop(connector: Connector, msg: InboundMessage): (() => void) | null {
@@ -527,11 +196,6 @@ function startConnectorTypingLoop(connector: Connector, msg: InboundMessage): ((
527
196
  return () => clearInterval(timer)
528
197
  }
529
198
 
530
- type RouteMessageHandler = (connector: Connector, msg: InboundMessage) => Promise<string>
531
- const routeHandlerKey = '__swarmclaw_connector_route_handler__' as const
532
- const routeMessageHandlerRef: { current: RouteMessageHandler } =
533
- getOrInitGlobalValue(routeHandlerKey, () => ({ current: async () => '[Error] Connector router unavailable.' }))
534
-
535
199
  async function flushDebouncedInbound(key: string): Promise<void> {
536
200
  const entry = pendingInboundDebounce.get(key)
537
201
  if (!entry) return
@@ -573,14 +237,14 @@ async function routeOrDebounceInbound(connector: Connector, msg: InboundMessage)
573
237
  clearTimeout(pending.timer)
574
238
  pending.timer = setTimeout(() => {
575
239
  void flushDebouncedInbound(debounceKey).catch((err: unknown) => {
576
- console.warn(`[connector] Debounced inbound flush failed: ${err instanceof Error ? err.message : String(err)}`)
240
+ console.warn(`[connector] Debounced inbound flush failed: ${errorMessage(err)}`)
577
241
  })
578
242
  }, policy.inboundDebounceMs)
579
243
  pending.timer.unref?.()
580
244
  } else {
581
245
  const timer = setTimeout(() => {
582
246
  void flushDebouncedInbound(debounceKey).catch((err: unknown) => {
583
- console.warn(`[connector] Debounced inbound flush failed: ${err instanceof Error ? err.message : String(err)}`)
247
+ console.warn(`[connector] Debounced inbound flush failed: ${errorMessage(err)}`)
584
248
  })
585
249
  }, policy.inboundDebounceMs)
586
250
  timer.unref?.()
@@ -651,39 +315,12 @@ export async function getPlatform(platform: string) {
651
315
  }
652
316
  }
653
317
  } catch (err: unknown) {
654
- console.warn(`[connector] Failed to check plugins for platform "${platform}":`, err instanceof Error ? err.message : String(err))
318
+ console.warn(`[connector] Failed to check plugins for platform "${platform}":`, errorMessage(err))
655
319
  }
656
320
 
657
321
  throw new Error(`Unknown platform: ${platform}`)
658
322
  }
659
323
 
660
- export function formatMediaLine(media: InboundMedia): string {
661
- const typeLabel = media.type.toUpperCase()
662
- const name = media.fileName || media.mimeType || 'attachment'
663
- const size = media.sizeBytes ? ` (${Math.max(1, Math.round(media.sizeBytes / 1024))} KB)` : ''
664
- if (media.url) return `- ${typeLabel}: ${name}${size} -> ${media.url}`
665
- return `- ${typeLabel}: ${name}${size}`
666
- }
667
-
668
- export function formatInboundUserText(msg: InboundMessage): string {
669
- const baseText = (msg.text || '').trim()
670
- const lines: string[] = []
671
- if (baseText) lines.push(`[${msg.senderName}] ${baseText}`)
672
- else lines.push(`[${msg.senderName}]`)
673
-
674
- if (Array.isArray(msg.media) && msg.media.length > 0) {
675
- lines.push('')
676
- lines.push('Media received:')
677
- const preview = msg.media.slice(0, 6)
678
- for (const media of preview) lines.push(formatMediaLine(media))
679
- if (msg.media.length > preview.length) {
680
- lines.push(`- ...and ${msg.media.length - preview.length} more attachment(s)`)
681
- }
682
- }
683
-
684
- return lines.join('\n').trim()
685
- }
686
-
687
324
  type ConnectorCommandName =
688
325
  | 'help'
689
326
  | 'status'
@@ -1076,12 +713,7 @@ function pushSessionMessage(
1076
713
  text: string,
1077
714
  extra: Record<string, unknown> = {},
1078
715
  ): void {
1079
- if (!text.trim()) return
1080
- if (!Array.isArray(session.messages)) session.messages = []
1081
- const message = { role, text: text.trim(), time: Date.now(), ...extra }
1082
- session.messages.push(message)
1083
- session.lastActiveAt = Date.now()
1084
- mirrorConnectorMessageToAgentThread(session, message)
716
+ pushSessionMessageHelper(session, role, text, extra)
1085
717
  }
1086
718
 
1087
719
  function modelHistoryTail(
@@ -1102,7 +734,7 @@ function persistSession(session: ConnectorSession): void {
1102
734
  }
1103
735
 
1104
736
  function isRecoverableConnectorSendError(err: unknown): boolean {
1105
- const message = err instanceof Error ? err.message : String(err)
737
+ const message = errorMessage(err)
1106
738
  return /connection closed|not connected|socket closed|connection terminated|stream errored|connector .* is not running/i.test(message)
1107
739
  }
1108
740
 
@@ -1132,25 +764,7 @@ function resolvePairingAccess(connector: Connector, msg: InboundMessage): {
1132
764
  isAllowed: boolean
1133
765
  hasAnyApprover: boolean
1134
766
  } {
1135
- const policy = parsePairingPolicy(connector.config?.dmPolicy, 'open')
1136
- const configAllowFrom = parseAllowFromCsv(connector.config?.allowFrom)
1137
- const stored = listStoredAllowedSenders(connector.id)
1138
- const isAllowed = [
1139
- msg.senderId,
1140
- msg.senderIdAlt,
1141
- ]
1142
- .filter((senderId): senderId is string => typeof senderId === 'string' && !!senderId.trim())
1143
- .some((senderId) => isSenderAllowed({
1144
- connectorId: connector.id,
1145
- senderId,
1146
- configAllowFrom,
1147
- }))
1148
- return {
1149
- policy,
1150
- configAllowFrom,
1151
- isAllowed,
1152
- hasAnyApprover: (configAllowFrom.length + stored.length) > 0,
1153
- }
767
+ return resolvePairingAccessHelper(connector, msg)
1154
768
  }
1155
769
 
1156
770
  async function handlePairCommand(params: {
@@ -1229,16 +843,11 @@ async function handlePairCommand(params: {
1229
843
  }
1230
844
 
1231
845
  function resolveInboundApprovalSenderId(msg: InboundMessage): string {
1232
- const alt = typeof msg.senderIdAlt === 'string' ? msg.senderIdAlt.trim() : ''
1233
- if (alt) return alt
1234
- return typeof msg.senderId === 'string' ? msg.senderId.trim() : ''
846
+ return resolveInboundApprovalSenderIdHelper(msg)
1235
847
  }
1236
848
 
1237
849
  function buildInboundApprovalSubject(msg: InboundMessage): string {
1238
- const senderName = typeof msg.senderName === 'string' ? msg.senderName.trim() : ''
1239
- const senderId = resolveInboundApprovalSenderId(msg)
1240
- if (senderName && senderId && senderName !== senderId) return `${senderName} (${senderId})`
1241
- return senderName || senderId || 'this sender'
850
+ return buildInboundApprovalSubjectHelper(msg)
1242
851
  }
1243
852
 
1244
853
  async function enforceInboundAccessPolicy(params: {
@@ -1247,60 +856,10 @@ async function enforceInboundAccessPolicy(params: {
1247
856
  session: ConnectorSession
1248
857
  agent: ConnectorAgent
1249
858
  }): Promise<string | null> {
1250
- const { connector, msg, session, agent } = params
1251
- if (msg.isGroup) return null
1252
- const { policy, isAllowed } = resolvePairingAccess(connector, msg)
1253
- if (policy === 'open') return null
1254
-
1255
- if (policy === 'disabled') return NO_MESSAGE_SENTINEL
1256
- if (isAllowed) return null
1257
-
1258
- const senderId = resolveInboundApprovalSenderId(msg)
1259
- const senderSubject = buildInboundApprovalSubject(msg)
1260
- const approval = await requestApprovalMaybeAutoApprove({
1261
- category: 'connector_sender',
1262
- title: `Approve ${senderSubject} on ${connector.name}`,
1263
- description: `Allow ${senderSubject} to message ${agent.name} via ${connector.platform}/${connector.name}.`,
1264
- data: {
1265
- connectorId: connector.id,
1266
- connectorName: connector.name,
1267
- platform: connector.platform,
1268
- senderId,
1269
- senderIdRaw: typeof msg.senderId === 'string' ? msg.senderId.trim() : '',
1270
- senderName: typeof msg.senderName === 'string' ? msg.senderName.trim() : '',
1271
- channelId: typeof msg.channelId === 'string' ? msg.channelId.trim() : '',
1272
- policy,
1273
- },
1274
- agentId: agent.id,
1275
- sessionId: session.id,
859
+ return enforceInboundAccessPolicyHelper({
860
+ ...params,
861
+ noMessageSentinel: NO_MESSAGE_SENTINEL,
1276
862
  })
1277
-
1278
- if (approval.status === 'approved') return null
1279
-
1280
- if (policy === 'allowlist') {
1281
- return [
1282
- `${senderSubject} is pending approval for this connector.`,
1283
- 'A SwarmClaw approval request has been created for this sender.',
1284
- 'An approved operator can allow this sender in the app or via /pair allow <senderId>.',
1285
- ].join('\n')
1286
- }
1287
-
1288
- if (policy === 'pairing') {
1289
- const request = createOrTouchPairingRequest({
1290
- connectorId: connector.id,
1291
- senderId,
1292
- senderName: msg.senderName,
1293
- channelId: msg.channelId,
1294
- })
1295
- return [
1296
- `${senderSubject} is pending approval for this connector.`,
1297
- 'A SwarmClaw approval request has been created for this sender.',
1298
- `Pairing code: ${request.code}`,
1299
- 'Approve in the app, or ask an approved sender to run /pair approve <code>.',
1300
- ].join('\n')
1301
- }
1302
-
1303
- return 'This sender is not authorized for this connector.'
1304
863
  }
1305
864
 
1306
865
  async function handleConnectorCommand(params: {
@@ -1484,7 +1043,7 @@ async function handleConnectorCommand(params: {
1484
1043
  persistSession(session)
1485
1044
  return text
1486
1045
  } catch (err: unknown) {
1487
- return err instanceof Error ? err.message : String(err)
1046
+ return errorMessage(err)
1488
1047
  }
1489
1048
  }
1490
1049
  return 'Usage: /session, /session show, /session set <key> <value>, /session reset'
@@ -1695,7 +1254,7 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
1695
1254
  markProviderSuccess(agent.provider)
1696
1255
  }
1697
1256
  } catch (err: unknown) {
1698
- const errMsg = err instanceof Error ? err.message : String(err)
1257
+ const errMsg = errorMessage(err)
1699
1258
  markProviderFailure(agent.provider, errMsg)
1700
1259
  console.error(`[connector] Chatroom agent ${agent.name} error:`, errMsg)
1701
1260
  }
@@ -1727,7 +1286,7 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
1727
1286
  })
1728
1287
  console.log(`[connector] Sent chatroom media to ${msg.platform}: ${path.basename(file.path)}`)
1729
1288
  } catch (err: unknown) {
1730
- console.error(`[connector] Failed to send chatroom media ${path.basename(file.path)}:`, err instanceof Error ? err.message : String(err))
1289
+ console.error(`[connector] Failed to send chatroom media ${path.basename(file.path)}:`, errorMessage(err))
1731
1290
  }
1732
1291
  }
1733
1292
  }
@@ -1761,7 +1320,7 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1761
1320
  preferredCredentialId: agent.credentialId || null,
1762
1321
  })
1763
1322
 
1764
- const { session, sessionKey, wasCreated, staleReason, clearedMessages } = resolveDirectSession({
1323
+ const { session, sessionKey, wasCreated, staleReason, clearedMessages } = resolveDirectSessionHelper({
1765
1324
  connector,
1766
1325
  msg,
1767
1326
  agent,
@@ -1885,14 +1444,19 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1885
1444
  const stopTyping = startConnectorTypingLoop(connector, msg)
1886
1445
  try {
1887
1446
  // Enqueue system event + heartbeat wake for the agent only after access/gating checks pass.
1447
+ const threadSession = agent.threadSessionId
1448
+ ? (loadSessions()[agent.threadSessionId] as ConnectorSession | undefined) || ensureAgentThreadSession(agent.id)
1449
+ : ensureAgentThreadSession(agent.id)
1450
+ const wakeSessionId = threadSession?.id || session.id
1888
1451
  const preview = (msg.text || '').slice(0, 80)
1889
1452
  enqueueSystemEvent(
1890
- sessionKey,
1453
+ wakeSessionId,
1891
1454
  `Inbound message from ${msg.platform}: ${preview}`,
1892
1455
  'connector-message',
1893
1456
  )
1894
1457
  requestHeartbeatNow({
1895
1458
  agentId: effectiveAgentId,
1459
+ sessionId: wakeSessionId,
1896
1460
  eventId: `${connector.id}:${msg.messageId || msg.replyToMessageId || Date.now()}`,
1897
1461
  reason: 'connector-message',
1898
1462
  source: `connector:${msg.platform}`,
@@ -2086,7 +1650,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
2086
1650
  mediaExtractionText = [result.fullText || '', ...toolMediaOutputs].filter(Boolean).join('\n\n')
2087
1651
  console.log(`[connector] streamAgentChat returned ${result.fullText.length} chars total, ${fullText.length} chars final`)
2088
1652
  } catch (err: unknown) {
2089
- const message = err instanceof Error ? err.message : String(err)
1653
+ const message = errorMessage(err)
2090
1654
  console.error(`[connector] streamAgentChat error:`, message)
2091
1655
  return `[Error] ${message}`
2092
1656
  }
@@ -2234,7 +1798,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
2234
1798
  },
2235
1799
  })
2236
1800
  } catch (err: unknown) {
2237
- console.error(`[connector] Failed to send media ${path.basename(file.path)}:`, err instanceof Error ? err.message : String(err))
1801
+ console.error(`[connector] Failed to send media ${path.basename(file.path)}:`, errorMessage(err))
2238
1802
  logExecution(session.id, 'error', 'Connector media send failed', {
2239
1803
  agentId: agent.id,
2240
1804
  detail: {
@@ -2242,7 +1806,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
2242
1806
  channelId: msg.channelId,
2243
1807
  filePath: file.path,
2244
1808
  fileName: path.basename(file.path),
2245
- error: err instanceof Error ? err.message : String(err),
1809
+ error: errorMessage(err),
2246
1810
  },
2247
1811
  })
2248
1812
  }
@@ -2278,7 +1842,7 @@ export async function startConnector(connectorId: string): Promise<void> {
2278
1842
  // Wait for any pending operation on this connector to finish (with timeout)
2279
1843
  const pending = locks.get(connectorId)
2280
1844
  if (pending) {
2281
- await Promise.race([pending, new Promise(r => setTimeout(r, 15_000))]).catch(() => {})
1845
+ await Promise.race([pending, sleep(15_000)]).catch(() => {})
2282
1846
  locks.delete(connectorId)
2283
1847
  }
2284
1848
 
@@ -2367,7 +1931,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
2367
1931
  console.log(`[connector] Started ${connector.platform} connector: ${connector.name}`)
2368
1932
  recordHealthEvent(connectorId, 'started', `${connector.platform} connector "${connector.name}" started`)
2369
1933
  } catch (err: unknown) {
2370
- const errMsg = err instanceof Error ? err.message : String(err)
1934
+ const errMsg = errorMessage(err)
2371
1935
  connector.status = 'error'
2372
1936
  connector.isEnabled = true
2373
1937
  connector.lastError = errMsg
@@ -2381,7 +1945,11 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
2381
1945
  }
2382
1946
 
2383
1947
  /** Stop a connector */
2384
- export async function stopConnector(connectorId: string): Promise<void> {
1948
+ export async function stopConnector(
1949
+ connectorId: string,
1950
+ options?: { disable?: boolean },
1951
+ ): Promise<void> {
1952
+ const disable = options?.disable !== false
2385
1953
  const instance = running.get(connectorId)
2386
1954
  if (instance) {
2387
1955
  await instance.stop()
@@ -2410,7 +1978,7 @@ export async function stopConnector(connectorId: string): Promise<void> {
2410
1978
  const connector = connectors[connectorId]
2411
1979
  if (connector) {
2412
1980
  connector.status = 'stopped'
2413
- connector.isEnabled = false
1981
+ connector.isEnabled = disable ? false : connector.isEnabled === true
2414
1982
  connector.lastError = null
2415
1983
  connector.updatedAt = Date.now()
2416
1984
  connectors[connectorId] = connector
@@ -2466,9 +2034,9 @@ export async function repairConnector(connectorId: string): Promise<void> {
2466
2034
  }
2467
2035
 
2468
2036
  /** Stop all running connectors (for cleanup) */
2469
- export async function stopAllConnectors(): Promise<void> {
2037
+ export async function stopAllConnectors(options?: { disable?: boolean }): Promise<void> {
2470
2038
  for (const [id] of running) {
2471
- await stopConnector(id)
2039
+ await stopConnector(id, options)
2472
2040
  }
2473
2041
  }
2474
2042
 
@@ -2530,7 +2098,7 @@ export function listRunningConnectors(platform?: string): Array<{
2530
2098
  platform: connector.platform,
2531
2099
  agentId: connector.agentId || null,
2532
2100
  supportsSend: typeof instance.sendMessage === 'function',
2533
- configuredTargets: Array.from(new Set(configuredTargets)),
2101
+ configuredTargets: dedup(configuredTargets),
2534
2102
  recentChannelId: lastInboundChannelByConnector.get(id) || null,
2535
2103
  })
2536
2104
  }
@@ -2556,69 +2124,6 @@ export function getRunningInstance(connectorId: string): ConnectorInstance | und
2556
2124
  return running.get(connectorId)
2557
2125
  }
2558
2126
 
2559
- export function getConnectorReplySendOptions(params: {
2560
- connectorId: string
2561
- inbound: InboundMessage
2562
- }): { replyToMessageId?: string; threadId?: string } {
2563
- const connectors = loadConnectors()
2564
- const connector = connectors[params.connectorId] as Connector | undefined
2565
- if (!connector) return {}
2566
- const session = findDirectSessionForInbound(connector, params.inbound)
2567
- const policy = resolveConnectorSessionPolicy(connector, params.inbound, session)
2568
- return shouldReplyToInboundMessage({
2569
- msg: params.inbound,
2570
- session,
2571
- policy,
2572
- })
2573
- }
2574
-
2575
- export async function recordConnectorOutboundDelivery(params: {
2576
- connectorId: string
2577
- inbound: InboundMessage
2578
- messageId?: string
2579
- state?: 'sent' | 'silent'
2580
- }): Promise<void> {
2581
- const connectors = loadConnectors()
2582
- const connector = connectors[params.connectorId] as Connector | undefined
2583
- if (!connector) return
2584
- const session = findDirectSessionForInbound(connector, params.inbound)
2585
- if (session) {
2586
- session.connectorContext = {
2587
- ...(session.connectorContext || {}),
2588
- lastOutboundAt: Date.now(),
2589
- lastOutboundMessageId: params.messageId || session.connectorContext?.lastOutboundMessageId || null,
2590
- threadId: params.inbound.threadId || session.connectorContext?.threadId || null,
2591
- }
2592
- const history = Array.isArray(session.messages) ? session.messages : []
2593
- for (let i = history.length - 1; i >= 0; i -= 1) {
2594
- const entry = history[i]
2595
- if (entry?.role !== 'assistant') continue
2596
- const source: Partial<MessageSource> = entry?.source || {}
2597
- if (source.connectorId !== connector.id) continue
2598
- if (source.channelId !== params.inbound.channelId) continue
2599
- if (!source.messageId && params.messageId) {
2600
- entry.source = {
2601
- platform: source.platform || connector.platform,
2602
- connectorId: source.connectorId || connector.id,
2603
- connectorName: source.connectorName || connector.name,
2604
- channelId: source.channelId || params.inbound.channelId,
2605
- senderId: source.senderId,
2606
- senderName: source.senderName,
2607
- messageId: params.messageId,
2608
- replyToMessageId: source.replyToMessageId || params.inbound.messageId,
2609
- threadId: source.threadId || params.inbound.threadId,
2610
- }
2611
- }
2612
- break
2613
- }
2614
- persistSessionRecord(session)
2615
- notify(`messages:${session.id}`)
2616
- }
2617
- if (params.state) {
2618
- await maybeSendStatusReaction(connector, params.inbound, params.state)
2619
- }
2620
- }
2621
-
2622
2127
  export async function performConnectorMessageAction(params: {
2623
2128
  connectorId?: string
2624
2129
  platform?: string
@@ -2734,7 +2239,7 @@ export async function sendConnectorMessage(params: {
2734
2239
  replyToMessageId?: string
2735
2240
  threadId?: string
2736
2241
  ptt?: boolean
2737
- }): Promise<{ connectorId: string; platform: string; channelId: string; messageId?: string }> {
2242
+ }): Promise<{ connectorId: string; platform: string; channelId: string; messageId?: string; suppressed?: boolean }> {
2738
2243
  const connectors = loadConnectors()
2739
2244
  const requestedId = params.connectorId?.trim()
2740
2245
  let connector: Connector | undefined
@@ -2772,7 +2277,7 @@ export async function sendConnectorMessage(params: {
2772
2277
  // Apply NO_MESSAGE filter at the delivery layer so all outbound paths respect it
2773
2278
  if ((suppressHiddenText || isNoMessage(sanitizedText)) && !params.imageUrl && !params.fileUrl && !params.mediaPath) {
2774
2279
  console.log(`[connector] sendConnectorMessage: NO_MESSAGE — suppressing outbound send`)
2775
- return { connectorId, platform: connector.platform, channelId: params.channelId }
2280
+ return { connectorId, platform: connector.platform, channelId: params.channelId, suppressed: true }
2776
2281
  }
2777
2282
 
2778
2283
  const hasMedia = !!(params.imageUrl || params.fileUrl || params.mediaPath)
@@ -2823,7 +2328,7 @@ export async function sendConnectorMessage(params: {
2823
2328
  result = await sendThroughCurrentInstance()
2824
2329
  } catch (err: unknown) {
2825
2330
  if (!isRecoverableConnectorSendError(err)) throw err
2826
- const errMsg = err instanceof Error ? err.message : String(err)
2331
+ const errMsg = errorMessage(err)
2827
2332
  console.warn(`[connector] Outbound send failed for ${connectorId}; attempting automatic restart`, { error: errMsg })
2828
2333
  recordHealthEvent(connectorId, 'disconnected', `Outbound send failed: ${errMsg}`)
2829
2334
  await startConnector(connectorId)
@@ -2871,6 +2376,7 @@ export async function sendConnectorMessage(params: {
2871
2376
  platform: connector.platform,
2872
2377
  channelId,
2873
2378
  messageId: result?.messageId,
2379
+ suppressed: false,
2874
2380
  }
2875
2381
  }
2876
2382
 
@@ -2901,59 +2407,28 @@ export function scheduleConnectorFollowUp(params: {
2901
2407
  params.threadId || '',
2902
2408
  (params.text || '').trim().slice(0, 160),
2903
2409
  ].join('|')
2904
- const existing = scheduledFollowupByDedupe.get(dedupeKey)
2905
- if (existing && existing.sendAt > Date.now() && !params.replaceExisting) {
2906
- return { followUpId: existing.id, sendAt: existing.sendAt }
2907
- }
2908
- if (existing && params.replaceExisting) {
2909
- const scheduled = scheduledFollowups.get(existing.id)
2910
- if (scheduled) {
2911
- clearTimeout(scheduled.timer)
2912
- scheduledFollowups.delete(existing.id)
2913
- }
2914
- scheduledFollowupByDedupe.delete(dedupeKey)
2915
- }
2916
- const followUpId = genId()
2917
- const sendAt = Date.now() + delayMs
2918
-
2919
- const timer = setTimeout(() => {
2920
- void sendConnectorMessage({
2921
- connectorId: params.connectorId,
2922
- platform: params.platform,
2923
- channelId: params.channelId,
2924
- text: params.text,
2925
- sessionId: params.sessionId,
2926
- imageUrl: params.imageUrl,
2927
- fileUrl: params.fileUrl,
2928
- mediaPath: params.mediaPath,
2929
- mimeType: params.mimeType,
2930
- fileName: params.fileName,
2931
- caption: params.caption,
2932
- replyToMessageId: params.replyToMessageId,
2933
- threadId: params.threadId,
2934
- ptt: params.ptt,
2935
- }).catch((err: unknown) => {
2936
- const msg = err instanceof Error ? err.message : String(err)
2937
- console.warn(`[connector] Scheduled follow-up ${followUpId} failed: ${msg}`)
2938
- }).finally(() => {
2939
- scheduledFollowups.delete(followUpId)
2940
- if (scheduledFollowupByDedupe.get(dedupeKey)?.id === followUpId) {
2941
- scheduledFollowupByDedupe.delete(dedupeKey)
2942
- }
2943
- })
2944
- }, delayMs)
2945
-
2946
- scheduledFollowups.set(followUpId, {
2947
- id: followUpId,
2410
+ const { outboxId, sendAt } = enqueueConnectorOutbox({
2948
2411
  connectorId: params.connectorId,
2949
2412
  platform: params.platform,
2950
2413
  channelId: params.channelId,
2951
- sendAt,
2952
- timer,
2414
+ text: params.text,
2415
+ sessionId: params.sessionId,
2416
+ imageUrl: params.imageUrl,
2417
+ fileUrl: params.fileUrl,
2418
+ mediaPath: params.mediaPath,
2419
+ mimeType: params.mimeType,
2420
+ fileName: params.fileName,
2421
+ caption: params.caption,
2422
+ replyToMessageId: params.replyToMessageId,
2423
+ threadId: params.threadId,
2424
+ ptt: params.ptt,
2425
+ sendAt: Date.now() + delayMs,
2426
+ dedupeKey,
2427
+ }, {
2428
+ replaceExisting: params.replaceExisting,
2953
2429
  })
2954
- scheduledFollowupByDedupe.set(dedupeKey, { id: followUpId, sendAt })
2955
2430
 
2956
- return { followUpId, sendAt }
2431
+ return { followUpId: outboxId, sendAt }
2957
2432
  }
2958
2433
 
2959
2434
  /**
@@ -2971,7 +2446,7 @@ export async function checkConnectorHealth(): Promise<void> {
2971
2446
 
2972
2447
  if (instance.isAlive()) {
2973
2448
  // Connector is healthy — clear any reconnect state
2974
- if (reconnectState.has(id)) {
2449
+ if (connectorReconnectStateStore.has(id)) {
2975
2450
  console.log(`[connector-health] Connector "${instance.connector.name}" recovered`)
2976
2451
  clearReconnectState(id)
2977
2452
  }
@@ -3000,7 +2475,7 @@ export async function checkConnectorHealth(): Promise<void> {
3000
2475
  connector.updatedAt = Date.now()
3001
2476
  connectors[id] = connector
3002
2477
  connectorsDirty = true
3003
- if (!reconnectState.has(id)) {
2478
+ if (!connectorReconnectStateStore.has(id)) {
3004
2479
  setReconnectState(id, createConnectorReconnectState({
3005
2480
  error: connector.lastError || 'Connection lost',
3006
2481
  }))
@@ -3013,21 +2488,7 @@ export async function checkConnectorHealth(): Promise<void> {
3013
2488
  }
3014
2489
 
3015
2490
  // Purge reconnect state for connectors that no longer exist
3016
- for (const id of reconnectState.keys()) {
2491
+ for (const id of connectorReconnectStateStore.keys()) {
3017
2492
  if (!connectors[id] || connectors[id]?.isEnabled !== true || running.has(id)) clearReconnectState(id)
3018
2493
  }
3019
2494
  }
3020
-
3021
- /** Get the reconnect state for a specific connector (null if not in reconnect cycle) */
3022
- export function getReconnectState(connectorId: string): ConnectorReconnectState | null {
3023
- return reconnectState.get(connectorId) ?? null
3024
- }
3025
-
3026
- /** Get all reconnect states (for dashboard/API) */
3027
- export function getAllReconnectStates(): Record<string, ConnectorReconnectState> {
3028
- const result: Record<string, ConnectorReconnectState> = {}
3029
- for (const [id, state] of reconnectState.entries()) {
3030
- result[id] = { ...state }
3031
- }
3032
- return result
3033
- }