@swarmclawai/swarmclaw 0.7.8 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/README.md +12 -15
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +22 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +26 -1
  17. package/src/app/api/external-agents/route.test.ts +165 -0
  18. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  19. package/src/app/api/gateways/[id]/route.ts +2 -0
  20. package/src/app/api/gateways/health-route.test.ts +135 -0
  21. package/src/app/api/gateways/route.ts +2 -0
  22. package/src/app/api/mcp-servers/route.test.ts +130 -0
  23. package/src/app/api/openclaw/deploy/route.ts +38 -5
  24. package/src/app/api/plugins/install/route.ts +46 -6
  25. package/src/app/api/plugins/marketplace/route.ts +48 -15
  26. package/src/app/api/preview-server/route.ts +26 -11
  27. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  28. package/src/app/api/schedules/route.test.ts +86 -0
  29. package/src/app/api/schedules/route.ts +6 -1
  30. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  31. package/src/app/api/setup/check-provider/route.ts +40 -10
  32. package/src/app/api/skills/[id]/route.ts +12 -0
  33. package/src/app/api/skills/import/route.ts +14 -12
  34. package/src/app/api/skills/route.ts +13 -1
  35. package/src/app/api/tasks/[id]/route.ts +10 -1
  36. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  37. package/src/app/api/tasks/import/github/route.ts +337 -0
  38. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  39. package/src/app/api/wallets/[id]/route.ts +79 -33
  40. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  41. package/src/app/api/wallets/route.ts +78 -61
  42. package/src/app/api/webhooks/[id]/route.ts +33 -6
  43. package/src/app/api/webhooks/route.test.ts +272 -0
  44. package/src/cli/index.js +1 -0
  45. package/src/cli/spec.js +1 -0
  46. package/src/components/agents/agent-card.tsx +9 -2
  47. package/src/components/agents/agent-chat-list.tsx +18 -2
  48. package/src/components/agents/agent-list.tsx +1 -0
  49. package/src/components/agents/agent-sheet.tsx +73 -24
  50. package/src/components/agents/inspector-panel.tsx +41 -0
  51. package/src/components/canvas/canvas-panel.tsx +236 -65
  52. package/src/components/chat/chat-card.tsx +36 -13
  53. package/src/components/chat/chat-header.tsx +44 -16
  54. package/src/components/chat/chat-list.tsx +28 -4
  55. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  56. package/src/components/chat/message-bubble.tsx +208 -145
  57. package/src/components/chat/message-list.tsx +48 -19
  58. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  59. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  60. package/src/components/connectors/connector-health.tsx +1 -1
  61. package/src/components/connectors/connector-list.tsx +7 -2
  62. package/src/components/connectors/connector-sheet.tsx +337 -148
  63. package/src/components/gateways/gateway-sheet.tsx +2 -2
  64. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  65. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  66. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  67. package/src/components/plugins/plugin-list.tsx +45 -9
  68. package/src/components/plugins/plugin-sheet.tsx +55 -7
  69. package/src/components/providers/provider-list.tsx +2 -1
  70. package/src/components/providers/provider-sheet.tsx +21 -2
  71. package/src/components/schedules/schedule-card.tsx +25 -1
  72. package/src/components/schedules/schedule-sheet.tsx +44 -2
  73. package/src/components/secrets/secret-sheet.tsx +21 -2
  74. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  75. package/src/components/shared/bottom-sheet.tsx +13 -3
  76. package/src/components/shared/command-palette.tsx +8 -1
  77. package/src/components/shared/confirm-dialog.tsx +19 -4
  78. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  79. package/src/components/shared/connector-platform-icon.tsx +39 -6
  80. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  81. package/src/components/shared/settings/section-capability-policy.tsx +7 -3
  82. package/src/components/skills/skill-list.tsx +25 -0
  83. package/src/components/skills/skill-sheet.tsx +84 -12
  84. package/src/components/tasks/approvals-panel.tsx +191 -95
  85. package/src/components/tasks/task-board.tsx +273 -2
  86. package/src/components/tasks/task-card.tsx +38 -9
  87. package/src/components/ui/dialog.tsx +2 -2
  88. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  89. package/src/components/wallets/wallet-panel.tsx +435 -90
  90. package/src/components/wallets/wallet-section.tsx +198 -48
  91. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  92. package/src/lib/approval-display.ts +20 -0
  93. package/src/lib/canvas-content.ts +198 -0
  94. package/src/lib/chat-artifact-summary.ts +165 -0
  95. package/src/lib/chat-display.test.ts +91 -0
  96. package/src/lib/chat-display.ts +58 -0
  97. package/src/lib/chat-streaming-state.test.ts +47 -1
  98. package/src/lib/chat-streaming-state.ts +42 -0
  99. package/src/lib/ollama-model.ts +10 -0
  100. package/src/lib/openclaw-endpoint.test.ts +8 -0
  101. package/src/lib/openclaw-endpoint.ts +6 -1
  102. package/src/lib/plugin-install-cors.ts +46 -0
  103. package/src/lib/plugin-sources.test.ts +43 -0
  104. package/src/lib/plugin-sources.ts +77 -0
  105. package/src/lib/providers/ollama.ts +16 -6
  106. package/src/lib/providers/openclaw.test.ts +54 -0
  107. package/src/lib/providers/openclaw.ts +127 -11
  108. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  109. package/src/lib/schedule-dedupe.test.ts +66 -1
  110. package/src/lib/schedule-dedupe.ts +169 -12
  111. package/src/lib/schedule-origin.test.ts +20 -0
  112. package/src/lib/schedule-origin.ts +15 -0
  113. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  114. package/src/lib/server/agent-availability.ts +16 -0
  115. package/src/lib/server/agent-runtime-config.ts +12 -4
  116. package/src/lib/server/agent-thread-session.test.ts +51 -0
  117. package/src/lib/server/agent-thread-session.ts +7 -0
  118. package/src/lib/server/approval-match.ts +205 -0
  119. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  120. package/src/lib/server/approvals.ts +214 -1
  121. package/src/lib/server/assistant-control.test.ts +29 -0
  122. package/src/lib/server/assistant-control.ts +23 -0
  123. package/src/lib/server/build-llm.test.ts +79 -0
  124. package/src/lib/server/build-llm.ts +14 -4
  125. package/src/lib/server/canvas-content.test.ts +32 -0
  126. package/src/lib/server/canvas-content.ts +6 -0
  127. package/src/lib/server/capability-router.test.ts +11 -0
  128. package/src/lib/server/capability-router.ts +26 -1
  129. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  130. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  131. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  132. package/src/lib/server/chat-execution.ts +353 -72
  133. package/src/lib/server/clawhub-client.test.ts +14 -8
  134. package/src/lib/server/connectors/manager.test.ts +1147 -0
  135. package/src/lib/server/connectors/manager.ts +362 -63
  136. package/src/lib/server/connectors/pairing.ts +26 -5
  137. package/src/lib/server/connectors/types.ts +2 -0
  138. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  139. package/src/lib/server/connectors/whatsapp.ts +271 -47
  140. package/src/lib/server/context-manager.ts +6 -1
  141. package/src/lib/server/daemon-state.ts +1 -1
  142. package/src/lib/server/data-dir.test.ts +37 -0
  143. package/src/lib/server/data-dir.ts +20 -1
  144. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  145. package/src/lib/server/devserver-launch.test.ts +60 -0
  146. package/src/lib/server/devserver-launch.ts +85 -0
  147. package/src/lib/server/elevenlabs.test.ts +189 -1
  148. package/src/lib/server/elevenlabs.ts +147 -43
  149. package/src/lib/server/ethereum.ts +590 -0
  150. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  151. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  152. package/src/lib/server/eval/agent-regression.ts +383 -11
  153. package/src/lib/server/evm-swap.ts +475 -0
  154. package/src/lib/server/execution-log.ts +1 -0
  155. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  156. package/src/lib/server/heartbeat-service.ts +15 -10
  157. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  158. package/src/lib/server/heartbeat-wake.ts +338 -57
  159. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  160. package/src/lib/server/mcp-client.test.ts +16 -0
  161. package/src/lib/server/mcp-client.ts +25 -0
  162. package/src/lib/server/memory-integration.test.ts +719 -0
  163. package/src/lib/server/memory-policy.test.ts +43 -0
  164. package/src/lib/server/memory-policy.ts +132 -0
  165. package/src/lib/server/memory-tiers.test.ts +60 -0
  166. package/src/lib/server/memory-tiers.ts +16 -0
  167. package/src/lib/server/ollama-runtime.ts +58 -0
  168. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  169. package/src/lib/server/openclaw-deploy.ts +557 -81
  170. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  171. package/src/lib/server/openclaw-gateway.ts +10 -4
  172. package/src/lib/server/openclaw-health.test.ts +35 -0
  173. package/src/lib/server/openclaw-health.ts +215 -47
  174. package/src/lib/server/orchestrator-lg.ts +2 -2
  175. package/src/lib/server/plugins-advanced.test.ts +351 -0
  176. package/src/lib/server/plugins.ts +205 -5
  177. package/src/lib/server/queue-advanced.test.ts +528 -0
  178. package/src/lib/server/queue-followups.test.ts +262 -0
  179. package/src/lib/server/queue-reconcile.test.ts +128 -0
  180. package/src/lib/server/queue.ts +293 -61
  181. package/src/lib/server/scheduler.ts +29 -1
  182. package/src/lib/server/session-note.test.ts +36 -0
  183. package/src/lib/server/session-note.ts +42 -0
  184. package/src/lib/server/session-run-manager.ts +52 -4
  185. package/src/lib/server/session-tools/canvas.ts +14 -12
  186. package/src/lib/server/session-tools/connector.test.ts +138 -0
  187. package/src/lib/server/session-tools/connector.ts +348 -61
  188. package/src/lib/server/session-tools/context.ts +12 -3
  189. package/src/lib/server/session-tools/crud.ts +221 -10
  190. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  191. package/src/lib/server/session-tools/delegate.ts +64 -8
  192. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  193. package/src/lib/server/session-tools/discovery.ts +80 -12
  194. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  195. package/src/lib/server/session-tools/file.ts +43 -4
  196. package/src/lib/server/session-tools/human-loop.ts +35 -5
  197. package/src/lib/server/session-tools/index.ts +44 -9
  198. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  199. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  200. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  201. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  202. package/src/lib/server/session-tools/memory.test.ts +93 -0
  203. package/src/lib/server/session-tools/memory.ts +546 -79
  204. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  205. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  206. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  207. package/src/lib/server/session-tools/schedule.ts +6 -1
  208. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  209. package/src/lib/server/session-tools/shell.ts +22 -3
  210. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  211. package/src/lib/server/session-tools/wallet.ts +1374 -139
  212. package/src/lib/server/session-tools/web-inputs.test.ts +162 -1
  213. package/src/lib/server/session-tools/web.ts +468 -64
  214. package/src/lib/server/skill-discovery.ts +128 -0
  215. package/src/lib/server/skill-eligibility.test.ts +84 -0
  216. package/src/lib/server/skill-eligibility.ts +95 -0
  217. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  218. package/src/lib/server/skill-prompt-budget.ts +125 -0
  219. package/src/lib/server/skills-normalize.test.ts +54 -0
  220. package/src/lib/server/skills-normalize.ts +372 -26
  221. package/src/lib/server/solana.ts +214 -29
  222. package/src/lib/server/storage.ts +65 -36
  223. package/src/lib/server/stream-agent-chat.test.ts +419 -9
  224. package/src/lib/server/stream-agent-chat.ts +887 -83
  225. package/src/lib/server/system-events.ts +1 -1
  226. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  227. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  228. package/src/lib/server/tool-loop-detection.ts +260 -0
  229. package/src/lib/server/tool-planning.ts +4 -2
  230. package/src/lib/server/wallet-execution.test.ts +198 -0
  231. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  232. package/src/lib/server/wallet-portfolio.ts +724 -0
  233. package/src/lib/server/wallet-service.test.ts +57 -0
  234. package/src/lib/server/wallet-service.ts +213 -0
  235. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  236. package/src/lib/server/watch-jobs.ts +17 -2
  237. package/src/lib/server/workspace-context.ts +111 -0
  238. package/src/lib/skill-save-payload.test.ts +39 -0
  239. package/src/lib/skill-save-payload.ts +37 -0
  240. package/src/lib/tasks.ts +28 -0
  241. package/src/lib/tool-event-summary.test.ts +30 -0
  242. package/src/lib/tool-event-summary.ts +37 -0
  243. package/src/lib/validation/schemas.ts +1 -0
  244. package/src/lib/wallet-transactions.test.ts +75 -0
  245. package/src/lib/wallet-transactions.ts +43 -0
  246. package/src/lib/wallet.test.ts +17 -0
  247. package/src/lib/wallet.ts +183 -0
  248. package/src/proxy.test.ts +31 -0
  249. package/src/proxy.ts +34 -2
  250. package/src/stores/use-chat-store.ts +15 -1
  251. package/src/types/index.ts +210 -14
@@ -30,6 +30,7 @@ import { evaluateRoutingRules } from '../chatroom-routing'
30
30
  import { markProviderFailure, markProviderSuccess } from '../provider-health'
31
31
  import { syncSessionArchiveMemory } from '../session-archive-memory'
32
32
  import { buildIdentityContinuityContext } from '../identity-continuity'
33
+ import { ensureAgentThreadSession } from '../agent-thread-session'
33
34
  import { getProvider } from '@/lib/providers'
34
35
  import type { Agent, Connector, MessageSource, Chatroom, ChatroomMessage, Session } from '@/types'
35
36
  import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
@@ -59,6 +60,16 @@ import {
59
60
  textMentionsAlias,
60
61
  } from './policy'
61
62
  import { buildConnectorThreadContextBlock, resolveThreadPersonaLabel } from './thread-context'
63
+ import { shouldSuppressHiddenControlText, stripHiddenControlTokens } from '../assistant-control'
64
+ import { requestApprovalMaybeAutoApprove } from '../approvals'
65
+
66
+ let streamAgentChatImpl = streamAgentChat
67
+
68
+ export function setStreamAgentChatForTest(
69
+ handler: typeof streamAgentChat | null,
70
+ ): void {
71
+ streamAgentChatImpl = handler || streamAgentChat
72
+ }
62
73
 
63
74
  function resolveUploadPathFromUrl(rawUrl: string): string | null {
64
75
  if (!rawUrl) return null
@@ -113,6 +124,32 @@ function parseConnectorToolResult(toolOutput: string): { status?: string; to?: s
113
124
  }
114
125
  }
115
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
+
116
153
  function canonicalUploadMediaKey(filePath: string): string {
117
154
  const base = path.basename(filePath)
118
155
  const ext = path.extname(base).toLowerCase()
@@ -435,17 +472,19 @@ function rememberRecentInbound(key: string, now = Date.now(), ttlMs = 120_000):
435
472
  function findDirectSessionForInbound(connector: Connector, msg: InboundMessage): ConnectorSession | null {
436
473
  if (connector.chatroomId) return null
437
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))
438
477
  const sessions = Object.values(loadSessions() as Record<string, ConnectorSession>)
439
478
  const candidates = sessions.filter((session) =>
440
479
  session?.agentId === effectiveAgentId
441
480
  && session?.connectorContext?.connectorId === connector.id
442
- && session?.connectorContext?.channelId === msg.channelId,
481
+ && channelIds.has(session?.connectorContext?.channelId || ''),
443
482
  )
444
483
  if (msg.threadId) {
445
484
  const threadExact = candidates.find((session) => session?.connectorContext?.threadId === msg.threadId)
446
485
  if (threadExact) return threadExact
447
486
  }
448
- const senderExact = candidates.find((session) => session?.connectorContext?.senderId === msg.senderId)
487
+ const senderExact = candidates.find((session) => senderIds.has(session?.connectorContext?.senderId || ''))
449
488
  if (senderExact) return senderExact
450
489
  return candidates[0] || null
451
490
  }
@@ -687,8 +726,10 @@ function parseConnectorCommand(text: string): ParsedConnectorCommand | null {
687
726
 
688
727
  function persistSessionRecord(session: ConnectorSession): void {
689
728
  const sessions = loadSessions()
729
+ session.updatedAt = Date.now()
690
730
  sessions[session.id] = session
691
731
  saveSessions(sessions)
732
+ notify('sessions')
692
733
  }
693
734
 
694
735
  function updateSessionConnectorContext(session: ConnectorSession, connector: Connector, msg: InboundMessage, sessionKey: string): void {
@@ -910,6 +951,9 @@ function resolveDirectSession(params: {
910
951
  })
911
952
  const sessions = loadSessions()
912
953
  let session = Object.values(sessions as Record<string, ConnectorSession>).find((item) => item?.name === sessionKey)
954
+ if (!session) {
955
+ session = findDirectSessionForInbound(connector, msg) || undefined
956
+ }
913
957
  let wasCreated = false
914
958
  if (!session) {
915
959
  const id = genId()
@@ -980,20 +1024,93 @@ function resolveDirectSession(params: {
980
1024
  }
981
1025
  }
982
1026
 
983
- function pushSessionMessage(session: ConnectorSession, role: 'user' | 'assistant', text: string): void {
1027
+ function mirrorConnectorMessageToAgentThread(
1028
+ session: ConnectorSession,
1029
+ message: Record<string, unknown>,
1030
+ ): void {
1031
+ if (!session.agentId) return
1032
+ if (typeof session.name !== 'string' || !session.name.startsWith('connector:')) return
1033
+
1034
+ const agents = loadAgents()
1035
+ const agent = agents[session.agentId]
1036
+ const threadSession = agent?.threadSessionId
1037
+ ? loadSessions()[agent.threadSessionId]
1038
+ : ensureAgentThreadSession(session.agentId)
1039
+ if (!threadSession || threadSession.id === session.id) return
1040
+
1041
+ const last = Array.isArray(threadSession.messages) ? threadSession.messages[threadSession.messages.length - 1] : null
1042
+ const source = message.source as MessageSource | undefined
1043
+ const lastSource = (last?.source || null) as MessageSource | null
1044
+ if (
1045
+ last
1046
+ && last.role === message.role
1047
+ && last.text === message.text
1048
+ && lastSource?.platform === source?.platform
1049
+ && lastSource?.connectorId === source?.connectorId
1050
+ && lastSource?.channelId === source?.channelId
1051
+ && lastSource?.messageId === source?.messageId
1052
+ ) {
1053
+ return
1054
+ }
1055
+
1056
+ if (!Array.isArray(threadSession.messages)) threadSession.messages = []
1057
+ threadSession.messages.push({
1058
+ ...message,
1059
+ time: typeof message.time === 'number' ? message.time : Date.now(),
1060
+ historyExcluded: true,
1061
+ } as Session['messages'][number])
1062
+ threadSession.lastActiveAt = Date.now()
1063
+
1064
+ const sessions = loadSessions()
1065
+ sessions[threadSession.id] = threadSession
1066
+ saveSessions(sessions)
1067
+ notify('sessions')
1068
+ notify(`messages:${threadSession.id}`)
1069
+ }
1070
+
1071
+ function pushSessionMessage(
1072
+ session: ConnectorSession,
1073
+ role: 'user' | 'assistant',
1074
+ text: string,
1075
+ extra: Record<string, unknown> = {},
1076
+ ): void {
984
1077
  if (!text.trim()) return
985
1078
  if (!Array.isArray(session.messages)) session.messages = []
986
- session.messages.push({ role, text: text.trim(), time: Date.now() })
1079
+ const message = { role, text: text.trim(), time: Date.now(), ...extra }
1080
+ session.messages.push(message)
987
1081
  session.lastActiveAt = Date.now()
1082
+ mirrorConnectorMessageToAgentThread(session, message)
1083
+ }
1084
+
1085
+ function modelHistoryTail(
1086
+ messages: Session['messages'] | null | undefined,
1087
+ limit = 20,
1088
+ ) : Session['messages'] {
1089
+ const filtered = (Array.isArray(messages) ? messages : []).filter((message) => message?.historyExcluded !== true)
1090
+ return filtered.slice(-limit)
988
1091
  }
989
1092
 
990
1093
  function persistSession(session: ConnectorSession): void {
991
1094
  const sessions = loadSessions()
1095
+ session.updatedAt = Date.now()
992
1096
  sessions[session.id] = session
993
1097
  saveSessions(sessions)
1098
+ notify('sessions')
994
1099
  notify(`messages:${session.id}`)
995
1100
  }
996
1101
 
1102
+ function isRecoverableConnectorSendError(err: unknown): boolean {
1103
+ const message = err instanceof Error ? err.message : String(err)
1104
+ return /connection closed|not connected|socket closed|connection terminated|stream errored|connector .* is not running/i.test(message)
1105
+ }
1106
+
1107
+ function connectorEmptyReplyFallback(streamErrorText: string): string {
1108
+ if (/abort|timed?\s*out|network|socket|connection/i.test(streamErrorText)) {
1109
+ return 'Sorry, I hit a temporary issue while responding. Please try again.'
1110
+ }
1111
+ return 'Sorry, I could not produce a reply just now. Please try again.'
1112
+ }
1113
+
997
1114
  function summarizeForCompaction(messages: Array<{ role?: string; text?: string }>): string {
998
1115
  const preview = messages
999
1116
  .slice(-8)
@@ -1016,11 +1133,16 @@ function resolvePairingAccess(connector: Connector, msg: InboundMessage): {
1016
1133
  const policy = parsePairingPolicy(connector.config?.dmPolicy, 'open')
1017
1134
  const configAllowFrom = parseAllowFromCsv(connector.config?.allowFrom)
1018
1135
  const stored = listStoredAllowedSenders(connector.id)
1019
- const isAllowed = isSenderAllowed({
1020
- connectorId: connector.id,
1021
- senderId: msg.senderId,
1022
- configAllowFrom,
1023
- })
1136
+ const isAllowed = [
1137
+ msg.senderId,
1138
+ msg.senderIdAlt,
1139
+ ]
1140
+ .filter((senderId): senderId is string => typeof senderId === 'string' && !!senderId.trim())
1141
+ .some((senderId) => isSenderAllowed({
1142
+ connectorId: connector.id,
1143
+ senderId,
1144
+ configAllowFrom,
1145
+ }))
1024
1146
  return {
1025
1147
  policy,
1026
1148
  configAllowFrom,
@@ -1104,38 +1226,79 @@ async function handlePairCommand(params: {
1104
1226
  ].join('\n')
1105
1227
  }
1106
1228
 
1107
- function enforceInboundAccessPolicy(connector: Connector, msg: InboundMessage): string | null {
1229
+ function resolveInboundApprovalSenderId(msg: InboundMessage): string {
1230
+ const alt = typeof msg.senderIdAlt === 'string' ? msg.senderIdAlt.trim() : ''
1231
+ if (alt) return alt
1232
+ return typeof msg.senderId === 'string' ? msg.senderId.trim() : ''
1233
+ }
1234
+
1235
+ function buildInboundApprovalSubject(msg: InboundMessage): string {
1236
+ const senderName = typeof msg.senderName === 'string' ? msg.senderName.trim() : ''
1237
+ const senderId = resolveInboundApprovalSenderId(msg)
1238
+ if (senderName && senderId && senderName !== senderId) return `${senderName} (${senderId})`
1239
+ return senderName || senderId || 'this sender'
1240
+ }
1241
+
1242
+ async function enforceInboundAccessPolicy(params: {
1243
+ connector: Connector
1244
+ msg: InboundMessage
1245
+ session: ConnectorSession
1246
+ agent: ConnectorAgent
1247
+ }): Promise<string | null> {
1248
+ const { connector, msg, session, agent } = params
1108
1249
  if (msg.isGroup) return null
1109
- const { policy, configAllowFrom, isAllowed } = resolvePairingAccess(connector, msg)
1110
- const storedAllowFrom = listStoredAllowedSenders(connector.id)
1250
+ const { policy, isAllowed } = resolvePairingAccess(connector, msg)
1111
1251
  if (policy === 'open') return null
1112
1252
 
1113
1253
  if (policy === 'disabled') return NO_MESSAGE_SENTINEL
1114
1254
  if (isAllowed) return null
1115
1255
 
1256
+ const senderId = resolveInboundApprovalSenderId(msg)
1257
+ const senderSubject = buildInboundApprovalSubject(msg)
1258
+ const approval = await requestApprovalMaybeAutoApprove({
1259
+ category: 'connector_sender',
1260
+ title: `Approve ${senderSubject} on ${connector.name}`,
1261
+ description: `Allow ${senderSubject} to message ${agent.name} via ${connector.platform}/${connector.name}.`,
1262
+ data: {
1263
+ connectorId: connector.id,
1264
+ connectorName: connector.name,
1265
+ platform: connector.platform,
1266
+ senderId,
1267
+ senderIdRaw: typeof msg.senderId === 'string' ? msg.senderId.trim() : '',
1268
+ senderName: typeof msg.senderName === 'string' ? msg.senderName.trim() : '',
1269
+ channelId: typeof msg.channelId === 'string' ? msg.channelId.trim() : '',
1270
+ policy,
1271
+ },
1272
+ agentId: agent.id,
1273
+ sessionId: session.id,
1274
+ })
1275
+
1276
+ if (approval.status === 'approved') return null
1277
+
1116
1278
  if (policy === 'allowlist') {
1117
- if (!configAllowFrom.length && !storedAllowFrom.length) {
1118
- return 'This connector is set to allowlist mode, but no allowFrom entries are configured.'
1119
- }
1120
- return 'You are not authorized for this connector. Ask an approved user to add your sender ID via /pair allow <senderId>.'
1279
+ return [
1280
+ `${senderSubject} is pending approval for this connector.`,
1281
+ 'A SwarmClaw approval request has been created for this sender.',
1282
+ 'An approved operator can allow this sender in the app or via /pair allow <senderId>.',
1283
+ ].join('\n')
1121
1284
  }
1122
1285
 
1123
1286
  if (policy === 'pairing') {
1124
1287
  const request = createOrTouchPairingRequest({
1125
1288
  connectorId: connector.id,
1126
- senderId: msg.senderId,
1289
+ senderId,
1127
1290
  senderName: msg.senderName,
1128
1291
  channelId: msg.channelId,
1129
1292
  })
1130
1293
  return [
1131
- 'Pairing is required before this connector will respond.',
1132
- `Your pairing code: ${request.code}`,
1133
- 'Ask an approved sender to run /pair approve <code>.',
1134
- 'Tip: if this is first-time setup with no approvals yet, run /pair approve <code> from this chat to bootstrap.',
1294
+ `${senderSubject} is pending approval for this connector.`,
1295
+ 'A SwarmClaw approval request has been created for this sender.',
1296
+ `Pairing code: ${request.code}`,
1297
+ 'Approve in the app, or ask an approved sender to run /pair approve <code>.',
1135
1298
  ].join('\n')
1136
1299
  }
1137
1300
 
1138
- return null
1301
+ return 'This sender is not authorized for this connector.'
1139
1302
  }
1140
1303
 
1141
1304
  async function handleConnectorCommand(params: {
@@ -1493,7 +1656,7 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
1493
1656
  history,
1494
1657
  })
1495
1658
 
1496
- const responseText = result.finalResponse || result.fullText
1659
+ const responseText = stripHiddenControlTokens(result.finalResponse || result.fullText)
1497
1660
  if (responseText.trim() && !isNoMessage(responseText)) {
1498
1661
  // Persist agent response to chatroom
1499
1662
  const agentSource: MessageSource = {
@@ -1601,6 +1764,19 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1601
1764
  msg,
1602
1765
  agent,
1603
1766
  })
1767
+ const rawText = (msg.text || '').trim()
1768
+ const inboundText = formatInboundUserText(msg)
1769
+ const messageSource: MessageSource = {
1770
+ platform: connector.platform,
1771
+ connectorId: connector.id,
1772
+ connectorName: connector.name,
1773
+ channelId: msg.channelId,
1774
+ senderId: msg.senderId,
1775
+ senderName: msg.senderName,
1776
+ messageId: msg.messageId,
1777
+ replyToMessageId: msg.replyToMessageId,
1778
+ threadId: msg.threadId,
1779
+ }
1604
1780
 
1605
1781
  const parsedCommand = parseConnectorCommand(msg.text || '')
1606
1782
  if (parsedCommand?.name === 'pair') {
@@ -1621,8 +1797,36 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1621
1797
  return commandResult
1622
1798
  }
1623
1799
 
1624
- const accessPolicyResult = enforceInboundAccessPolicy(connector, msg)
1800
+ const accessPolicyResult = await enforceInboundAccessPolicy({
1801
+ connector,
1802
+ msg,
1803
+ session,
1804
+ agent,
1805
+ })
1625
1806
  if (accessPolicyResult) {
1807
+ if (accessPolicyResult !== NO_MESSAGE_SENTINEL) {
1808
+ const assistantSource: MessageSource = {
1809
+ platform: connector.platform,
1810
+ connectorId: connector.id,
1811
+ connectorName: connector.name,
1812
+ channelId: msg.channelId,
1813
+ senderId: msg.senderId,
1814
+ senderName: msg.senderName,
1815
+ replyToMessageId: msg.messageId,
1816
+ threadId: msg.threadId,
1817
+ }
1818
+ pushSessionMessage(session, 'user', rawText || inboundText, {
1819
+ source: messageSource,
1820
+ historyExcluded: true,
1821
+ })
1822
+ pushSessionMessage(session, 'assistant', accessPolicyResult, {
1823
+ source: assistantSource,
1824
+ historyExcluded: true,
1825
+ })
1826
+ updateSessionConnectorContext(session, connector, msg, sessionKey)
1827
+ persistSessionRecord(session)
1828
+ notify(`messages:${session.id}`)
1829
+ }
1626
1830
  logExecution(session.id, 'decision', 'Connector inbound blocked by access policy', {
1627
1831
  agentId: agent.id,
1628
1832
  detail: {
@@ -1685,7 +1889,18 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1685
1889
  `Inbound message from ${msg.platform}: ${preview}`,
1686
1890
  'connector-message',
1687
1891
  )
1688
- requestHeartbeatNow({ agentId: effectiveAgentId, reason: 'connector-message' })
1892
+ requestHeartbeatNow({
1893
+ agentId: effectiveAgentId,
1894
+ eventId: `${connector.id}:${msg.messageId || msg.replyToMessageId || Date.now()}`,
1895
+ reason: 'connector-message',
1896
+ source: `connector:${msg.platform}`,
1897
+ resumeMessage: `Inbound ${msg.platform} message from ${msg.senderName || msg.senderId || 'unknown sender'}.`,
1898
+ detail: [
1899
+ (msg.text || '').trim() ? `Text: ${(msg.text || '').slice(0, 240)}` : '',
1900
+ msg.imageUrl ? 'Includes image input.' : '',
1901
+ Array.isArray(msg.media) && msg.media.length > 0 ? `Media count: ${msg.media.length}` : '',
1902
+ ].filter(Boolean).join(' '),
1903
+ })
1689
1904
 
1690
1905
  logExecution(session.id, 'trigger', `${msg.platform} message from ${msg.senderName}`, {
1691
1906
  agentId: agent.id,
@@ -1786,32 +2001,15 @@ If media sending fails, report the exact error and retry with a corrected path/t
1786
2001
  const firstImageUrl = msg.imageUrl || (firstImage?.url) || undefined
1787
2002
  const firstImagePath = firstImage?.localPath || undefined
1788
2003
  const inboundAttachmentPaths = buildInboundAttachmentPaths(msg)
1789
- const inboundText = formatInboundUserText(msg)
1790
2004
  const modelInputText = inboundText
1791
2005
  // Store the raw user text for display (source.senderName handles attribution).
1792
2006
  // The formatted text with [SenderName] prefix is only used for LLM history context.
1793
- const rawText = (msg.text || '').trim()
1794
- const messageSource: MessageSource = {
1795
- platform: connector.platform,
1796
- connectorId: connector.id,
1797
- connectorName: connector.name,
1798
- channelId: msg.channelId,
1799
- senderId: msg.senderId,
1800
- senderName: msg.senderName,
1801
- messageId: msg.messageId,
1802
- replyToMessageId: msg.replyToMessageId,
1803
- threadId: msg.threadId,
1804
- }
1805
- session.messages.push({
1806
- role: 'user',
1807
- text: rawText || inboundText,
1808
- time: Date.now(),
2007
+ pushSessionMessage(session, 'user', rawText || inboundText, {
1809
2008
  imageUrl: firstImageUrl,
1810
2009
  imagePath: firstImagePath,
1811
2010
  attachedFiles: inboundAttachmentPaths.length ? inboundAttachmentPaths : undefined,
1812
2011
  source: messageSource,
1813
2012
  })
1814
- session.lastActiveAt = Date.now()
1815
2013
  updateSessionConnectorContext(session, connector, msg, sessionKey)
1816
2014
  persistSessionRecord(session)
1817
2015
  notify(`messages:${session.id}`)
@@ -1821,13 +2019,16 @@ If media sending fails, report the exact error and retry with a corrected path/t
1821
2019
  let mediaExtractionText = ''
1822
2020
  let connectorToolDeliveredCurrentChannel = false
1823
2021
  let connectorToolDeliveredMessageId: string | undefined
2022
+ let streamErrorText = ''
2023
+ const connectorToolInputsByCallId = new Map<string, Record<string, unknown>>()
2024
+ const connectorToolMirrorTexts: string[] = []
1824
2025
  const hasTools = session.plugins?.length && session.provider !== 'claude-cli'
1825
2026
  console.log(`[connector] Routing message to agent "${agent.name}" (${session.provider}/${session.model}), hasTools=${!!hasTools}`)
1826
2027
 
1827
2028
  if (hasTools) {
1828
2029
  try {
1829
2030
  const toolMediaOutputs: string[] = []
1830
- const result = await streamAgentChat({
2031
+ const result = await streamAgentChatImpl({
1831
2032
  session: session as Session,
1832
2033
  message: modelInputText,
1833
2034
  imagePath: firstImagePath,
@@ -1836,11 +2037,27 @@ If media sending fails, report the exact error and retry with a corrected path/t
1836
2037
  systemPrompt,
1837
2038
  write: (raw) => {
1838
2039
  for (const event of parseSseDataEvents(raw)) {
2040
+ if (event.t === 'err') {
2041
+ const errText = typeof event.text === 'string' ? event.text.trim() : ''
2042
+ if (errText) streamErrorText = errText
2043
+ continue
2044
+ }
2045
+ if (event.t === 'tool_call' && event.toolName === 'connector_message_tool') {
2046
+ const toolCallId = typeof event.toolCallId === 'string' ? event.toolCallId : ''
2047
+ const toolInput = typeof event.toolInput === 'string' ? event.toolInput : ''
2048
+ if (toolCallId && toolInput) {
2049
+ const parsedInput = parseConnectorToolInput(toolInput)
2050
+ if (parsedInput) connectorToolInputsByCallId.set(toolCallId, parsedInput)
2051
+ }
2052
+ continue
2053
+ }
1839
2054
  if (event.t !== 'tool_result') continue
1840
2055
  const toolOutput = typeof event.toolOutput === 'string' ? event.toolOutput : ''
1841
2056
  if (!toolOutput) continue
1842
2057
  toolMediaOutputs.push(toolOutput)
1843
2058
  if (event.toolName === 'connector_message_tool') {
2059
+ const toolCallId = typeof event.toolCallId === 'string' ? event.toolCallId : ''
2060
+ const mirrorInput = toolCallId ? connectorToolInputsByCallId.get(toolCallId) || null : null
1844
2061
  const parsed = parseConnectorToolResult(toolOutput)
1845
2062
  if (!parsed?.status || !parsed.to) continue
1846
2063
  const sentLikeStatus = parsed.status === 'sent' || parsed.status === 'voice_sent'
@@ -1854,11 +2071,13 @@ If media sending fails, report the exact error and retry with a corrected path/t
1854
2071
  if (inboundTarget && outboundTarget && inboundTarget === outboundTarget) {
1855
2072
  connectorToolDeliveredCurrentChannel = true
1856
2073
  if (parsed.messageId) connectorToolDeliveredMessageId = parsed.messageId
2074
+ const mirrorText = visibleConnectorToolText(mirrorInput)
2075
+ if (mirrorText) connectorToolMirrorTexts.push(mirrorText)
1857
2076
  }
1858
2077
  }
1859
2078
  }
1860
2079
  },
1861
- history: session.messages.slice(-20),
2080
+ history: modelHistoryTail(session.messages),
1862
2081
  })
1863
2082
  // Use finalResponse for connectors — strips intermediate planning/tool-use text
1864
2083
  fullText = result.finalResponse || result.fullText
@@ -1891,26 +2110,54 @@ If media sending fails, report the exact error and retry with a corrected path/t
1891
2110
  }
1892
2111
  },
1893
2112
  active: new Map(),
1894
- loadHistory: () => session.messages.slice(-20),
2113
+ loadHistory: () => modelHistoryTail(session.messages),
1895
2114
  })
1896
2115
  mediaExtractionText = fullText
1897
2116
  }
1898
2117
 
2118
+ if (!fullText.trim() && !connectorToolDeliveredCurrentChannel) {
2119
+ fullText = connectorEmptyReplyFallback(streamErrorText)
2120
+ }
2121
+
2122
+ const suppressHiddenResponse = shouldSuppressHiddenControlText(fullText)
2123
+ fullText = stripHiddenControlTokens(fullText)
2124
+
1899
2125
  // If the agent chose NO_MESSAGE, skip saving it to history — the user's message
1900
2126
  // is already recorded, and saving the sentinel would pollute the LLM's context
1901
- if (isNoMessage(fullText)) {
2127
+ if (suppressHiddenResponse || isNoMessage(fullText)) {
1902
2128
  if (connectorToolDeliveredCurrentChannel) {
2129
+ const mirroredToolText = connectorToolMirrorTexts
2130
+ .map((entry) => entry.trim())
2131
+ .filter(Boolean)
2132
+ .join('\n\n')
2133
+ if (mirroredToolText) {
2134
+ const assistantSource: MessageSource = {
2135
+ platform: connector.platform,
2136
+ connectorId: connector.id,
2137
+ connectorName: connector.name,
2138
+ channelId: msg.channelId,
2139
+ senderId: msg.senderId,
2140
+ senderName: msg.senderName,
2141
+ messageId: connectorToolDeliveredMessageId,
2142
+ replyToMessageId: msg.messageId,
2143
+ threadId: msg.threadId,
2144
+ }
2145
+ pushSessionMessage(session, 'assistant', mirroredToolText, {
2146
+ source: assistantSource,
2147
+ })
2148
+ }
1903
2149
  session.connectorContext = {
1904
2150
  ...(session.connectorContext || {}),
1905
2151
  lastOutboundAt: Date.now(),
1906
2152
  lastOutboundMessageId: connectorToolDeliveredMessageId || session.connectorContext?.lastOutboundMessageId || null,
1907
2153
  }
1908
2154
  persistSessionRecord(session)
2155
+ notify(`messages:${session.id}`)
1909
2156
  await maybeSendStatusReaction(connector, msg, 'sent')
1910
2157
  } else {
1911
2158
  await maybeSendStatusReaction(connector, msg, 'silent')
1912
2159
  }
1913
- console.log(`[connector] Agent returned NO_MESSAGE — suppressing outbound reply`)
2160
+ console.log(`[connector] Agent returned hidden control sentinel — suppressing outbound reply`)
1914
2161
  logExecution(session.id, 'decision', 'Agent suppressed outbound (NO_MESSAGE)', {
1915
2162
  agentId: agent.id,
1916
2163
  detail: { platform: msg.platform, channelId: msg.channelId },
@@ -1936,12 +2183,13 @@ If media sending fails, report the exact error and retry with a corrected path/t
1936
2183
  connectorId: connector.id,
1937
2184
  connectorName: connector.name,
1938
2185
  channelId: msg.channelId,
2186
+ senderId: msg.senderId,
2187
+ senderName: msg.senderName,
1939
2188
  replyToMessageId: msg.messageId,
1940
2189
  threadId: msg.threadId,
1941
2190
  }
1942
2191
  if (fullText.trim()) {
1943
- session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now(), source: assistantSource })
1944
- session.lastActiveAt = Date.now()
2192
+ pushSessionMessage(session, 'assistant', fullText.trim(), { source: assistantSource })
1945
2193
  persistSessionRecord(session)
1946
2194
  notify(`messages:${session.id}`)
1947
2195
  }
@@ -2021,6 +2269,8 @@ If media sending fails, report the exact error and retry with a corrected path/t
2021
2269
 
2022
2270
  routeMessageHandlerRef.current = routeMessage
2023
2271
 
2272
+ export const routeConnectorMessageForTest = routeMessage
2273
+
2024
2274
  /** Start a connector (serialized per ID to prevent concurrent start/stop races) */
2025
2275
  export async function startConnector(connectorId: string): Promise<void> {
2026
2276
  // Wait for any pending operation on this connector to finish (with timeout)
@@ -2439,6 +2689,30 @@ export async function performConnectorMessageAction(params: {
2439
2689
  }
2440
2690
  }
2441
2691
 
2692
+ export function sanitizeConnectorOutboundContent(params: {
2693
+ text?: string
2694
+ caption?: string
2695
+ }): {
2696
+ sanitizedText: string
2697
+ suppressHiddenText: boolean
2698
+ sanitizedCaptionText: string
2699
+ sanitizedCaption?: string
2700
+ } {
2701
+ const sanitizedText = stripHiddenControlTokens(params.text || '')
2702
+ const suppressHiddenText = shouldSuppressHiddenControlText(params.text || '')
2703
+ const sanitizedCaptionText = stripHiddenControlTokens(params.caption || '').trim()
2704
+ const sanitizedCaption = shouldSuppressHiddenControlText(params.caption || '')
2705
+ ? undefined
2706
+ : (sanitizedCaptionText || undefined)
2707
+
2708
+ return {
2709
+ sanitizedText,
2710
+ suppressHiddenText,
2711
+ sanitizedCaptionText,
2712
+ sanitizedCaption,
2713
+ }
2714
+ }
2715
+
2442
2716
  /**
2443
2717
  * Send an outbound message through a running connector.
2444
2718
  * Intended for proactive agent notifications (e.g. WhatsApp updates).
@@ -2483,16 +2757,18 @@ export async function sendConnectorMessage(params: {
2483
2757
 
2484
2758
  if (!connector || !connectorId) throw new Error('Connector resolution failed.')
2485
2759
 
2486
- const instance = running.get(connectorId)
2487
- if (!instance) {
2488
- throw new Error(`Connector "${connectorId}" is not running.`)
2489
- }
2490
- if (typeof instance.sendMessage !== 'function') {
2491
- throw new Error(`Connector "${connector.name}" (${connector.platform}) does not support outbound sends.`)
2492
- }
2760
+ const {
2761
+ sanitizedText,
2762
+ suppressHiddenText,
2763
+ sanitizedCaptionText,
2764
+ sanitizedCaption,
2765
+ } = sanitizeConnectorOutboundContent({
2766
+ text: params.text,
2767
+ caption: params.caption,
2768
+ })
2493
2769
 
2494
2770
  // Apply NO_MESSAGE filter at the delivery layer so all outbound paths respect it
2495
- if (isNoMessage(params.text) && !params.imageUrl && !params.fileUrl && !params.mediaPath) {
2771
+ if ((suppressHiddenText || isNoMessage(sanitizedText)) && !params.imageUrl && !params.fileUrl && !params.mediaPath) {
2496
2772
  console.log(`[connector] sendConnectorMessage: NO_MESSAGE — suppressing outbound send`)
2497
2773
  return { connectorId, platform: connector.platform, channelId: params.channelId }
2498
2774
  }
@@ -2502,14 +2778,14 @@ export async function sendConnectorMessage(params: {
2502
2778
  ? normalizeWhatsappTarget(params.channelId)
2503
2779
  : params.channelId
2504
2780
 
2505
- let outboundText = params.text || ''
2781
+ let outboundText = sanitizedText
2506
2782
  let outboundOptions: Parameters<NonNullable<ConnectorInstance['sendMessage']>>[2] | undefined = {
2507
2783
  imageUrl: params.imageUrl,
2508
2784
  fileUrl: params.fileUrl,
2509
2785
  mediaPath: params.mediaPath,
2510
2786
  mimeType: params.mimeType,
2511
2787
  fileName: params.fileName,
2512
- caption: params.caption,
2788
+ caption: sanitizedCaption,
2513
2789
  replyToMessageId: params.replyToMessageId,
2514
2790
  threadId: params.threadId,
2515
2791
  ptt: params.ptt,
@@ -2520,8 +2796,8 @@ export async function sendConnectorMessage(params: {
2520
2796
  || params.fileUrl
2521
2797
  || (params.mediaPath ? uploadApiUrlFromPath(params.mediaPath) : null)
2522
2798
  const fallbackParts = [
2523
- (params.text || '').trim(),
2524
- (params.caption || '').trim(),
2799
+ sanitizedText.trim(),
2800
+ sanitizedCaptionText,
2525
2801
  mediaLink ? `Attachment: ${mediaLink}` : '',
2526
2802
  !mediaLink && params.mediaPath ? `Attachment: ${path.basename(params.mediaPath)}` : '',
2527
2803
  ].filter(Boolean)
@@ -2529,7 +2805,29 @@ export async function sendConnectorMessage(params: {
2529
2805
  outboundOptions = undefined
2530
2806
  }
2531
2807
 
2532
- const result = await instance.sendMessage(channelId, outboundText, outboundOptions)
2808
+ const sendThroughCurrentInstance = async () => {
2809
+ const liveInstance = running.get(connectorId)
2810
+ if (!liveInstance) {
2811
+ throw new Error(`Connector "${connectorId}" is not running.`)
2812
+ }
2813
+ if (typeof liveInstance.sendMessage !== 'function') {
2814
+ throw new Error(`Connector "${connector.name}" (${connector.platform}) does not support outbound sends.`)
2815
+ }
2816
+ return liveInstance.sendMessage(channelId, outboundText, outboundOptions)
2817
+ }
2818
+
2819
+ let result
2820
+ try {
2821
+ result = await sendThroughCurrentInstance()
2822
+ } catch (err: unknown) {
2823
+ if (!isRecoverableConnectorSendError(err)) throw err
2824
+ const errMsg = err instanceof Error ? err.message : String(err)
2825
+ console.warn(`[connector] Outbound send failed for ${connectorId}; attempting automatic restart`, { error: errMsg })
2826
+ recordHealthEvent(connectorId, 'disconnected', `Outbound send failed: ${errMsg}`)
2827
+ await startConnector(connectorId)
2828
+ result = await sendThroughCurrentInstance()
2829
+ }
2830
+
2533
2831
  if (params.sessionId) {
2534
2832
  const sessions = loadSessions()
2535
2833
  const session = sessions[params.sessionId]
@@ -2562,6 +2860,7 @@ export async function sendConnectorMessage(params: {
2562
2860
  }
2563
2861
  sessions[session.id] = session
2564
2862
  saveSessions(sessions)
2863
+ notify('sessions')
2565
2864
  notify(`messages:${session.id}`)
2566
2865
  }
2567
2866
  }