@swarmclawai/swarmclaw 0.7.7 → 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 (281) hide show
  1. package/README.md +12 -14
  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 +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -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()
@@ -332,6 +369,7 @@ export interface ConnectorReconnectState {
332
369
  nextRetryAt: number
333
370
  backoffMs: number
334
371
  error: string
372
+ exhausted: boolean
335
373
  }
336
374
 
337
375
  const reconnectStateKey = '__swarmclaw_connector_reconnect_state__' as const
@@ -342,6 +380,55 @@ const RECONNECT_INITIAL_BACKOFF_MS = 1_000
342
380
  const RECONNECT_MAX_BACKOFF_MS = 5 * 60 * 1_000
343
381
  const RECONNECT_MAX_ATTEMPTS = 10
344
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
+ }
431
+
345
432
  /** Record a health event for a connector (persisted to connector_health collection) */
346
433
  function recordHealthEvent(connectorId: string, event: ConnectorHealthEventType, message?: string): void {
347
434
  const id = genId()
@@ -385,17 +472,19 @@ function rememberRecentInbound(key: string, now = Date.now(), ttlMs = 120_000):
385
472
  function findDirectSessionForInbound(connector: Connector, msg: InboundMessage): ConnectorSession | null {
386
473
  if (connector.chatroomId) return null
387
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))
388
477
  const sessions = Object.values(loadSessions() as Record<string, ConnectorSession>)
389
478
  const candidates = sessions.filter((session) =>
390
479
  session?.agentId === effectiveAgentId
391
480
  && session?.connectorContext?.connectorId === connector.id
392
- && session?.connectorContext?.channelId === msg.channelId,
481
+ && channelIds.has(session?.connectorContext?.channelId || ''),
393
482
  )
394
483
  if (msg.threadId) {
395
484
  const threadExact = candidates.find((session) => session?.connectorContext?.threadId === msg.threadId)
396
485
  if (threadExact) return threadExact
397
486
  }
398
- const senderExact = candidates.find((session) => session?.connectorContext?.senderId === msg.senderId)
487
+ const senderExact = candidates.find((session) => senderIds.has(session?.connectorContext?.senderId || ''))
399
488
  if (senderExact) return senderExact
400
489
  return candidates[0] || null
401
490
  }
@@ -637,8 +726,10 @@ function parseConnectorCommand(text: string): ParsedConnectorCommand | null {
637
726
 
638
727
  function persistSessionRecord(session: ConnectorSession): void {
639
728
  const sessions = loadSessions()
729
+ session.updatedAt = Date.now()
640
730
  sessions[session.id] = session
641
731
  saveSessions(sessions)
732
+ notify('sessions')
642
733
  }
643
734
 
644
735
  function updateSessionConnectorContext(session: ConnectorSession, connector: Connector, msg: InboundMessage, sessionKey: string): void {
@@ -860,6 +951,9 @@ function resolveDirectSession(params: {
860
951
  })
861
952
  const sessions = loadSessions()
862
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
+ }
863
957
  let wasCreated = false
864
958
  if (!session) {
865
959
  const id = genId()
@@ -930,20 +1024,93 @@ function resolveDirectSession(params: {
930
1024
  }
931
1025
  }
932
1026
 
933
- 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 {
934
1077
  if (!text.trim()) return
935
1078
  if (!Array.isArray(session.messages)) session.messages = []
936
- 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)
937
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)
938
1091
  }
939
1092
 
940
1093
  function persistSession(session: ConnectorSession): void {
941
1094
  const sessions = loadSessions()
1095
+ session.updatedAt = Date.now()
942
1096
  sessions[session.id] = session
943
1097
  saveSessions(sessions)
1098
+ notify('sessions')
944
1099
  notify(`messages:${session.id}`)
945
1100
  }
946
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
+
947
1114
  function summarizeForCompaction(messages: Array<{ role?: string; text?: string }>): string {
948
1115
  const preview = messages
949
1116
  .slice(-8)
@@ -966,11 +1133,16 @@ function resolvePairingAccess(connector: Connector, msg: InboundMessage): {
966
1133
  const policy = parsePairingPolicy(connector.config?.dmPolicy, 'open')
967
1134
  const configAllowFrom = parseAllowFromCsv(connector.config?.allowFrom)
968
1135
  const stored = listStoredAllowedSenders(connector.id)
969
- const isAllowed = isSenderAllowed({
970
- connectorId: connector.id,
971
- senderId: msg.senderId,
972
- configAllowFrom,
973
- })
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
+ }))
974
1146
  return {
975
1147
  policy,
976
1148
  configAllowFrom,
@@ -1054,38 +1226,79 @@ async function handlePairCommand(params: {
1054
1226
  ].join('\n')
1055
1227
  }
1056
1228
 
1057
- 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
1058
1249
  if (msg.isGroup) return null
1059
- const { policy, configAllowFrom, isAllowed } = resolvePairingAccess(connector, msg)
1060
- const storedAllowFrom = listStoredAllowedSenders(connector.id)
1250
+ const { policy, isAllowed } = resolvePairingAccess(connector, msg)
1061
1251
  if (policy === 'open') return null
1062
1252
 
1063
1253
  if (policy === 'disabled') return NO_MESSAGE_SENTINEL
1064
1254
  if (isAllowed) return null
1065
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
+
1066
1278
  if (policy === 'allowlist') {
1067
- if (!configAllowFrom.length && !storedAllowFrom.length) {
1068
- return 'This connector is set to allowlist mode, but no allowFrom entries are configured.'
1069
- }
1070
- 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')
1071
1284
  }
1072
1285
 
1073
1286
  if (policy === 'pairing') {
1074
1287
  const request = createOrTouchPairingRequest({
1075
1288
  connectorId: connector.id,
1076
- senderId: msg.senderId,
1289
+ senderId,
1077
1290
  senderName: msg.senderName,
1078
1291
  channelId: msg.channelId,
1079
1292
  })
1080
1293
  return [
1081
- 'Pairing is required before this connector will respond.',
1082
- `Your pairing code: ${request.code}`,
1083
- 'Ask an approved sender to run /pair approve <code>.',
1084
- '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>.',
1085
1298
  ].join('\n')
1086
1299
  }
1087
1300
 
1088
- return null
1301
+ return 'This sender is not authorized for this connector.'
1089
1302
  }
1090
1303
 
1091
1304
  async function handleConnectorCommand(params: {
@@ -1443,7 +1656,7 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
1443
1656
  history,
1444
1657
  })
1445
1658
 
1446
- const responseText = result.finalResponse || result.fullText
1659
+ const responseText = stripHiddenControlTokens(result.finalResponse || result.fullText)
1447
1660
  if (responseText.trim() && !isNoMessage(responseText)) {
1448
1661
  // Persist agent response to chatroom
1449
1662
  const agentSource: MessageSource = {
@@ -1551,6 +1764,19 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1551
1764
  msg,
1552
1765
  agent,
1553
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
+ }
1554
1780
 
1555
1781
  const parsedCommand = parseConnectorCommand(msg.text || '')
1556
1782
  if (parsedCommand?.name === 'pair') {
@@ -1571,8 +1797,36 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1571
1797
  return commandResult
1572
1798
  }
1573
1799
 
1574
- const accessPolicyResult = enforceInboundAccessPolicy(connector, msg)
1800
+ const accessPolicyResult = await enforceInboundAccessPolicy({
1801
+ connector,
1802
+ msg,
1803
+ session,
1804
+ agent,
1805
+ })
1575
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
+ }
1576
1830
  logExecution(session.id, 'decision', 'Connector inbound blocked by access policy', {
1577
1831
  agentId: agent.id,
1578
1832
  detail: {
@@ -1635,7 +1889,18 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1635
1889
  `Inbound message from ${msg.platform}: ${preview}`,
1636
1890
  'connector-message',
1637
1891
  )
1638
- 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
+ })
1639
1904
 
1640
1905
  logExecution(session.id, 'trigger', `${msg.platform} message from ${msg.senderName}`, {
1641
1906
  agentId: agent.id,
@@ -1736,32 +2001,15 @@ If media sending fails, report the exact error and retry with a corrected path/t
1736
2001
  const firstImageUrl = msg.imageUrl || (firstImage?.url) || undefined
1737
2002
  const firstImagePath = firstImage?.localPath || undefined
1738
2003
  const inboundAttachmentPaths = buildInboundAttachmentPaths(msg)
1739
- const inboundText = formatInboundUserText(msg)
1740
2004
  const modelInputText = inboundText
1741
2005
  // Store the raw user text for display (source.senderName handles attribution).
1742
2006
  // The formatted text with [SenderName] prefix is only used for LLM history context.
1743
- const rawText = (msg.text || '').trim()
1744
- const messageSource: MessageSource = {
1745
- platform: connector.platform,
1746
- connectorId: connector.id,
1747
- connectorName: connector.name,
1748
- channelId: msg.channelId,
1749
- senderId: msg.senderId,
1750
- senderName: msg.senderName,
1751
- messageId: msg.messageId,
1752
- replyToMessageId: msg.replyToMessageId,
1753
- threadId: msg.threadId,
1754
- }
1755
- session.messages.push({
1756
- role: 'user',
1757
- text: rawText || inboundText,
1758
- time: Date.now(),
2007
+ pushSessionMessage(session, 'user', rawText || inboundText, {
1759
2008
  imageUrl: firstImageUrl,
1760
2009
  imagePath: firstImagePath,
1761
2010
  attachedFiles: inboundAttachmentPaths.length ? inboundAttachmentPaths : undefined,
1762
2011
  source: messageSource,
1763
2012
  })
1764
- session.lastActiveAt = Date.now()
1765
2013
  updateSessionConnectorContext(session, connector, msg, sessionKey)
1766
2014
  persistSessionRecord(session)
1767
2015
  notify(`messages:${session.id}`)
@@ -1771,13 +2019,16 @@ If media sending fails, report the exact error and retry with a corrected path/t
1771
2019
  let mediaExtractionText = ''
1772
2020
  let connectorToolDeliveredCurrentChannel = false
1773
2021
  let connectorToolDeliveredMessageId: string | undefined
2022
+ let streamErrorText = ''
2023
+ const connectorToolInputsByCallId = new Map<string, Record<string, unknown>>()
2024
+ const connectorToolMirrorTexts: string[] = []
1774
2025
  const hasTools = session.plugins?.length && session.provider !== 'claude-cli'
1775
2026
  console.log(`[connector] Routing message to agent "${agent.name}" (${session.provider}/${session.model}), hasTools=${!!hasTools}`)
1776
2027
 
1777
2028
  if (hasTools) {
1778
2029
  try {
1779
2030
  const toolMediaOutputs: string[] = []
1780
- const result = await streamAgentChat({
2031
+ const result = await streamAgentChatImpl({
1781
2032
  session: session as Session,
1782
2033
  message: modelInputText,
1783
2034
  imagePath: firstImagePath,
@@ -1786,11 +2037,27 @@ If media sending fails, report the exact error and retry with a corrected path/t
1786
2037
  systemPrompt,
1787
2038
  write: (raw) => {
1788
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
+ }
1789
2054
  if (event.t !== 'tool_result') continue
1790
2055
  const toolOutput = typeof event.toolOutput === 'string' ? event.toolOutput : ''
1791
2056
  if (!toolOutput) continue
1792
2057
  toolMediaOutputs.push(toolOutput)
1793
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
1794
2061
  const parsed = parseConnectorToolResult(toolOutput)
1795
2062
  if (!parsed?.status || !parsed.to) continue
1796
2063
  const sentLikeStatus = parsed.status === 'sent' || parsed.status === 'voice_sent'
@@ -1804,11 +2071,13 @@ If media sending fails, report the exact error and retry with a corrected path/t
1804
2071
  if (inboundTarget && outboundTarget && inboundTarget === outboundTarget) {
1805
2072
  connectorToolDeliveredCurrentChannel = true
1806
2073
  if (parsed.messageId) connectorToolDeliveredMessageId = parsed.messageId
2074
+ const mirrorText = visibleConnectorToolText(mirrorInput)
2075
+ if (mirrorText) connectorToolMirrorTexts.push(mirrorText)
1807
2076
  }
1808
2077
  }
1809
2078
  }
1810
2079
  },
1811
- history: session.messages.slice(-20),
2080
+ history: modelHistoryTail(session.messages),
1812
2081
  })
1813
2082
  // Use finalResponse for connectors — strips intermediate planning/tool-use text
1814
2083
  fullText = result.finalResponse || result.fullText
@@ -1841,26 +2110,54 @@ If media sending fails, report the exact error and retry with a corrected path/t
1841
2110
  }
1842
2111
  },
1843
2112
  active: new Map(),
1844
- loadHistory: () => session.messages.slice(-20),
2113
+ loadHistory: () => modelHistoryTail(session.messages),
1845
2114
  })
1846
2115
  mediaExtractionText = fullText
1847
2116
  }
1848
2117
 
2118
+ if (!fullText.trim() && !connectorToolDeliveredCurrentChannel) {
2119
+ fullText = connectorEmptyReplyFallback(streamErrorText)
2120
+ }
2121
+
2122
+ const suppressHiddenResponse = shouldSuppressHiddenControlText(fullText)
2123
+ fullText = stripHiddenControlTokens(fullText)
2124
+
1849
2125
  // If the agent chose NO_MESSAGE, skip saving it to history — the user's message
1850
2126
  // is already recorded, and saving the sentinel would pollute the LLM's context
1851
- if (isNoMessage(fullText)) {
2127
+ if (suppressHiddenResponse || isNoMessage(fullText)) {
1852
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
+ }
1853
2149
  session.connectorContext = {
1854
2150
  ...(session.connectorContext || {}),
1855
2151
  lastOutboundAt: Date.now(),
1856
2152
  lastOutboundMessageId: connectorToolDeliveredMessageId || session.connectorContext?.lastOutboundMessageId || null,
1857
2153
  }
1858
2154
  persistSessionRecord(session)
2155
+ notify(`messages:${session.id}`)
1859
2156
  await maybeSendStatusReaction(connector, msg, 'sent')
1860
2157
  } else {
1861
2158
  await maybeSendStatusReaction(connector, msg, 'silent')
1862
2159
  }
1863
- console.log(`[connector] Agent returned NO_MESSAGE — suppressing outbound reply`)
2160
+ console.log(`[connector] Agent returned hidden control sentinel — suppressing outbound reply`)
1864
2161
  logExecution(session.id, 'decision', 'Agent suppressed outbound (NO_MESSAGE)', {
1865
2162
  agentId: agent.id,
1866
2163
  detail: { platform: msg.platform, channelId: msg.channelId },
@@ -1886,12 +2183,13 @@ If media sending fails, report the exact error and retry with a corrected path/t
1886
2183
  connectorId: connector.id,
1887
2184
  connectorName: connector.name,
1888
2185
  channelId: msg.channelId,
2186
+ senderId: msg.senderId,
2187
+ senderName: msg.senderName,
1889
2188
  replyToMessageId: msg.messageId,
1890
2189
  threadId: msg.threadId,
1891
2190
  }
1892
2191
  if (fullText.trim()) {
1893
- session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now(), source: assistantSource })
1894
- session.lastActiveAt = Date.now()
2192
+ pushSessionMessage(session, 'assistant', fullText.trim(), { source: assistantSource })
1895
2193
  persistSessionRecord(session)
1896
2194
  notify(`messages:${session.id}`)
1897
2195
  }
@@ -1971,6 +2269,8 @@ If media sending fails, report the exact error and retry with a corrected path/t
1971
2269
 
1972
2270
  routeMessageHandlerRef.current = routeMessage
1973
2271
 
2272
+ export const routeConnectorMessageForTest = routeMessage
2273
+
1974
2274
  /** Start a connector (serialized per ID to prevent concurrent start/stop races) */
1975
2275
  export async function startConnector(connectorId: string): Promise<void> {
1976
2276
  // Wait for any pending operation on this connector to finish (with timeout)
@@ -2008,33 +2308,43 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
2008
2308
  const connector = connectors[connectorId] as Connector | undefined
2009
2309
  if (!connector) throw new Error('Connector not found')
2010
2310
 
2011
- // Resolve bot token from credential
2012
- let botToken = ''
2013
- if (connector.credentialId) {
2014
- const creds = loadCredentials()
2015
- const cred = creds[connector.credentialId]
2016
- if (cred?.encryptedKey) {
2017
- try { botToken = decryptKey(cred.encryptedKey) } catch { /* ignore */ }
2018
- }
2019
- }
2020
- // Also check config for inline token (some platforms)
2021
- if (!botToken && connector.config.botToken) {
2022
- botToken = connector.config.botToken
2023
- }
2024
- if (!botToken && connector.platform === 'bluebubbles' && connector.config.password) {
2025
- botToken = connector.config.password
2311
+ // Starting a connector expresses durable intent: keep it enabled across
2312
+ // transient failures so daemon recovery and server restarts can retry it.
2313
+ if (connector.isEnabled !== true) {
2314
+ connector.isEnabled = true
2315
+ connector.updatedAt = Date.now()
2316
+ connectors[connectorId] = connector
2317
+ saveConnectors(connectors)
2318
+ notify('connectors')
2026
2319
  }
2027
2320
 
2028
- if (!botToken && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal' && connector.platform !== 'email') {
2029
- throw new Error('No bot token configured')
2030
- }
2321
+ try {
2322
+ // Resolve bot token from credential
2323
+ let botToken = ''
2324
+ if (connector.credentialId) {
2325
+ const creds = loadCredentials()
2326
+ const cred = creds[connector.credentialId]
2327
+ if (cred?.encryptedKey) {
2328
+ try { botToken = decryptKey(cred.encryptedKey) } catch { /* ignore */ }
2329
+ }
2330
+ }
2331
+ // Also check config for inline token (some platforms)
2332
+ if (!botToken && connector.config.botToken) {
2333
+ botToken = connector.config.botToken
2334
+ }
2335
+ if (!botToken && connector.platform === 'bluebubbles' && connector.config.password) {
2336
+ botToken = connector.config.password
2337
+ }
2031
2338
 
2032
- const platform = await getPlatform(connector.platform)
2339
+ if (!botToken && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal' && connector.platform !== 'email') {
2340
+ throw new Error('No bot token configured')
2341
+ }
2033
2342
 
2034
- // Bump generation counter so stale events from previous instances are ignored
2035
- generationCounter.set(connectorId, (generationCounter.get(connectorId) ?? 0) + 1)
2343
+ const platform = await getPlatform(connector.platform)
2344
+
2345
+ // Bump generation counter so stale events from previous instances are ignored
2346
+ generationCounter.set(connectorId, (generationCounter.get(connectorId) ?? 0) + 1)
2036
2347
 
2037
- try {
2038
2348
  const instance = await platform.start(
2039
2349
  connector,
2040
2350
  botToken,
@@ -2049,6 +2359,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
2049
2359
  connector.updatedAt = Date.now()
2050
2360
  connectors[connectorId] = connector
2051
2361
  saveConnectors(connectors)
2362
+ clearReconnectState(connectorId)
2052
2363
  notify('connectors')
2053
2364
 
2054
2365
  console.log(`[connector] Started ${connector.platform} connector: ${connector.name}`)
@@ -2056,7 +2367,7 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
2056
2367
  } catch (err: unknown) {
2057
2368
  const errMsg = err instanceof Error ? err.message : String(err)
2058
2369
  connector.status = 'error'
2059
- connector.isEnabled = false
2370
+ connector.isEnabled = true
2060
2371
  connector.lastError = errMsg
2061
2372
  connector.updatedAt = Date.now()
2062
2373
  connectors[connectorId] = connector
@@ -2074,6 +2385,7 @@ export async function stopConnector(connectorId: string): Promise<void> {
2074
2385
  await instance.stop()
2075
2386
  running.delete(connectorId)
2076
2387
  }
2388
+ clearReconnectState(connectorId)
2077
2389
 
2078
2390
  for (const [debounceKey, entry] of pendingInboundDebounce.entries()) {
2079
2391
  if (entry.connector.id !== connectorId) continue
@@ -2141,6 +2453,7 @@ export async function repairConnector(connectorId: string): Promise<void> {
2141
2453
  await instance.stop()
2142
2454
  running.delete(connectorId)
2143
2455
  }
2456
+ clearReconnectState(connectorId)
2144
2457
 
2145
2458
  // Clear auth directory
2146
2459
  const { clearAuthDir } = await import('./whatsapp')
@@ -2376,6 +2689,30 @@ export async function performConnectorMessageAction(params: {
2376
2689
  }
2377
2690
  }
2378
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
+
2379
2716
  /**
2380
2717
  * Send an outbound message through a running connector.
2381
2718
  * Intended for proactive agent notifications (e.g. WhatsApp updates).
@@ -2420,16 +2757,18 @@ export async function sendConnectorMessage(params: {
2420
2757
 
2421
2758
  if (!connector || !connectorId) throw new Error('Connector resolution failed.')
2422
2759
 
2423
- const instance = running.get(connectorId)
2424
- if (!instance) {
2425
- throw new Error(`Connector "${connectorId}" is not running.`)
2426
- }
2427
- if (typeof instance.sendMessage !== 'function') {
2428
- throw new Error(`Connector "${connector.name}" (${connector.platform}) does not support outbound sends.`)
2429
- }
2760
+ const {
2761
+ sanitizedText,
2762
+ suppressHiddenText,
2763
+ sanitizedCaptionText,
2764
+ sanitizedCaption,
2765
+ } = sanitizeConnectorOutboundContent({
2766
+ text: params.text,
2767
+ caption: params.caption,
2768
+ })
2430
2769
 
2431
2770
  // Apply NO_MESSAGE filter at the delivery layer so all outbound paths respect it
2432
- if (isNoMessage(params.text) && !params.imageUrl && !params.fileUrl && !params.mediaPath) {
2771
+ if ((suppressHiddenText || isNoMessage(sanitizedText)) && !params.imageUrl && !params.fileUrl && !params.mediaPath) {
2433
2772
  console.log(`[connector] sendConnectorMessage: NO_MESSAGE — suppressing outbound send`)
2434
2773
  return { connectorId, platform: connector.platform, channelId: params.channelId }
2435
2774
  }
@@ -2439,14 +2778,14 @@ export async function sendConnectorMessage(params: {
2439
2778
  ? normalizeWhatsappTarget(params.channelId)
2440
2779
  : params.channelId
2441
2780
 
2442
- let outboundText = params.text || ''
2781
+ let outboundText = sanitizedText
2443
2782
  let outboundOptions: Parameters<NonNullable<ConnectorInstance['sendMessage']>>[2] | undefined = {
2444
2783
  imageUrl: params.imageUrl,
2445
2784
  fileUrl: params.fileUrl,
2446
2785
  mediaPath: params.mediaPath,
2447
2786
  mimeType: params.mimeType,
2448
2787
  fileName: params.fileName,
2449
- caption: params.caption,
2788
+ caption: sanitizedCaption,
2450
2789
  replyToMessageId: params.replyToMessageId,
2451
2790
  threadId: params.threadId,
2452
2791
  ptt: params.ptt,
@@ -2457,8 +2796,8 @@ export async function sendConnectorMessage(params: {
2457
2796
  || params.fileUrl
2458
2797
  || (params.mediaPath ? uploadApiUrlFromPath(params.mediaPath) : null)
2459
2798
  const fallbackParts = [
2460
- (params.text || '').trim(),
2461
- (params.caption || '').trim(),
2799
+ sanitizedText.trim(),
2800
+ sanitizedCaptionText,
2462
2801
  mediaLink ? `Attachment: ${mediaLink}` : '',
2463
2802
  !mediaLink && params.mediaPath ? `Attachment: ${path.basename(params.mediaPath)}` : '',
2464
2803
  ].filter(Boolean)
@@ -2466,7 +2805,29 @@ export async function sendConnectorMessage(params: {
2466
2805
  outboundOptions = undefined
2467
2806
  }
2468
2807
 
2469
- 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
+
2470
2831
  if (params.sessionId) {
2471
2832
  const sessions = loadSessions()
2472
2833
  const session = sessions[params.sessionId]
@@ -2499,6 +2860,7 @@ export async function sendConnectorMessage(params: {
2499
2860
  }
2500
2861
  sessions[session.id] = session
2501
2862
  saveSessions(sessions)
2863
+ notify('sessions')
2502
2864
  notify(`messages:${session.id}`)
2503
2865
  }
2504
2866
  }
@@ -2609,7 +2971,7 @@ export async function checkConnectorHealth(): Promise<void> {
2609
2971
  // Connector is healthy — clear any reconnect state
2610
2972
  if (reconnectState.has(id)) {
2611
2973
  console.log(`[connector-health] Connector "${instance.connector.name}" recovered`)
2612
- reconnectState.delete(id)
2974
+ clearReconnectState(id)
2613
2975
  }
2614
2976
  continue
2615
2977
  }
@@ -2627,68 +2989,30 @@ export async function checkConnectorHealth(): Promise<void> {
2627
2989
 
2628
2990
  // If the connector is not enabled, don't attempt reconnect
2629
2991
  if (!connector.isEnabled) {
2630
- reconnectState.delete(id)
2992
+ clearReconnectState(id)
2631
2993
  continue
2632
2994
  }
2633
2995
 
2634
- // Attempt reconnect with backoff
2635
- const state = reconnectState.get(id) ?? {
2636
- attempts: 0,
2637
- lastAttemptAt: 0,
2638
- nextRetryAt: 0,
2639
- backoffMs: RECONNECT_INITIAL_BACKOFF_MS,
2640
- error: '',
2641
- }
2642
-
2643
- // Check if we've exceeded max attempts
2644
- if (state.attempts >= RECONNECT_MAX_ATTEMPTS) {
2645
- console.warn(`[connector-health] Connector "${connector.name}" exceeded ${RECONNECT_MAX_ATTEMPTS} reconnect attempts — marking as error`)
2646
- connector.status = 'error'
2647
- connector.lastError = `Auto-reconnect gave up after ${RECONNECT_MAX_ATTEMPTS} attempts: ${state.error}`
2648
- connector.updatedAt = Date.now()
2649
- connectors[id] = connector
2650
- connectorsDirty = true
2651
- reconnectState.delete(id)
2652
- notify('connectors')
2653
- continue
2654
- }
2655
-
2656
- const now = Date.now()
2657
-
2658
- // Check if enough time has passed for the next retry
2659
- if (now < state.nextRetryAt) {
2660
- // Not yet time to retry — keep state and skip
2661
- continue
2662
- }
2663
-
2664
- state.attempts += 1
2665
- state.lastAttemptAt = now
2666
- reconnectState.set(id, state)
2667
-
2668
- try {
2669
- console.log(`[connector-health] Reconnecting "${connector.name}" (attempt ${state.attempts}/${RECONNECT_MAX_ATTEMPTS})`)
2670
- await startConnector(id)
2671
- // Success — clear reconnect state
2672
- reconnectState.delete(id)
2673
- console.log(`[connector-health] Connector "${connector.name}" reconnected successfully`)
2674
- recordHealthEvent(id, 'reconnected', `Connector "${connector.name}" reconnected after ${state.attempts} attempt(s)`)
2675
- } catch (err: unknown) {
2676
- const errorMsg = err instanceof Error ? err.message : String(err)
2677
- state.error = errorMsg
2678
- state.backoffMs = Math.min(RECONNECT_MAX_BACKOFF_MS, RECONNECT_INITIAL_BACKOFF_MS * (2 ** state.attempts))
2679
- state.nextRetryAt = now + state.backoffMs
2680
- reconnectState.set(id, state)
2681
- console.warn(`[connector-health] Reconnect failed for "${connector.name}" (attempt ${state.attempts}/${RECONNECT_MAX_ATTEMPTS}): ${errorMsg}. Next retry at ${new Date(state.nextRetryAt).toISOString()}`)
2996
+ connector.status = 'error'
2997
+ connector.lastError = connector.lastError || 'Connection lost'
2998
+ connector.updatedAt = Date.now()
2999
+ connectors[id] = connector
3000
+ connectorsDirty = true
3001
+ if (!reconnectState.has(id)) {
3002
+ setReconnectState(id, createConnectorReconnectState({
3003
+ error: connector.lastError || 'Connection lost',
3004
+ }))
2682
3005
  }
2683
3006
  }
2684
3007
 
2685
3008
  if (connectorsDirty) {
2686
3009
  saveConnectors(connectors)
3010
+ notify('connectors')
2687
3011
  }
2688
3012
 
2689
3013
  // Purge reconnect state for connectors that no longer exist
2690
3014
  for (const id of reconnectState.keys()) {
2691
- if (!connectors[id]) reconnectState.delete(id)
3015
+ if (!connectors[id] || connectors[id]?.isEnabled !== true || running.has(id)) clearReconnectState(id)
2692
3016
  }
2693
3017
  }
2694
3018