@swarmclawai/swarmclaw 0.7.2 → 0.7.4

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 (274) hide show
  1. package/README.md +116 -50
  2. package/bin/package-manager.js +157 -0
  3. package/bin/package-manager.test.js +90 -0
  4. package/bin/server-cmd.js +38 -7
  5. package/bin/swarmclaw.js +54 -4
  6. package/bin/update-cmd.js +48 -10
  7. package/bin/update-cmd.test.js +55 -0
  8. package/package.json +8 -3
  9. package/scripts/postinstall.mjs +26 -0
  10. package/src/app/api/agents/[id]/route.ts +43 -0
  11. package/src/app/api/agents/[id]/thread/route.ts +39 -8
  12. package/src/app/api/agents/route.ts +35 -2
  13. package/src/app/api/auth/route.ts +77 -8
  14. package/src/app/api/chatrooms/[id]/chat/route.ts +22 -6
  15. package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
  16. package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
  17. package/src/app/api/chatrooms/[id]/route.ts +6 -0
  18. package/src/app/api/chats/[id]/browser/route.ts +5 -1
  19. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  20. package/src/app/api/chats/[id]/messages/route.ts +19 -13
  21. package/src/app/api/chats/[id]/route.ts +30 -0
  22. package/src/app/api/chats/[id]/stop/route.ts +6 -1
  23. package/src/app/api/chats/heartbeat/route.ts +2 -1
  24. package/src/app/api/chats/route.ts +23 -1
  25. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  26. package/src/app/api/connectors/doctor/route.ts +13 -0
  27. package/src/app/api/external-agents/[id]/heartbeat/route.ts +33 -0
  28. package/src/app/api/external-agents/[id]/route.ts +31 -0
  29. package/src/app/api/external-agents/register/route.ts +3 -0
  30. package/src/app/api/external-agents/route.ts +66 -0
  31. package/src/app/api/files/open/route.ts +16 -14
  32. package/src/app/api/gateways/[id]/health/route.ts +28 -0
  33. package/src/app/api/gateways/[id]/route.ts +79 -0
  34. package/src/app/api/gateways/route.ts +57 -0
  35. package/src/app/api/memory/maintenance/route.ts +11 -1
  36. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  37. package/src/app/api/openclaw/gateway/route.ts +10 -7
  38. package/src/app/api/openclaw/skills/route.ts +12 -4
  39. package/src/app/api/plugins/dependencies/route.ts +24 -0
  40. package/src/app/api/plugins/install/route.ts +15 -92
  41. package/src/app/api/plugins/route.ts +3 -26
  42. package/src/app/api/plugins/settings/route.ts +17 -12
  43. package/src/app/api/plugins/ui/route.ts +1 -0
  44. package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
  45. package/src/app/api/schedules/[id]/route.ts +38 -9
  46. package/src/app/api/schedules/route.ts +51 -28
  47. package/src/app/api/settings/route.ts +55 -17
  48. package/src/app/api/setup/doctor/route.ts +6 -4
  49. package/src/app/api/tasks/[id]/route.ts +16 -6
  50. package/src/app/api/tasks/bulk/route.ts +3 -3
  51. package/src/app/api/tasks/route.ts +9 -4
  52. package/src/app/api/webhooks/[id]/route.ts +8 -1
  53. package/src/app/page.tsx +135 -17
  54. package/src/cli/binary.test.js +142 -0
  55. package/src/cli/index.js +38 -11
  56. package/src/cli/index.test.js +195 -0
  57. package/src/cli/index.ts +21 -12
  58. package/src/cli/server-cmd.test.js +59 -0
  59. package/src/cli/spec.js +20 -2
  60. package/src/components/agents/agent-card.tsx +15 -12
  61. package/src/components/agents/agent-chat-list.tsx +101 -1
  62. package/src/components/agents/agent-list.tsx +46 -9
  63. package/src/components/agents/agent-sheet.tsx +456 -23
  64. package/src/components/agents/inspector-panel.tsx +110 -49
  65. package/src/components/agents/sandbox-env-panel.tsx +4 -1
  66. package/src/components/auth/access-key-gate.tsx +36 -97
  67. package/src/components/auth/setup-wizard.tsx +970 -275
  68. package/src/components/chat/chat-area.tsx +70 -27
  69. package/src/components/chat/chat-card.tsx +6 -21
  70. package/src/components/chat/chat-header.tsx +263 -366
  71. package/src/components/chat/chat-list.tsx +62 -26
  72. package/src/components/chat/checkpoint-timeline.tsx +1 -1
  73. package/src/components/chat/message-list.tsx +145 -19
  74. package/src/components/chatrooms/chatroom-input.tsx +96 -33
  75. package/src/components/chatrooms/chatroom-list.tsx +141 -72
  76. package/src/components/chatrooms/chatroom-message.tsx +7 -6
  77. package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
  78. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
  79. package/src/components/chatrooms/chatroom-view.tsx +422 -209
  80. package/src/components/chatrooms/reaction-picker.tsx +38 -33
  81. package/src/components/connectors/connector-list.tsx +265 -127
  82. package/src/components/connectors/connector-sheet.tsx +217 -0
  83. package/src/components/gateways/gateway-sheet.tsx +567 -0
  84. package/src/components/home/home-view.tsx +128 -4
  85. package/src/components/input/chat-input.tsx +135 -86
  86. package/src/components/layout/app-layout.tsx +385 -194
  87. package/src/components/layout/mobile-header.tsx +26 -8
  88. package/src/components/memory/memory-browser.tsx +71 -6
  89. package/src/components/memory/memory-card.tsx +18 -0
  90. package/src/components/memory/memory-detail.tsx +58 -31
  91. package/src/components/memory/memory-sheet.tsx +32 -4
  92. package/src/components/plugins/plugin-list.tsx +15 -3
  93. package/src/components/plugins/plugin-sheet.tsx +118 -9
  94. package/src/components/projects/project-detail.tsx +189 -1
  95. package/src/components/providers/provider-list.tsx +158 -2
  96. package/src/components/providers/provider-sheet.tsx +81 -70
  97. package/src/components/shared/agent-picker-list.tsx +2 -2
  98. package/src/components/shared/bottom-sheet.tsx +31 -15
  99. package/src/components/shared/command-palette.tsx +111 -24
  100. package/src/components/shared/confirm-dialog.tsx +45 -30
  101. package/src/components/shared/model-combobox.tsx +90 -8
  102. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  103. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  104. package/src/components/shared/settings/section-heartbeat.tsx +88 -6
  105. package/src/components/shared/settings/section-orchestrator.tsx +6 -3
  106. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  107. package/src/components/shared/settings/section-secrets.tsx +6 -6
  108. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  109. package/src/components/shared/settings/section-voice.tsx +5 -1
  110. package/src/components/shared/settings/section-web-search.tsx +10 -2
  111. package/src/components/shared/settings/settings-page.tsx +248 -47
  112. package/src/components/tasks/approvals-panel.tsx +211 -18
  113. package/src/components/tasks/task-board.tsx +242 -46
  114. package/src/components/ui/dialog.tsx +2 -2
  115. package/src/components/usage/metrics-dashboard.tsx +74 -1
  116. package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
  117. package/src/components/wallets/wallet-panel.tsx +17 -5
  118. package/src/components/webhooks/webhook-sheet.tsx +7 -7
  119. package/src/lib/auth.ts +17 -0
  120. package/src/lib/chat-streaming-state.test.ts +108 -0
  121. package/src/lib/chat-streaming-state.ts +108 -0
  122. package/src/lib/heartbeat-defaults.ts +48 -0
  123. package/src/lib/memory-presentation.ts +59 -0
  124. package/src/lib/openclaw-agent-id.test.ts +14 -0
  125. package/src/lib/openclaw-agent-id.ts +31 -0
  126. package/src/lib/provider-model-discovery-client.ts +29 -0
  127. package/src/lib/providers/index.ts +12 -5
  128. package/src/lib/runtime-loop.ts +105 -3
  129. package/src/lib/safe-storage.ts +6 -1
  130. package/src/lib/server/agent-assignment.test.ts +112 -0
  131. package/src/lib/server/agent-assignment.ts +169 -0
  132. package/src/lib/server/agent-runtime-config.test.ts +141 -0
  133. package/src/lib/server/agent-runtime-config.ts +277 -0
  134. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  135. package/src/lib/server/approvals-auto-approve.test.ts +264 -0
  136. package/src/lib/server/approvals.ts +483 -75
  137. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  138. package/src/lib/server/browser-state.test.ts +118 -0
  139. package/src/lib/server/browser-state.ts +123 -0
  140. package/src/lib/server/build-llm.test.ts +44 -0
  141. package/src/lib/server/build-llm.ts +11 -4
  142. package/src/lib/server/builtin-plugins.ts +34 -0
  143. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  144. package/src/lib/server/chat-execution-tool-events.test.ts +219 -0
  145. package/src/lib/server/chat-execution.ts +402 -125
  146. package/src/lib/server/chatroom-health.test.ts +26 -0
  147. package/src/lib/server/chatroom-health.ts +2 -3
  148. package/src/lib/server/chatroom-helpers.test.ts +74 -2
  149. package/src/lib/server/chatroom-helpers.ts +144 -11
  150. package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
  151. package/src/lib/server/connectors/discord.ts +175 -11
  152. package/src/lib/server/connectors/doctor.test.ts +80 -0
  153. package/src/lib/server/connectors/doctor.ts +116 -0
  154. package/src/lib/server/connectors/manager.ts +994 -130
  155. package/src/lib/server/connectors/policy.test.ts +222 -0
  156. package/src/lib/server/connectors/policy.ts +452 -0
  157. package/src/lib/server/connectors/slack.ts +189 -10
  158. package/src/lib/server/connectors/telegram.ts +65 -15
  159. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  160. package/src/lib/server/connectors/thread-context.ts +72 -0
  161. package/src/lib/server/connectors/types.ts +41 -11
  162. package/src/lib/server/daemon-state.ts +62 -3
  163. package/src/lib/server/data-dir.ts +13 -0
  164. package/src/lib/server/delegation-jobs.test.ts +140 -0
  165. package/src/lib/server/delegation-jobs.ts +248 -0
  166. package/src/lib/server/document-utils.test.ts +47 -0
  167. package/src/lib/server/document-utils.ts +397 -0
  168. package/src/lib/server/eval/agent-regression.test.ts +47 -0
  169. package/src/lib/server/eval/agent-regression.ts +1742 -0
  170. package/src/lib/server/eval/runner.ts +11 -1
  171. package/src/lib/server/eval/store.ts +2 -1
  172. package/src/lib/server/heartbeat-service.ts +23 -43
  173. package/src/lib/server/heartbeat-source.test.ts +22 -0
  174. package/src/lib/server/heartbeat-source.ts +7 -0
  175. package/src/lib/server/identity-continuity.test.ts +77 -0
  176. package/src/lib/server/identity-continuity.ts +127 -0
  177. package/src/lib/server/mailbox-utils.ts +347 -0
  178. package/src/lib/server/main-agent-loop.ts +31 -964
  179. package/src/lib/server/memory-db.ts +4 -6
  180. package/src/lib/server/memory-tiers.ts +40 -0
  181. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  182. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  183. package/src/lib/server/openclaw-exec-config.ts +6 -5
  184. package/src/lib/server/openclaw-gateway.ts +123 -36
  185. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  186. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  187. package/src/lib/server/openclaw-sync.ts +3 -2
  188. package/src/lib/server/orchestrator-lg.ts +18 -8
  189. package/src/lib/server/orchestrator.ts +5 -4
  190. package/src/lib/server/playwright-proxy.mjs +27 -3
  191. package/src/lib/server/plugins.test.ts +215 -0
  192. package/src/lib/server/plugins.ts +832 -69
  193. package/src/lib/server/provider-health.ts +33 -3
  194. package/src/lib/server/provider-model-discovery.ts +481 -0
  195. package/src/lib/server/queue.ts +4 -21
  196. package/src/lib/server/runtime-settings.test.ts +119 -0
  197. package/src/lib/server/runtime-settings.ts +12 -92
  198. package/src/lib/server/schedule-normalization.ts +187 -0
  199. package/src/lib/server/scheduler.ts +2 -0
  200. package/src/lib/server/session-archive-memory.test.ts +85 -0
  201. package/src/lib/server/session-archive-memory.ts +230 -0
  202. package/src/lib/server/session-mailbox.ts +8 -18
  203. package/src/lib/server/session-reset-policy.test.ts +99 -0
  204. package/src/lib/server/session-reset-policy.ts +311 -0
  205. package/src/lib/server/session-run-manager.ts +33 -80
  206. package/src/lib/server/session-tools/autonomy-tools.test.ts +128 -0
  207. package/src/lib/server/session-tools/calendar.ts +2 -12
  208. package/src/lib/server/session-tools/connector.ts +109 -8
  209. package/src/lib/server/session-tools/context.ts +14 -2
  210. package/src/lib/server/session-tools/crawl.ts +447 -0
  211. package/src/lib/server/session-tools/crud.ts +96 -34
  212. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  213. package/src/lib/server/session-tools/delegate.ts +406 -20
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
  215. package/src/lib/server/session-tools/discovery.ts +40 -12
  216. package/src/lib/server/session-tools/document.ts +283 -0
  217. package/src/lib/server/session-tools/email.ts +1 -3
  218. package/src/lib/server/session-tools/extract.ts +137 -0
  219. package/src/lib/server/session-tools/file-normalize.test.ts +98 -0
  220. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  221. package/src/lib/server/session-tools/file.ts +243 -24
  222. package/src/lib/server/session-tools/http.ts +9 -3
  223. package/src/lib/server/session-tools/human-loop.ts +227 -0
  224. package/src/lib/server/session-tools/image-gen.ts +1 -3
  225. package/src/lib/server/session-tools/index.ts +87 -2
  226. package/src/lib/server/session-tools/mailbox.ts +276 -0
  227. package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
  228. package/src/lib/server/session-tools/memory.ts +35 -3
  229. package/src/lib/server/session-tools/monitor.ts +162 -12
  230. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  231. package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
  232. package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
  233. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  234. package/src/lib/server/session-tools/platform.ts +142 -4
  235. package/src/lib/server/session-tools/plugin-creator.ts +95 -25
  236. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  237. package/src/lib/server/session-tools/replicate.ts +1 -3
  238. package/src/lib/server/session-tools/sandbox.ts +51 -92
  239. package/src/lib/server/session-tools/schedule.ts +20 -10
  240. package/src/lib/server/session-tools/session-info.ts +58 -4
  241. package/src/lib/server/session-tools/session-tools-wiring.test.ts +54 -17
  242. package/src/lib/server/session-tools/shell.ts +2 -2
  243. package/src/lib/server/session-tools/subagent.ts +195 -27
  244. package/src/lib/server/session-tools/table.ts +587 -0
  245. package/src/lib/server/session-tools/wallet.ts +13 -10
  246. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  247. package/src/lib/server/session-tools/web.ts +947 -108
  248. package/src/lib/server/storage.ts +255 -10
  249. package/src/lib/server/stream-agent-chat.test.ts +61 -0
  250. package/src/lib/server/stream-agent-chat.ts +185 -25
  251. package/src/lib/server/structured-extract.test.ts +72 -0
  252. package/src/lib/server/structured-extract.ts +373 -0
  253. package/src/lib/server/task-mention.test.ts +16 -2
  254. package/src/lib/server/task-mention.ts +61 -11
  255. package/src/lib/server/tool-aliases.ts +80 -12
  256. package/src/lib/server/tool-capability-policy.ts +7 -1
  257. package/src/lib/server/tool-retry.ts +2 -0
  258. package/src/lib/server/watch-jobs.test.ts +173 -0
  259. package/src/lib/server/watch-jobs.ts +532 -0
  260. package/src/lib/server/ws-hub.ts +5 -3
  261. package/src/lib/setup-defaults.ts +352 -11
  262. package/src/lib/tool-definitions.ts +3 -4
  263. package/src/lib/validation/schemas.test.ts +26 -0
  264. package/src/lib/validation/schemas.ts +62 -1
  265. package/src/lib/ws-client.ts +14 -12
  266. package/src/proxy.ts +5 -5
  267. package/src/stores/use-app-store.ts +43 -7
  268. package/src/stores/use-chat-store.ts +31 -2
  269. package/src/stores/use-chatroom-store.ts +153 -26
  270. package/src/types/index.ts +470 -44
  271. package/src/app/api/chats/[id]/main-loop/route.ts +0 -94
  272. package/src/components/chat/new-chat-sheet.tsx +0 -253
  273. package/src/lib/server/main-session.ts +0 -17
  274. package/src/lib/server/session-run-manager.test.ts +0 -26
@@ -20,7 +20,7 @@ import {
20
20
  parseMentions,
21
21
  compactChatroomMessages,
22
22
  buildChatroomSystemPrompt,
23
- buildSyntheticSession,
23
+ ensureSyntheticSession,
24
24
  buildAgentSystemPromptForChatroom,
25
25
  buildHistoryForAgent,
26
26
  resolveApiKey as resolveApiKeyHelper,
@@ -28,8 +28,10 @@ import {
28
28
  import { filterHealthyChatroomAgents } from '../chatroom-health'
29
29
  import { evaluateRoutingRules } from '../chatroom-routing'
30
30
  import { markProviderFailure, markProviderSuccess } from '../provider-health'
31
+ import { syncSessionArchiveMemory } from '../session-archive-memory'
32
+ import { buildIdentityContinuityContext } from '../identity-continuity'
31
33
  import { getProvider } from '@/lib/providers'
32
- import type { Connector, MessageSource, Chatroom, ChatroomMessage } from '@/types'
34
+ import type { Agent, Connector, MessageSource, Chatroom, ChatroomMessage, Session } from '@/types'
33
35
  import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
34
36
  import {
35
37
  addAllowedSender,
@@ -43,6 +45,20 @@ import {
43
45
  type PairingPolicy,
44
46
  } from './pairing'
45
47
  import { enrichInboundMessageWithAudioTranscript } from './inbound-audio-transcription'
48
+ import {
49
+ buildConnectorConversationKey,
50
+ buildConnectorDoctorWarnings,
51
+ buildInboundDebounceKey,
52
+ buildInboundDedupeKey,
53
+ getConnectorSessionStaleness,
54
+ isReplyToLastOutbound,
55
+ mergeInboundMessages,
56
+ resetConnectorSessionRuntime,
57
+ resolveConnectorSessionPolicy,
58
+ shouldReplyToInboundMessage,
59
+ textMentionsAlias,
60
+ } from './policy'
61
+ import { buildConnectorThreadContextBlock, resolveThreadPersonaLabel } from './thread-context'
46
62
 
47
63
  function resolveUploadPathFromUrl(rawUrl: string): string | null {
48
64
  if (!rawUrl) return null
@@ -80,7 +96,7 @@ function parseSseDataEvents(raw: string): Array<Record<string, unknown>> {
80
96
  return events
81
97
  }
82
98
 
83
- function parseConnectorToolResult(toolOutput: string): { status?: string; to?: string; followUpId?: string } | null {
99
+ function parseConnectorToolResult(toolOutput: string): { status?: string; to?: string; followUpId?: string; messageId?: string } | null {
84
100
  const raw = toolOutput.trim()
85
101
  if (!raw) return null
86
102
  try {
@@ -90,7 +106,8 @@ function parseConnectorToolResult(toolOutput: string): { status?: string; to?: s
90
106
  const status = typeof record.status === 'string' ? String(record.status) : undefined
91
107
  const to = typeof record.to === 'string' ? String(record.to) : undefined
92
108
  const followUpId = typeof record.followUpId === 'string' ? String(record.followUpId) : undefined
93
- return { status, to, followUpId }
109
+ const messageId = typeof record.messageId === 'string' ? String(record.messageId) : undefined
110
+ return { status, to, followUpId, messageId }
94
111
  } catch {
95
112
  return null
96
113
  }
@@ -241,30 +258,41 @@ export function isNoMessage(text: string): boolean {
241
258
  * Stored on globalThis to survive HMR reloads in dev mode —
242
259
  * prevents duplicate sockets fighting for the same WhatsApp session. */
243
260
  const globalKey = '__swarmclaw_running_connectors__' as const
244
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
245
- const g = globalThis as any
261
+ const g = globalThis as typeof globalThis & Record<string, unknown>
262
+
263
+ function getOrInitGlobalValue<T>(key: string, factory: () => T): T {
264
+ const existing = g[key]
265
+ if (existing !== undefined) return existing as T
266
+ const created = factory()
267
+ g[key] = created
268
+ return created
269
+ }
270
+
271
+ type ConnectorSession = Session
272
+ type ConnectorAgent = Agent
273
+
246
274
  const running: Map<string, ConnectorInstance> =
247
- g[globalKey] ?? (g[globalKey] = new Map<string, ConnectorInstance>())
275
+ getOrInitGlobalValue(globalKey, () => new Map<string, ConnectorInstance>())
248
276
 
249
277
  /** Most recent inbound channel per connector (used for proactive replies/default outbound target) */
250
278
  const lastInboundKey = '__swarmclaw_connector_last_inbound__' as const
251
279
  const lastInboundChannelByConnector: Map<string, string> =
252
- g[lastInboundKey] ?? (g[lastInboundKey] = new Map<string, string>())
280
+ getOrInitGlobalValue(lastInboundKey, () => new Map<string, string>())
253
281
 
254
282
  /** Last inbound message timestamp per connector (for presence indicators) */
255
283
  const lastInboundTimeKey = '__swarmclaw_connector_last_inbound_time__' as const
256
284
  const lastInboundTimeByConnector: Map<string, number> =
257
- g[lastInboundTimeKey] ?? (g[lastInboundTimeKey] = new Map<string, number>())
285
+ getOrInitGlobalValue(lastInboundTimeKey, () => new Map<string, number>())
258
286
 
259
287
  /** Per-connector lock to prevent concurrent start/stop operations */
260
288
  const lockKey = '__swarmclaw_connector_locks__' as const
261
289
  const locks: Map<string, Promise<void>> =
262
- g[lockKey] ?? (g[lockKey] = new Map<string, Promise<void>>())
290
+ getOrInitGlobalValue(lockKey, () => new Map<string, Promise<void>>())
263
291
 
264
292
  /** Generation counter per connector — used to detect stale lifecycle events after restart */
265
293
  const genCounterKey = '__swarmclaw_connector_gen__' as const
266
294
  const generationCounter: Map<string, number> =
267
- g[genCounterKey] ?? (g[genCounterKey] = new Map<string, number>())
295
+ getOrInitGlobalValue(genCounterKey, () => new Map<string, number>())
268
296
 
269
297
  type ScheduledConnectorFollowup = {
270
298
  id: string
@@ -277,7 +305,25 @@ type ScheduledConnectorFollowup = {
277
305
 
278
306
  const followupKey = '__swarmclaw_connector_followups__' as const
279
307
  const scheduledFollowups: Map<string, ScheduledConnectorFollowup> =
280
- g[followupKey] ?? (g[followupKey] = new Map<string, ScheduledConnectorFollowup>())
308
+ getOrInitGlobalValue(followupKey, () => new Map<string, ScheduledConnectorFollowup>())
309
+
310
+ const inboundDedupeKey = '__swarmclaw_connector_inbound_dedupe__' as const
311
+ const recentInboundByKey: Map<string, number> =
312
+ getOrInitGlobalValue(inboundDedupeKey, () => new Map<string, number>())
313
+
314
+ type DebouncedInboundEntry = {
315
+ connector: Connector
316
+ messages: InboundMessage[]
317
+ timer: ReturnType<typeof setTimeout>
318
+ }
319
+
320
+ const inboundDebounceKey = '__swarmclaw_connector_inbound_debounce__' as const
321
+ const pendingInboundDebounce: Map<string, DebouncedInboundEntry> =
322
+ getOrInitGlobalValue(inboundDebounceKey, () => new Map<string, DebouncedInboundEntry>())
323
+
324
+ const followupDedupeKey = '__swarmclaw_connector_followup_dedupe__' as const
325
+ const scheduledFollowupByDedupe: Map<string, { id: string; sendAt: number }> =
326
+ getOrInitGlobalValue(followupDedupeKey, () => new Map<string, { id: string; sendAt: number }>())
281
327
 
282
328
  /** Reconnect state per connector — tracks backoff and retry attempts for crash recovery */
283
329
  export interface ConnectorReconnectState {
@@ -290,7 +336,7 @@ export interface ConnectorReconnectState {
290
336
 
291
337
  const reconnectStateKey = '__swarmclaw_connector_reconnect_state__' as const
292
338
  const reconnectState: Map<string, ConnectorReconnectState> =
293
- g[reconnectStateKey] ?? (g[reconnectStateKey] = new Map<string, ConnectorReconnectState>())
339
+ getOrInitGlobalValue(reconnectStateKey, () => new Map<string, ConnectorReconnectState>())
294
340
 
295
341
  const RECONNECT_INITIAL_BACKOFF_MS = 1_000
296
342
  const RECONNECT_MAX_BACKOFF_MS = 5 * 60 * 1_000
@@ -308,10 +354,155 @@ function recordHealthEvent(connectorId: string, event: ConnectorHealthEventType,
308
354
  })
309
355
  }
310
356
 
357
+ function statusReactionForPlatform(platform: string, state: 'processing' | 'sent' | 'silent'): string {
358
+ if (platform === 'slack') {
359
+ if (state === 'processing') return 'eyes'
360
+ if (state === 'sent') return 'white_check_mark'
361
+ return 'zipper_mouth_face'
362
+ }
363
+ if (state === 'processing') return '👀'
364
+ if (state === 'sent') return '✅'
365
+ return '🤐'
366
+ }
367
+
368
+ function pruneTransientConnectorState(now = Date.now()): void {
369
+ for (const [key, seenAt] of recentInboundByKey.entries()) {
370
+ if (now - seenAt > 120_000) recentInboundByKey.delete(key)
371
+ }
372
+ for (const [key, entry] of scheduledFollowupByDedupe.entries()) {
373
+ if (entry.sendAt <= now) scheduledFollowupByDedupe.delete(key)
374
+ }
375
+ }
376
+
377
+ function rememberRecentInbound(key: string, now = Date.now(), ttlMs = 120_000): boolean {
378
+ pruneTransientConnectorState(now)
379
+ const previous = recentInboundByKey.get(key) || 0
380
+ if (previous && now - previous < ttlMs) return false
381
+ recentInboundByKey.set(key, now)
382
+ return true
383
+ }
384
+
385
+ function findDirectSessionForInbound(connector: Connector, msg: InboundMessage): ConnectorSession | null {
386
+ if (connector.chatroomId) return null
387
+ const effectiveAgentId = msg.agentIdOverride || connector.agentId
388
+ const sessions = Object.values(loadSessions() as Record<string, ConnectorSession>)
389
+ const candidates = sessions.filter((session) =>
390
+ session?.agentId === effectiveAgentId
391
+ && session?.connectorContext?.connectorId === connector.id
392
+ && session?.connectorContext?.channelId === msg.channelId,
393
+ )
394
+ if (msg.threadId) {
395
+ const threadExact = candidates.find((session) => session?.connectorContext?.threadId === msg.threadId)
396
+ if (threadExact) return threadExact
397
+ }
398
+ const senderExact = candidates.find((session) => session?.connectorContext?.senderId === msg.senderId)
399
+ if (senderExact) return senderExact
400
+ return candidates[0] || null
401
+ }
402
+
403
+ async function maybeSendStatusReaction(
404
+ connector: Connector,
405
+ msg: InboundMessage,
406
+ state: 'processing' | 'sent' | 'silent',
407
+ ): Promise<void> {
408
+ if (!msg.messageId) return
409
+ const session = findDirectSessionForInbound(connector, msg)
410
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
411
+ if (!policy.statusReactions) return
412
+ const instance = running.get(connector.id)
413
+ if (!instance?.sendReaction) return
414
+ try {
415
+ await instance.sendReaction(msg.channelId, msg.messageId, statusReactionForPlatform(connector.platform, state))
416
+ } catch {
417
+ // Ignore reaction failures — connectors vary widely here.
418
+ }
419
+ }
420
+
421
+ function startConnectorTypingLoop(connector: Connector, msg: InboundMessage): (() => void) | null {
422
+ const session = findDirectSessionForInbound(connector, msg)
423
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
424
+ if (!policy.typingIndicators) return null
425
+ const instance = running.get(connector.id)
426
+ if (!instance?.sendTyping) return null
427
+ const replyOptions = shouldReplyToInboundMessage({ msg, session, policy })
428
+
429
+ const sendTyping = () => {
430
+ void instance.sendTyping?.(msg.channelId, { threadId: replyOptions.threadId }).catch(() => {
431
+ // Best effort only.
432
+ })
433
+ }
434
+
435
+ sendTyping()
436
+ const timer = setInterval(sendTyping, 4_000)
437
+ timer.unref?.()
438
+ return () => clearInterval(timer)
439
+ }
440
+
311
441
  type RouteMessageHandler = (connector: Connector, msg: InboundMessage) => Promise<string>
312
442
  const routeHandlerKey = '__swarmclaw_connector_route_handler__' as const
313
443
  const routeMessageHandlerRef: { current: RouteMessageHandler } =
314
- g[routeHandlerKey] ?? (g[routeHandlerKey] = { current: async () => '[Error] Connector router unavailable.' })
444
+ getOrInitGlobalValue(routeHandlerKey, () => ({ current: async () => '[Error] Connector router unavailable.' }))
445
+
446
+ async function flushDebouncedInbound(key: string): Promise<void> {
447
+ const entry = pendingInboundDebounce.get(key)
448
+ if (!entry) return
449
+ pendingInboundDebounce.delete(key)
450
+ clearTimeout(entry.timer)
451
+ const merged = mergeInboundMessages(entry.messages)
452
+ const response = await routeMessageHandlerRef.current(entry.connector, merged)
453
+ if (isNoMessage(response)) {
454
+ return
455
+ }
456
+ const replyOptions = getConnectorReplySendOptions({ connectorId: entry.connector.id, inbound: merged })
457
+ const session = findDirectSessionForInbound(entry.connector, merged)
458
+ await sendConnectorMessage({
459
+ connectorId: entry.connector.id,
460
+ channelId: merged.channelId,
461
+ text: response,
462
+ sessionId: session?.id,
463
+ replyToMessageId: replyOptions.replyToMessageId,
464
+ threadId: replyOptions.threadId,
465
+ })
466
+ await maybeSendStatusReaction(entry.connector, merged, 'sent')
467
+ }
468
+
469
+ async function routeOrDebounceInbound(connector: Connector, msg: InboundMessage): Promise<string> {
470
+ const dedupeKey = buildInboundDedupeKey(connector, msg)
471
+ const dedupeTtlMs = dedupeKey.startsWith('msg:') ? 120_000 : 15_000
472
+ if (!rememberRecentInbound(dedupeKey, Date.now(), dedupeTtlMs)) return NO_MESSAGE_SENTINEL
473
+
474
+ const session = findDirectSessionForInbound(connector, msg)
475
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
476
+ if (policy.inboundDebounceMs <= 0) {
477
+ return routeMessageHandlerRef.current(connector, msg)
478
+ }
479
+
480
+ const debounceKey = buildInboundDebounceKey(connector, msg)
481
+ const pending = pendingInboundDebounce.get(debounceKey)
482
+ if (pending) {
483
+ pending.messages.push(msg)
484
+ clearTimeout(pending.timer)
485
+ pending.timer = setTimeout(() => {
486
+ void flushDebouncedInbound(debounceKey).catch((err: unknown) => {
487
+ console.warn(`[connector] Debounced inbound flush failed: ${err instanceof Error ? err.message : String(err)}`)
488
+ })
489
+ }, policy.inboundDebounceMs)
490
+ pending.timer.unref?.()
491
+ } else {
492
+ const timer = setTimeout(() => {
493
+ void flushDebouncedInbound(debounceKey).catch((err: unknown) => {
494
+ console.warn(`[connector] Debounced inbound flush failed: ${err instanceof Error ? err.message : String(err)}`)
495
+ })
496
+ }, policy.inboundDebounceMs)
497
+ timer.unref?.()
498
+ pendingInboundDebounce.set(debounceKey, {
499
+ connector,
500
+ messages: [msg],
501
+ timer,
502
+ })
503
+ }
504
+ return NO_MESSAGE_SENTINEL
505
+ }
315
506
 
316
507
  function dispatchInboundConnectorMessage(
317
508
  connectorId: string,
@@ -320,7 +511,7 @@ function dispatchInboundConnectorMessage(
320
511
  ): Promise<string> {
321
512
  const connectors = loadConnectors()
322
513
  const currentConnector = connectors[connectorId] as Connector | undefined
323
- return routeMessageHandlerRef.current(currentConnector ?? fallbackConnector, msg)
514
+ return routeOrDebounceInbound(currentConnector ?? fallbackConnector, msg)
324
515
  }
325
516
 
326
517
  /** Get the current generation number for a connector (0 if never started) */
@@ -404,7 +595,17 @@ export function formatInboundUserText(msg: InboundMessage): string {
404
595
  return lines.join('\n').trim()
405
596
  }
406
597
 
407
- type ConnectorCommandName = 'help' | 'status' | 'new' | 'reset' | 'compact' | 'think' | 'pair'
598
+ type ConnectorCommandName =
599
+ | 'help'
600
+ | 'status'
601
+ | 'new'
602
+ | 'reset'
603
+ | 'compact'
604
+ | 'think'
605
+ | 'pair'
606
+ | 'session'
607
+ | 'focus'
608
+ | 'doctor'
408
609
 
409
610
  interface ParsedConnectorCommand {
410
611
  name: ConnectorCommandName
@@ -425,22 +626,318 @@ function parseConnectorCommand(text: string): ParsedConnectorCommand | null {
425
626
  case 'compact':
426
627
  case 'think':
427
628
  case 'pair':
629
+ case 'session':
630
+ case 'focus':
631
+ case 'doctor':
428
632
  return { name, args } as ParsedConnectorCommand
429
633
  default:
430
634
  return null
431
635
  }
432
636
  }
433
637
 
434
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
435
- function pushSessionMessage(session: Record<string, any>, role: 'user' | 'assistant', text: string): void {
638
+ function persistSessionRecord(session: ConnectorSession): void {
639
+ const sessions = loadSessions()
640
+ sessions[session.id] = session
641
+ saveSessions(sessions)
642
+ }
643
+
644
+ function updateSessionConnectorContext(session: ConnectorSession, connector: Connector, msg: InboundMessage, sessionKey: string): void {
645
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
646
+ session.connectorContext = {
647
+ ...(session.connectorContext || {}),
648
+ connectorId: connector.id,
649
+ platform: connector.platform,
650
+ channelId: msg.channelId,
651
+ senderId: msg.senderId,
652
+ senderName: msg.senderName,
653
+ sessionKey,
654
+ peerKey: msg.senderId,
655
+ scope: policy.scope,
656
+ replyMode: policy.replyMode,
657
+ threadBinding: policy.threadBinding,
658
+ groupPolicy: policy.groupPolicy,
659
+ threadId: msg.threadId || session.connectorContext?.threadId || null,
660
+ threadTitle: msg.threadTitle || session.connectorContext?.threadTitle || null,
661
+ threadPersonaLabel: resolveThreadPersonaLabel(msg) || session.connectorContext?.threadPersonaLabel || null,
662
+ threadParentChannelId: msg.threadParentChannelId || session.connectorContext?.threadParentChannelId || null,
663
+ threadParentChannelName: msg.threadParentChannelName || session.connectorContext?.threadParentChannelName || null,
664
+ isGroup: !!msg.isGroup,
665
+ lastInboundAt: Date.now(),
666
+ lastInboundMessageId: msg.messageId || null,
667
+ lastInboundReplyToMessageId: msg.replyToMessageId || null,
668
+ lastInboundThreadId: msg.threadId || null,
669
+ lastOutboundAt: session.connectorContext?.lastOutboundAt ?? null,
670
+ lastOutboundMessageId: session.connectorContext?.lastOutboundMessageId ?? null,
671
+ lastResetAt: session.connectorContext?.lastResetAt ?? null,
672
+ lastResetReason: session.connectorContext?.lastResetReason ?? null,
673
+ }
674
+ }
675
+
676
+ function describeSessionControls(session: ConnectorSession, connector: Connector, msg: InboundMessage): string {
677
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
678
+ const context = session.connectorContext || {}
679
+ const sessionAgeSec = Math.max(0, Math.round((Date.now() - (session.createdAt || Date.now())) / 1000))
680
+ const idleSec = Math.max(0, Math.round((Date.now() - (session.lastActiveAt || Date.now())) / 1000))
681
+ return [
682
+ `Session controls for ${connector.platform}/${connector.name}:`,
683
+ `- Session: ${session.id}`,
684
+ `- Scope: ${policy.scope}`,
685
+ `- Reply mode: ${policy.replyMode}`,
686
+ `- Thread binding: ${policy.threadBinding}`,
687
+ `- Group policy: ${policy.groupPolicy}`,
688
+ `- Reset mode: ${policy.resetMode}`,
689
+ `- Idle timeout: ${policy.idleTimeoutSec ?? 0}s`,
690
+ `- Max age: ${policy.maxAgeSec ?? 0}s`,
691
+ `- Daily reset: ${policy.dailyResetAt || 'off'}`,
692
+ `- Reset timezone: ${policy.resetTimezone || 'local'}`,
693
+ `- Debounce: ${policy.inboundDebounceMs}ms`,
694
+ `- Typing indicators: ${policy.typingIndicators ? 'on' : 'off'}`,
695
+ `- Thinking: ${policy.thinkingLevel || session.thinkingLevel || 'inherit'}`,
696
+ `- Model: ${session.provider}/${session.model}`,
697
+ `- Last outbound message: ${context.lastOutboundMessageId || 'none'}`,
698
+ `- Thread: ${context.threadId || 'none'}`,
699
+ `- Thread title: ${context.threadTitle || 'none'}`,
700
+ `- Thread persona: ${context.threadPersonaLabel || 'none'}`,
701
+ `- Session age: ${sessionAgeSec}s`,
702
+ `- Idle for: ${idleSec}s`,
703
+ ].join('\n')
704
+ }
705
+
706
+ function normalizeSessionSettingKey(raw: string): string {
707
+ return raw.trim().toLowerCase().replace(/[_-]+/g, '')
708
+ }
709
+
710
+ function applySessionSetting(session: ConnectorSession, keyRaw: string, valueRaw: string, msg: InboundMessage): string {
711
+ const key = normalizeSessionSettingKey(keyRaw)
712
+ const value = valueRaw.trim()
713
+ const asInt = () => {
714
+ const parsed = Number.parseInt(value, 10)
715
+ if (!Number.isFinite(parsed) || parsed < 0) {
716
+ throw new Error(`Invalid numeric value for ${keyRaw}: ${valueRaw}`)
717
+ }
718
+ return parsed
719
+ }
720
+ const asEnum = <T extends string>(allowed: readonly T[], label: string): T | null => {
721
+ if (!value) return null
722
+ const normalized = value.toLowerCase()
723
+ if ((allowed as readonly string[]).includes(normalized)) return normalized as T
724
+ throw new Error(`Invalid ${label}. Use one of: ${allowed.join(', ')}.`)
725
+ }
726
+
727
+ switch (key) {
728
+ case 'think':
729
+ case 'thinkinglevel':
730
+ session.connectorThinkLevel = asEnum(['minimal', 'low', 'medium', 'high'] as const, '/think level')
731
+ return `Connector thinking level set to ${session.connectorThinkLevel || 'inherit'}.`
732
+ case 'reply':
733
+ case 'replymode':
734
+ session.connectorReplyMode = asEnum(['off', 'first', 'all'] as const, 'reply mode')
735
+ return `Reply mode set to ${session.connectorReplyMode || 'inherit'}.`
736
+ case 'scope':
737
+ case 'sessionscope':
738
+ session.connectorSessionScope = asEnum(['main', 'channel', 'peer', 'channel-peer', 'thread'] as const, 'session scope')
739
+ return `Session scope set to ${session.connectorSessionScope || 'inherit'}.`
740
+ case 'thread':
741
+ case 'threadbinding':
742
+ session.connectorThreadBinding = asEnum(['off', 'prefer', 'strict'] as const, 'thread binding')
743
+ if (!value) {
744
+ session.connectorContext = { ...(session.connectorContext || {}), threadId: null }
745
+ } else if (session.connectorThreadBinding === 'strict' && msg.threadId) {
746
+ session.connectorContext = { ...(session.connectorContext || {}), threadId: msg.threadId }
747
+ }
748
+ return `Thread binding set to ${session.connectorThreadBinding || 'inherit'}.`
749
+ case 'group':
750
+ case 'grouppolicy':
751
+ session.connectorGroupPolicy = asEnum(['open', 'mention', 'reply-or-mention', 'disabled'] as const, 'group policy')
752
+ return `Group policy set to ${session.connectorGroupPolicy || 'inherit'}.`
753
+ case 'idle':
754
+ case 'idletimeout':
755
+ session.connectorIdleTimeoutSec = asInt()
756
+ return `Idle timeout set to ${session.connectorIdleTimeoutSec}s.`
757
+ case 'maxage':
758
+ session.connectorMaxAgeSec = asInt()
759
+ return `Max age set to ${session.connectorMaxAgeSec}s.`
760
+ case 'reset':
761
+ case 'resetmode': {
762
+ const normalized = value.toLowerCase()
763
+ if (!value) {
764
+ session.sessionResetMode = null
765
+ return 'Reset mode set to inherit.'
766
+ }
767
+ if (normalized !== 'idle' && normalized !== 'daily') {
768
+ throw new Error('Reset mode must be "idle" or "daily".')
769
+ }
770
+ session.sessionResetMode = normalized
771
+ return `Reset mode set to ${session.sessionResetMode}.`
772
+ }
773
+ case 'daily':
774
+ case 'dailyreset':
775
+ case 'dailyresetat':
776
+ if (!value) {
777
+ session.sessionDailyResetAt = null
778
+ return 'Daily reset time cleared.'
779
+ }
780
+ if (!/^\d{1,2}:\d{2}$/.test(value)) {
781
+ throw new Error('Daily reset time must be in HH:MM format.')
782
+ }
783
+ session.sessionDailyResetAt = value
784
+ return `Daily reset time set to ${session.sessionDailyResetAt}.`
785
+ case 'timezone':
786
+ case 'resettimezone':
787
+ session.sessionResetTimezone = value || null
788
+ return `Reset timezone set to ${session.sessionResetTimezone || 'inherit/local'}.`
789
+ case 'model':
790
+ session.model = value
791
+ return `Model set to ${session.model}.`
792
+ case 'provider': {
793
+ const provider = getProvider(value)
794
+ if (!provider) {
795
+ throw new Error(`Unknown provider "${value}".`)
796
+ }
797
+ session.provider = provider.id as Session['provider']
798
+ session.apiEndpoint = provider.defaultEndpoint || session.apiEndpoint || null
799
+ return `Provider set to ${session.provider}.`
800
+ }
801
+ default:
802
+ throw new Error(`Unknown session setting "${keyRaw}".`)
803
+ }
804
+ }
805
+
806
+ function evaluateGroupPolicy(params: {
807
+ connector: Connector
808
+ msg: InboundMessage
809
+ session?: ConnectorSession | null
810
+ aliases: string[]
811
+ }): { allowed: boolean; reason: string } {
812
+ const { connector, msg, session, aliases } = params
813
+ if (!msg.isGroup) return { allowed: true, reason: 'dm' }
814
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
815
+ if (policy.groupPolicy === 'open') return { allowed: true, reason: 'open' }
816
+ if (policy.groupPolicy === 'disabled') return { allowed: false, reason: 'disabled' }
817
+ const mentioned = !!msg.mentionsBot || textMentionsAlias(msg.text || '', aliases)
818
+ const replied = isReplyToLastOutbound(msg, session)
819
+ if (policy.groupPolicy === 'mention') {
820
+ return { allowed: mentioned, reason: mentioned ? 'mentioned' : 'mention_required' }
821
+ }
822
+ const allowed = mentioned || replied
823
+ return { allowed, reason: allowed ? (mentioned ? 'mentioned' : 'reply') : 'reply_or_mention_required' }
824
+ }
825
+
826
+ function applyConnectorRuntimeDefaults(session: ConnectorSession, defaults: {
827
+ provider: Session['provider']
828
+ model: string
829
+ apiEndpoint: string | null
830
+ thinkingLevel: Session['connectorThinkLevel']
831
+ }): void {
832
+ session.provider = defaults.provider
833
+ session.model = defaults.model
834
+ session.apiEndpoint = defaults.apiEndpoint
835
+ session.connectorThinkLevel = defaults.thinkingLevel
836
+ }
837
+
838
+ function resolveDirectSession(params: {
839
+ connector: Connector
840
+ msg: InboundMessage
841
+ agent: ConnectorAgent
842
+ }): { session: ConnectorSession; sessionKey: string; wasCreated: boolean; staleReason?: string | null; clearedMessages?: number } {
843
+ const { connector, msg, agent } = params
844
+ const policySeed = resolveConnectorSessionPolicy(connector, msg)
845
+ const providerInfo = policySeed.providerOverride ? getProvider(policySeed.providerOverride) : null
846
+ const defaultProvider: Session['provider'] = providerInfo?.id || (agent.provider === 'claude-cli' ? 'anthropic' : agent.provider)
847
+ const defaultModel = policySeed.modelOverride || agent.model
848
+ const defaultApiEndpoint = agent.apiEndpoint || providerInfo?.defaultEndpoint || null
849
+ const runtimeDefaults = {
850
+ provider: defaultProvider,
851
+ model: defaultModel,
852
+ apiEndpoint: defaultApiEndpoint,
853
+ thinkingLevel: policySeed.thinkingLevel || null,
854
+ }
855
+ const sessionKey = buildConnectorConversationKey({
856
+ connector,
857
+ msg,
858
+ agentId: agent.id,
859
+ policy: policySeed,
860
+ })
861
+ const sessions = loadSessions()
862
+ let session = Object.values(sessions as Record<string, ConnectorSession>).find((item) => item?.name === sessionKey)
863
+ let wasCreated = false
864
+ if (!session) {
865
+ const id = genId()
866
+ session = {
867
+ id,
868
+ name: sessionKey,
869
+ cwd: WORKSPACE_DIR,
870
+ user: 'connector',
871
+ provider: defaultProvider,
872
+ model: defaultModel,
873
+ credentialId: agent.credentialId || null,
874
+ fallbackCredentialIds: Array.isArray(agent.fallbackCredentialIds) ? [...agent.fallbackCredentialIds] : [],
875
+ apiEndpoint: defaultApiEndpoint,
876
+ claudeSessionId: null,
877
+ codexThreadId: null,
878
+ opencodeSessionId: null,
879
+ delegateResumeIds: {
880
+ claudeCode: null,
881
+ codex: null,
882
+ opencode: null,
883
+ gemini: null,
884
+ },
885
+ messages: [],
886
+ createdAt: Date.now(),
887
+ lastActiveAt: Date.now(),
888
+ sessionType: 'human' as const,
889
+ agentId: agent.id,
890
+ plugins: agent.plugins || agent.tools || [],
891
+ thinkingLevel: agent.thinkingLevel || null,
892
+ connectorThinkLevel: policySeed.thinkingLevel || null,
893
+ }
894
+ wasCreated = true
895
+ }
896
+ session.name = sessionKey
897
+ session.agentId = agent.id
898
+ session.plugins = Array.isArray(session.plugins) ? session.plugins : (agent.plugins || agent.tools || [])
899
+ if (!session.provider) session.provider = defaultProvider
900
+ if (!session.model) session.model = defaultModel
901
+ if (session.credentialId === undefined) session.credentialId = agent.credentialId || null
902
+ if (!Array.isArray(session.fallbackCredentialIds) && Array.isArray(agent.fallbackCredentialIds)) {
903
+ session.fallbackCredentialIds = [...agent.fallbackCredentialIds]
904
+ }
905
+ if (session.apiEndpoint === undefined || session.apiEndpoint === null) session.apiEndpoint = defaultApiEndpoint
906
+ if ((session.connectorThinkLevel === undefined || session.connectorThinkLevel === null) && policySeed.thinkingLevel) {
907
+ session.connectorThinkLevel = policySeed.thinkingLevel
908
+ }
909
+
910
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
911
+ const staleness = getConnectorSessionStaleness(session, policy)
912
+ let clearedMessages = 0
913
+ if (staleness.stale) {
914
+ try { syncSessionArchiveMemory(session, { agent }) } catch { /* archive sync is best-effort */ }
915
+ clearedMessages = resetConnectorSessionRuntime(session, staleness.reason || 'session_refresh')
916
+ applyConnectorRuntimeDefaults(session, {
917
+ ...runtimeDefaults,
918
+ thinkingLevel: policySeed.thinkingLevel || session.connectorThinkLevel || null,
919
+ })
920
+ }
921
+ updateSessionConnectorContext(session, connector, msg, sessionKey)
922
+ sessions[session.id] = session
923
+ saveSessions(sessions)
924
+ return {
925
+ session,
926
+ sessionKey,
927
+ wasCreated,
928
+ staleReason: staleness.reason || null,
929
+ clearedMessages,
930
+ }
931
+ }
932
+
933
+ function pushSessionMessage(session: ConnectorSession, role: 'user' | 'assistant', text: string): void {
436
934
  if (!text.trim()) return
437
935
  if (!Array.isArray(session.messages)) session.messages = []
438
936
  session.messages.push({ role, text: text.trim(), time: Date.now() })
439
937
  session.lastActiveAt = Date.now()
440
938
  }
441
939
 
442
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
443
- function persistSession(session: Record<string, any>): void {
940
+ function persistSession(session: ConnectorSession): void {
444
941
  const sessions = loadSessions()
445
942
  sessions[session.id] = session
446
943
  saveSessions(sessions)
@@ -594,8 +1091,7 @@ function enforceInboundAccessPolicy(connector: Connector, msg: InboundMessage):
594
1091
  async function handleConnectorCommand(params: {
595
1092
  command: ParsedConnectorCommand
596
1093
  connector: Connector
597
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
598
- session: Record<string, any>
1094
+ session: ConnectorSession
599
1095
  msg: InboundMessage
600
1096
  agentName: string
601
1097
  }): Promise<string> {
@@ -609,6 +1105,10 @@ async function handleConnectorCommand(params: {
609
1105
  '/new or /reset — Clear this connector conversation thread',
610
1106
  '/compact [keepLastN] — Summarize older history and keep recent messages (default 10)',
611
1107
  '/think <minimal|low|medium|high> — Set connector thread reasoning guidance',
1108
+ '/session — Show session controls',
1109
+ '/session set <scope|reply|thread|group|idle|maxAge|resetMode|dailyResetAt|timezone|think|model|provider> <value> — Patch this connector session',
1110
+ '/focus here|clear — Bind or clear focus on the current thread/topic',
1111
+ '/doctor — Show autonomy and safety warnings for this connector/session',
612
1112
  '/pair — Pairing/access controls (status, request, list, approve, allow)',
613
1113
  '/help — Show this list',
614
1114
  ].join('\n')
@@ -619,6 +1119,7 @@ async function handleConnectorCommand(params: {
619
1119
  }
620
1120
 
621
1121
  if (command.name === 'status') {
1122
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
622
1123
  const all = Array.isArray(session.messages) ? session.messages : []
623
1124
  const userCount = all.filter((m: { role?: string }) => m?.role === 'user').length
624
1125
  const assistantCount = all.filter((m: { role?: string }) => m?.role === 'assistant').length
@@ -632,6 +1133,9 @@ async function handleConnectorCommand(params: {
632
1133
  `- Tools enabled: ${toolsCount}`,
633
1134
  `- Channel: ${msg.channelName || msg.channelId}`,
634
1135
  `- Last active: ${new Date(session.lastActiveAt || session.createdAt || Date.now()).toLocaleString()}`,
1136
+ `- Reset mode: ${policy.resetMode}`,
1137
+ `- Reply mode: ${policy.replyMode}`,
1138
+ `- Scope: ${policy.scope}`,
635
1139
  ].join('\n')
636
1140
  pushSessionMessage(session, 'user', inboundText)
637
1141
  pushSessionMessage(session, 'assistant', statusText)
@@ -640,13 +1144,18 @@ async function handleConnectorCommand(params: {
640
1144
  }
641
1145
 
642
1146
  if (command.name === 'new' || command.name === 'reset') {
643
- const cleared = Array.isArray(session.messages) ? session.messages.length : 0
644
- session.messages = []
645
- session.claudeSessionId = null
646
- session.codexThreadId = null
647
- session.opencodeSessionId = null
648
- session.delegateResumeIds = { claudeCode: null, codex: null, opencode: null, gemini: null }
649
- session.lastActiveAt = Date.now()
1147
+ const agent = session.agentId ? (loadAgents() as Record<string, ConnectorAgent>)[session.agentId] : undefined
1148
+ try { syncSessionArchiveMemory(session, { agent }) } catch { /* best effort */ }
1149
+ const cleared = resetConnectorSessionRuntime(session, 'manual_reset')
1150
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
1151
+ const providerInfo = policy.providerOverride ? getProvider(policy.providerOverride) : null
1152
+ applyConnectorRuntimeDefaults(session, {
1153
+ provider: providerInfo?.id || session.provider,
1154
+ model: policy.modelOverride || session.model,
1155
+ apiEndpoint: providerInfo?.defaultEndpoint || session.apiEndpoint || null,
1156
+ thinkingLevel: policy.thinkingLevel || session.connectorThinkLevel || null,
1157
+ })
1158
+ updateSessionConnectorContext(session, connector, msg, session.name || session.id)
650
1159
  persistSession(session)
651
1160
  return `Reset complete for ${connector.platform} channel thread. Cleared ${cleared} message(s).`
652
1161
  }
@@ -681,10 +1190,11 @@ async function handleConnectorCommand(params: {
681
1190
 
682
1191
  if (command.name === 'think') {
683
1192
  const requested = command.args.trim().toLowerCase()
684
- const allowed = new Set(['minimal', 'low', 'medium', 'high'])
1193
+ const allowed = new Set(['minimal', 'low', 'medium', 'high'] as const)
685
1194
  if (!requested) {
686
- const current = typeof session.connectorThinkLevel === 'string' && allowed.has(session.connectorThinkLevel)
687
- ? session.connectorThinkLevel
1195
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
1196
+ const current = typeof policy.thinkingLevel === 'string' && allowed.has(policy.thinkingLevel)
1197
+ ? policy.thinkingLevel
688
1198
  : 'medium'
689
1199
  const text = `Current /think level: ${current}. Usage: /think <minimal|low|medium|high>.`
690
1200
  pushSessionMessage(session, 'user', inboundText)
@@ -692,7 +1202,12 @@ async function handleConnectorCommand(params: {
692
1202
  persistSession(session)
693
1203
  return text
694
1204
  }
695
- if (!allowed.has(requested)) {
1205
+ if (
1206
+ requested !== 'minimal'
1207
+ && requested !== 'low'
1208
+ && requested !== 'medium'
1209
+ && requested !== 'high'
1210
+ ) {
696
1211
  const text = 'Invalid /think level. Use one of: minimal, low, medium, high.'
697
1212
  pushSessionMessage(session, 'user', inboundText)
698
1213
  pushSessionMessage(session, 'assistant', text)
@@ -708,6 +1223,78 @@ async function handleConnectorCommand(params: {
708
1223
  return text
709
1224
  }
710
1225
 
1226
+ if (command.name === 'doctor') {
1227
+ const warnings = buildConnectorDoctorWarnings({ connector, msg, session })
1228
+ const text = warnings.length
1229
+ ? ['Connector doctor:', ...warnings.map((item) => `- ${item}`)].join('\n')
1230
+ : 'Connector doctor: no obvious autonomy or safety issues detected.'
1231
+ pushSessionMessage(session, 'user', inboundText)
1232
+ pushSessionMessage(session, 'assistant', text)
1233
+ persistSession(session)
1234
+ return text
1235
+ }
1236
+
1237
+ if (command.name === 'session') {
1238
+ const parts = command.args.split(/\s+/).map((item) => item.trim()).filter(Boolean)
1239
+ if (!parts.length || parts[0].toLowerCase() === 'show' || parts[0].toLowerCase() === 'status') {
1240
+ const text = describeSessionControls(session, connector, msg)
1241
+ pushSessionMessage(session, 'user', inboundText)
1242
+ pushSessionMessage(session, 'assistant', text)
1243
+ persistSession(session)
1244
+ return text
1245
+ }
1246
+ if (parts[0].toLowerCase() === 'reset') {
1247
+ const agent = session.agentId ? (loadAgents() as Record<string, ConnectorAgent>)[session.agentId] : undefined
1248
+ try { syncSessionArchiveMemory(session, { agent }) } catch { /* best effort */ }
1249
+ const cleared = resetConnectorSessionRuntime(session, 'manual_reset')
1250
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
1251
+ const providerInfo = policy.providerOverride ? getProvider(policy.providerOverride) : null
1252
+ applyConnectorRuntimeDefaults(session, {
1253
+ provider: providerInfo?.id || session.provider,
1254
+ model: policy.modelOverride || session.model,
1255
+ apiEndpoint: providerInfo?.defaultEndpoint || session.apiEndpoint || null,
1256
+ thinkingLevel: policy.thinkingLevel || session.connectorThinkLevel || null,
1257
+ })
1258
+ updateSessionConnectorContext(session, connector, msg, session.name || session.id)
1259
+ persistSession(session)
1260
+ return `Connector session reset. Cleared ${cleared} message(s).`
1261
+ }
1262
+ if (parts[0].toLowerCase() === 'set') {
1263
+ const key = parts[1] || ''
1264
+ const value = parts.slice(2).join(' ').trim()
1265
+ if (!key) return 'Usage: /session set <scope|reply|thread|group|idle|maxAge|resetMode|dailyResetAt|timezone|think|model|provider> <value>'
1266
+ try {
1267
+ const text = applySessionSetting(session, key, value, msg)
1268
+ updateSessionConnectorContext(session, connector, msg, session.name || session.id)
1269
+ persistSession(session)
1270
+ return text
1271
+ } catch (err: unknown) {
1272
+ return err instanceof Error ? err.message : String(err)
1273
+ }
1274
+ }
1275
+ return 'Usage: /session, /session show, /session set <key> <value>, /session reset'
1276
+ }
1277
+
1278
+ if (command.name === 'focus') {
1279
+ const subcommand = command.args.trim().toLowerCase()
1280
+ if (subcommand === 'clear') {
1281
+ session.connectorThreadBinding = null
1282
+ session.connectorSessionScope = null
1283
+ session.connectorContext = { ...(session.connectorContext || {}), threadId: null }
1284
+ persistSession(session)
1285
+ return 'Cleared connector thread focus.'
1286
+ }
1287
+ if (!msg.threadId) {
1288
+ return 'Focus can only be set from a threaded or topic-bound message.'
1289
+ }
1290
+ session.connectorThreadBinding = 'strict'
1291
+ session.connectorSessionScope = 'thread'
1292
+ session.connectorReplyMode = session.connectorReplyMode || 'all'
1293
+ session.connectorContext = { ...(session.connectorContext || {}), threadId: msg.threadId }
1294
+ persistSession(session)
1295
+ return `Focused this connector session on thread ${msg.threadId}.`
1296
+ }
1297
+
711
1298
  return 'Unknown command.'
712
1299
  }
713
1300
 
@@ -721,6 +1308,9 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
721
1308
  if (!chatroom) return '[Error] Chatroom not found.'
722
1309
 
723
1310
  const agents = loadAgents()
1311
+ const chatroomAgentAliases = chatroom.agentIds
1312
+ .map((agentId) => agents[agentId]?.name)
1313
+ .filter((name): name is string => typeof name === 'string' && !!name.trim())
724
1314
  const preferredCredentialId = (() => {
725
1315
  if (connector.agentId && agents[connector.agentId]?.credentialId) {
726
1316
  return agents[connector.agentId].credentialId as string
@@ -735,6 +1325,16 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
735
1325
  msg,
736
1326
  preferredCredentialId,
737
1327
  })
1328
+ const groupGate = evaluateGroupPolicy({
1329
+ connector,
1330
+ msg,
1331
+ aliases: [connector.name, ...chatroomAgentAliases],
1332
+ })
1333
+ if (!groupGate.allowed) return NO_MESSAGE_SENTINEL
1334
+
1335
+ await maybeSendStatusReaction(connector, msg, 'processing')
1336
+ const stopTyping = startConnectorTypingLoop(connector, msg)
1337
+ try {
738
1338
 
739
1339
  const source: MessageSource = {
740
1340
  platform: connector.platform,
@@ -743,10 +1343,14 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
743
1343
  channelId: msg.channelId,
744
1344
  senderId: msg.senderId,
745
1345
  senderName: msg.senderName,
1346
+ messageId: msg.messageId,
1347
+ replyToMessageId: msg.replyToMessageId,
1348
+ threadId: msg.threadId,
746
1349
  }
747
1350
  const inboundText = formatInboundUserText(msg)
748
1351
  const inboundAttachmentPaths = buildInboundAttachmentPaths(msg)
749
1352
  const firstImagePath = msg.media?.find((m) => m.type === 'image')?.localPath
1353
+ const threadContextBlock = buildConnectorThreadContextBlock(msg)
750
1354
 
751
1355
  // Parse mentions from the message text
752
1356
  let mentions = parseMentions(msg.text || '', agents, chatroom.agentIds)
@@ -821,10 +1425,10 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
821
1425
  continue
822
1426
  }
823
1427
 
824
- const syntheticSession = buildSyntheticSession(agent, chatroomId)
1428
+ const syntheticSession = ensureSyntheticSession(agent, chatroomId)
825
1429
  const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent)
826
1430
  const chatroomContext = buildChatroomSystemPrompt(freshChatroom, agents, agent.id)
827
- const fullSystemPrompt = [agentSystemPrompt, chatroomContext].filter(Boolean).join('\n\n')
1431
+ const fullSystemPrompt = [agentSystemPrompt, chatroomContext, threadContextBlock].filter(Boolean).join('\n\n')
828
1432
  const history = buildHistoryForAgent(freshChatroom, agent.id)
829
1433
 
830
1434
  try {
@@ -882,7 +1486,10 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
882
1486
  }
883
1487
  }
884
1488
 
885
- if (responses.length === 0) return NO_MESSAGE_SENTINEL
1489
+ if (responses.length === 0) {
1490
+ await maybeSendStatusReaction(connector, msg, 'silent')
1491
+ return NO_MESSAGE_SENTINEL
1492
+ }
886
1493
 
887
1494
  const joined = responses.join('\n\n')
888
1495
  // Extract embedded media from agent responses and send them via connector
@@ -891,9 +1498,18 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
891
1498
  if (filesToSend.length > 0) {
892
1499
  const inst = running.get(connector.id)
893
1500
  if (inst?.sendMessage) {
1501
+ const replyOptions = getConnectorReplySendOptions({ connectorId: connector.id, inbound: msg })
894
1502
  for (const file of filesToSend) {
895
1503
  try {
896
- await inst.sendMessage(msg.channelId, '', { mediaPath: file.path, caption: file.alt || undefined })
1504
+ await sendConnectorMessage({
1505
+ connectorId: connector.id,
1506
+ channelId: msg.channelId,
1507
+ text: '',
1508
+ mediaPath: file.path,
1509
+ caption: file.alt || undefined,
1510
+ replyToMessageId: replyOptions.replyToMessageId,
1511
+ threadId: replyOptions.threadId,
1512
+ })
897
1513
  console.log(`[connector] Sent chatroom media to ${msg.platform}: ${path.basename(file.path)}`)
898
1514
  } catch (err: unknown) {
899
1515
  console.error(`[connector] Failed to send chatroom media ${path.basename(file.path)}:`, err instanceof Error ? err.message : String(err))
@@ -903,6 +1519,9 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
903
1519
  return extracted.cleanText || '(no response)'
904
1520
  }
905
1521
  return joined
1522
+ } finally {
1523
+ stopTyping?.()
1524
+ }
906
1525
  }
907
1526
 
908
1527
  /** Route an inbound message through the assigned agent and return the response */
@@ -927,84 +1546,11 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
927
1546
  preferredCredentialId: agent.credentialId || null,
928
1547
  })
929
1548
 
930
- // Enqueue system event + heartbeat wake for the agent
931
- const preview = (msg.text || '').slice(0, 80)
932
- enqueueSystemEvent(
933
- `connector:${connector.id}:${msg.channelId}`,
934
- `Inbound message from ${msg.platform}: ${preview}`,
935
- 'connector-message',
936
- )
937
- requestHeartbeatNow({ agentId: effectiveAgentId, reason: 'connector-message' })
938
-
939
- // Log connector trigger
940
- const triggerSessionKey = `connector:${connector.id}:${msg.channelId}`
941
- const allSessions = loadSessions()
942
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
943
- const existingSession = Object.values(allSessions).find((s: any) => s.name === triggerSessionKey)
944
- if (existingSession) {
945
- logExecution(existingSession.id, 'trigger', `${msg.platform} message from ${msg.senderName}`, {
946
- agentId: agent.id,
947
- detail: {
948
- source: 'connector',
949
- platform: msg.platform,
950
- connectorId: connector.id,
951
- channelId: msg.channelId,
952
- senderName: msg.senderName,
953
- messagePreview: (msg.text || '').slice(0, 200),
954
- hasMedia: !!(msg.media?.length || msg.imageUrl),
955
- },
956
- })
957
- }
958
-
959
- // Resolve API key for the agent's provider
960
- let apiKey: string | null = null
961
- if (agent.credentialId) {
962
- const creds = loadCredentials()
963
- const cred = creds[agent.credentialId]
964
- if (cred?.encryptedKey) {
965
- try { apiKey = decryptKey(cred.encryptedKey) } catch { /* ignore */ }
966
- }
967
- }
968
-
969
- // Find a session for this connector message.
970
- // Prefer the agent's thread session (visible in the agent chat UI) so connector
971
- // messages appear inline alongside web UI messages.
972
- // Fall back to a connector-keyed session if the agent has no thread session.
973
- const sessionKey = `connector:${connector.id}:${msg.channelId}`
974
- const sessions = loadSessions()
975
- let session = (agent.threadSessionId && sessions[agent.threadSessionId])
976
- ? sessions[agent.threadSessionId]
977
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
978
- : Object.values(sessions).find((s: any) => s.name === sessionKey)
979
- if (!session) {
980
- const id = genId()
981
- session = {
982
- id,
983
- name: sessionKey,
984
- cwd: WORKSPACE_DIR,
985
- user: 'connector',
986
- provider: agent.provider === 'claude-cli' ? 'anthropic' : agent.provider,
987
- model: agent.model,
988
- credentialId: agent.credentialId || null,
989
- apiEndpoint: agent.apiEndpoint || null,
990
- claudeSessionId: null,
991
- codexThreadId: null,
992
- opencodeSessionId: null,
993
- delegateResumeIds: {
994
- claudeCode: null,
995
- codex: null,
996
- opencode: null,
997
- },
998
- messages: [],
999
- createdAt: Date.now(),
1000
- lastActiveAt: Date.now(),
1001
- sessionType: 'human' as const,
1002
- agentId: agent.id,
1003
- plugins: agent.plugins || agent.tools || [],
1004
- }
1005
- sessions[id] = session
1006
- saveSessions(sessions)
1007
- }
1549
+ const { session, sessionKey, wasCreated, staleReason, clearedMessages } = resolveDirectSession({
1550
+ connector,
1551
+ msg,
1552
+ agent,
1553
+ })
1008
1554
 
1009
1555
  const parsedCommand = parseConnectorCommand(msg.text || '')
1010
1556
  if (parsedCommand?.name === 'pair') {
@@ -1039,6 +1585,26 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1039
1585
  return accessPolicyResult
1040
1586
  }
1041
1587
 
1588
+ const groupGate = evaluateGroupPolicy({
1589
+ connector,
1590
+ msg,
1591
+ session,
1592
+ aliases: [agent.name, connector.name],
1593
+ })
1594
+ if (!groupGate.allowed) {
1595
+ logExecution(session.id, 'decision', 'Connector inbound blocked by group policy', {
1596
+ agentId: agent.id,
1597
+ detail: {
1598
+ platform: msg.platform,
1599
+ channelId: msg.channelId,
1600
+ senderId: msg.senderId,
1601
+ groupPolicy: resolveConnectorSessionPolicy(connector, msg, session).groupPolicy,
1602
+ reason: groupGate.reason,
1603
+ },
1604
+ })
1605
+ return NO_MESSAGE_SENTINEL
1606
+ }
1607
+
1042
1608
  if (parsedCommand) {
1043
1609
  const commandResult = await handleConnectorCommand({
1044
1610
  command: parsedCommand,
@@ -1059,6 +1625,58 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1059
1625
  return commandResult
1060
1626
  }
1061
1627
 
1628
+ await maybeSendStatusReaction(connector, msg, 'processing')
1629
+ const stopTyping = startConnectorTypingLoop(connector, msg)
1630
+ try {
1631
+ // Enqueue system event + heartbeat wake for the agent only after access/gating checks pass.
1632
+ const preview = (msg.text || '').slice(0, 80)
1633
+ enqueueSystemEvent(
1634
+ sessionKey,
1635
+ `Inbound message from ${msg.platform}: ${preview}`,
1636
+ 'connector-message',
1637
+ )
1638
+ requestHeartbeatNow({ agentId: effectiveAgentId, reason: 'connector-message' })
1639
+
1640
+ logExecution(session.id, 'trigger', `${msg.platform} message from ${msg.senderName}`, {
1641
+ agentId: agent.id,
1642
+ detail: {
1643
+ source: 'connector',
1644
+ platform: msg.platform,
1645
+ connectorId: connector.id,
1646
+ channelId: msg.channelId,
1647
+ senderName: msg.senderName,
1648
+ sessionKey,
1649
+ messagePreview: (msg.text || '').slice(0, 200),
1650
+ hasMedia: !!(msg.media?.length || msg.imageUrl),
1651
+ staleReason: staleReason || null,
1652
+ clearedMessages: clearedMessages || 0,
1653
+ },
1654
+ })
1655
+
1656
+ // Resolve API key for the effective session provider, preferring matching fallback credentials.
1657
+ let apiKey: string | null = null
1658
+ const sessionCredentialIds = [
1659
+ session.credentialId,
1660
+ ...(Array.isArray(session.fallbackCredentialIds) ? session.fallbackCredentialIds : []),
1661
+ ].filter(Boolean) as string[]
1662
+ if (sessionCredentialIds.length > 0) {
1663
+ const creds = loadCredentials()
1664
+ const matching = sessionCredentialIds.find((credentialId) => creds[credentialId]?.provider === session.provider)
1665
+ const ordered = matching
1666
+ ? [matching, ...sessionCredentialIds.filter((credentialId) => credentialId !== matching)]
1667
+ : sessionCredentialIds
1668
+ for (const credentialId of ordered) {
1669
+ const cred = creds[credentialId]
1670
+ if (!cred?.encryptedKey) continue
1671
+ try {
1672
+ apiKey = decryptKey(cred.encryptedKey)
1673
+ break
1674
+ } catch {
1675
+ // Try the next candidate.
1676
+ }
1677
+ }
1678
+ }
1679
+
1062
1680
  // Build system prompt: [identity] \n\n [userPrompt] \n\n [soul] \n\n [systemPrompt]
1063
1681
  const settings = loadSettings()
1064
1682
  const promptParts: string[] = []
@@ -1067,6 +1685,8 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1067
1685
  if (agent.description) identityLines.push(agent.description)
1068
1686
  identityLines.push('I should always refer to myself by this name. I am not "Assistant" — I have my own name and identity.')
1069
1687
  promptParts.push(identityLines.join(' '))
1688
+ const continuityBlock = buildIdentityContinuityContext(session as Session, agent)
1689
+ if (continuityBlock) promptParts.push(continuityBlock)
1070
1690
  if (settings.userPrompt) promptParts.push(settings.userPrompt)
1071
1691
  promptParts.push(buildCurrentDateTimePromptContext())
1072
1692
  if (agent.soul) promptParts.push(agent.soul)
@@ -1078,12 +1698,12 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1078
1698
  if (skill?.content) promptParts.push(`## Skill: ${skill.name}\n${skill.content}`)
1079
1699
  }
1080
1700
  }
1081
- const thinkLevel = typeof session.connectorThinkLevel === 'string'
1082
- ? session.connectorThinkLevel.trim().toLowerCase()
1083
- : ''
1701
+ const thinkLevel = resolveConnectorSessionPolicy(connector, msg, session).thinkingLevel || ''
1084
1702
  if (thinkLevel) {
1085
1703
  promptParts.push(`Connector thinking guidance: ${thinkLevel}. Keep responses concise and useful for chat.`)
1086
1704
  }
1705
+ const threadContextBlock = buildConnectorThreadContextBlock(msg, { isFirstThreadTurn: wasCreated })
1706
+ if (threadContextBlock) promptParts.push(threadContextBlock)
1087
1707
  // Add connector context
1088
1708
  promptParts.push(`\nYou are receiving messages via ${msg.platform}. The user "${msg.senderName}" is messaging from channel "${msg.channelName || msg.channelId}". Respond naturally and conversationally.
1089
1709
 
@@ -1128,6 +1748,9 @@ If media sending fails, report the exact error and retry with a corrected path/t
1128
1748
  channelId: msg.channelId,
1129
1749
  senderId: msg.senderId,
1130
1750
  senderName: msg.senderName,
1751
+ messageId: msg.messageId,
1752
+ replyToMessageId: msg.replyToMessageId,
1753
+ threadId: msg.threadId,
1131
1754
  }
1132
1755
  session.messages.push({
1133
1756
  role: 'user',
@@ -1139,23 +1762,23 @@ If media sending fails, report the exact error and retry with a corrected path/t
1139
1762
  source: messageSource,
1140
1763
  })
1141
1764
  session.lastActiveAt = Date.now()
1142
- const s1 = loadSessions()
1143
- s1[session.id] = session
1144
- saveSessions(s1)
1765
+ updateSessionConnectorContext(session, connector, msg, sessionKey)
1766
+ persistSessionRecord(session)
1145
1767
  notify(`messages:${session.id}`)
1146
1768
 
1147
1769
  // Stream the response
1148
1770
  let fullText = ''
1149
1771
  let mediaExtractionText = ''
1150
1772
  let connectorToolDeliveredCurrentChannel = false
1773
+ let connectorToolDeliveredMessageId: string | undefined
1151
1774
  const hasTools = session.plugins?.length && session.provider !== 'claude-cli'
1152
- console.log(`[connector] Routing message to agent "${agent.name}" (${agent.provider}/${agent.model}), hasTools=${!!hasTools}`)
1775
+ console.log(`[connector] Routing message to agent "${agent.name}" (${session.provider}/${session.model}), hasTools=${!!hasTools}`)
1153
1776
 
1154
1777
  if (hasTools) {
1155
1778
  try {
1156
1779
  const toolMediaOutputs: string[] = []
1157
1780
  const result = await streamAgentChat({
1158
- session,
1781
+ session: session as Session,
1159
1782
  message: modelInputText,
1160
1783
  imagePath: firstImagePath,
1161
1784
  attachedFiles: inboundAttachmentPaths.length ? inboundAttachmentPaths : undefined,
@@ -1180,6 +1803,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
1180
1803
  : parsed.to
1181
1804
  if (inboundTarget && outboundTarget && inboundTarget === outboundTarget) {
1182
1805
  connectorToolDeliveredCurrentChannel = true
1806
+ if (parsed.messageId) connectorToolDeliveredMessageId = parsed.messageId
1183
1807
  }
1184
1808
  }
1185
1809
  }
@@ -1202,7 +1826,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
1202
1826
  if (!provider) return '[Error] Provider not found.'
1203
1827
 
1204
1828
  await provider.handler.streamChat({
1205
- session,
1829
+ session: session as Session,
1206
1830
  message: modelInputText,
1207
1831
  imagePath: firstImagePath,
1208
1832
  apiKey,
@@ -1225,6 +1849,17 @@ If media sending fails, report the exact error and retry with a corrected path/t
1225
1849
  // If the agent chose NO_MESSAGE, skip saving it to history — the user's message
1226
1850
  // is already recorded, and saving the sentinel would pollute the LLM's context
1227
1851
  if (isNoMessage(fullText)) {
1852
+ if (connectorToolDeliveredCurrentChannel) {
1853
+ session.connectorContext = {
1854
+ ...(session.connectorContext || {}),
1855
+ lastOutboundAt: Date.now(),
1856
+ lastOutboundMessageId: connectorToolDeliveredMessageId || session.connectorContext?.lastOutboundMessageId || null,
1857
+ }
1858
+ persistSessionRecord(session)
1859
+ await maybeSendStatusReaction(connector, msg, 'sent')
1860
+ } else {
1861
+ await maybeSendStatusReaction(connector, msg, 'silent')
1862
+ }
1228
1863
  console.log(`[connector] Agent returned NO_MESSAGE — suppressing outbound reply`)
1229
1864
  logExecution(session.id, 'decision', 'Agent suppressed outbound (NO_MESSAGE)', {
1230
1865
  agentId: agent.id,
@@ -1251,13 +1886,13 @@ If media sending fails, report the exact error and retry with a corrected path/t
1251
1886
  connectorId: connector.id,
1252
1887
  connectorName: connector.name,
1253
1888
  channelId: msg.channelId,
1889
+ replyToMessageId: msg.messageId,
1890
+ threadId: msg.threadId,
1254
1891
  }
1255
1892
  if (fullText.trim()) {
1256
1893
  session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now(), source: assistantSource })
1257
1894
  session.lastActiveAt = Date.now()
1258
- const s2 = loadSessions()
1259
- s2[session.id] = session
1260
- saveSessions(s2)
1895
+ persistSessionRecord(session)
1261
1896
  notify(`messages:${session.id}`)
1262
1897
  }
1263
1898
 
@@ -1275,9 +1910,19 @@ If media sending fails, report the exact error and retry with a corrected path/t
1275
1910
  if (filesToSend.length > 0) {
1276
1911
  const inst = running.get(connector.id)
1277
1912
  if (inst?.sendMessage) {
1913
+ const replyOptions = getConnectorReplySendOptions({ connectorId: connector.id, inbound: msg })
1278
1914
  for (const file of filesToSend) {
1279
1915
  try {
1280
- await inst.sendMessage(msg.channelId, '', { mediaPath: file.path, caption: file.alt || undefined })
1916
+ await sendConnectorMessage({
1917
+ connectorId: connector.id,
1918
+ channelId: msg.channelId,
1919
+ text: '',
1920
+ sessionId: session.id,
1921
+ mediaPath: file.path,
1922
+ caption: file.alt || undefined,
1923
+ replyToMessageId: replyOptions.replyToMessageId,
1924
+ threadId: replyOptions.threadId,
1925
+ })
1281
1926
  console.log(`[connector] Sent media to ${msg.platform}: ${path.basename(file.path)}`)
1282
1927
  logExecution(session.id, 'outbound', 'Connector media sent', {
1283
1928
  agentId: agent.id,
@@ -1317,8 +1962,11 @@ If media sending fails, report the exact error and retry with a corrected path/t
1317
1962
  return extractedFromReply.cleanText || '(no response)'
1318
1963
  }
1319
1964
 
1320
- if (connectorToolDeliveredCurrentChannel) return NO_MESSAGE_SENTINEL
1321
- return fullText || '(no response)'
1965
+ if (connectorToolDeliveredCurrentChannel) return NO_MESSAGE_SENTINEL
1966
+ return fullText || '(no response)'
1967
+ } finally {
1968
+ stopTyping?.()
1969
+ }
1322
1970
  }
1323
1971
 
1324
1972
  routeMessageHandlerRef.current = routeMessage
@@ -1427,11 +2075,22 @@ export async function stopConnector(connectorId: string): Promise<void> {
1427
2075
  running.delete(connectorId)
1428
2076
  }
1429
2077
 
2078
+ for (const [debounceKey, entry] of pendingInboundDebounce.entries()) {
2079
+ if (entry.connector.id !== connectorId) continue
2080
+ clearTimeout(entry.timer)
2081
+ pendingInboundDebounce.delete(debounceKey)
2082
+ }
2083
+
1430
2084
  for (const [followupId, followup] of scheduledFollowups.entries()) {
1431
2085
  if (followup.connectorId !== connectorId) continue
1432
2086
  clearTimeout(followup.timer)
1433
2087
  scheduledFollowups.delete(followupId)
1434
2088
  }
2089
+ for (const [key, entry] of scheduledFollowupByDedupe.entries()) {
2090
+ if (!scheduledFollowups.has(entry.id)) {
2091
+ scheduledFollowupByDedupe.delete(key)
2092
+ }
2093
+ }
1435
2094
 
1436
2095
  const connectors = loadConnectors()
1437
2096
  const connector = connectors[connectorId]
@@ -1582,6 +2241,141 @@ export function getRunningInstance(connectorId: string): ConnectorInstance | und
1582
2241
  return running.get(connectorId)
1583
2242
  }
1584
2243
 
2244
+ export function getConnectorReplySendOptions(params: {
2245
+ connectorId: string
2246
+ inbound: InboundMessage
2247
+ }): { replyToMessageId?: string; threadId?: string } {
2248
+ const connectors = loadConnectors()
2249
+ const connector = connectors[params.connectorId] as Connector | undefined
2250
+ if (!connector) return {}
2251
+ const session = findDirectSessionForInbound(connector, params.inbound)
2252
+ const policy = resolveConnectorSessionPolicy(connector, params.inbound, session)
2253
+ return shouldReplyToInboundMessage({
2254
+ msg: params.inbound,
2255
+ session,
2256
+ policy,
2257
+ })
2258
+ }
2259
+
2260
+ export async function recordConnectorOutboundDelivery(params: {
2261
+ connectorId: string
2262
+ inbound: InboundMessage
2263
+ messageId?: string
2264
+ state?: 'sent' | 'silent'
2265
+ }): Promise<void> {
2266
+ const connectors = loadConnectors()
2267
+ const connector = connectors[params.connectorId] as Connector | undefined
2268
+ if (!connector) return
2269
+ const session = findDirectSessionForInbound(connector, params.inbound)
2270
+ if (session) {
2271
+ session.connectorContext = {
2272
+ ...(session.connectorContext || {}),
2273
+ lastOutboundAt: Date.now(),
2274
+ lastOutboundMessageId: params.messageId || session.connectorContext?.lastOutboundMessageId || null,
2275
+ threadId: params.inbound.threadId || session.connectorContext?.threadId || null,
2276
+ }
2277
+ const history = Array.isArray(session.messages) ? session.messages : []
2278
+ for (let i = history.length - 1; i >= 0; i -= 1) {
2279
+ const entry = history[i]
2280
+ if (entry?.role !== 'assistant') continue
2281
+ const source: Partial<MessageSource> = entry?.source || {}
2282
+ if (source.connectorId !== connector.id) continue
2283
+ if (source.channelId !== params.inbound.channelId) continue
2284
+ if (!source.messageId && params.messageId) {
2285
+ entry.source = {
2286
+ platform: source.platform || connector.platform,
2287
+ connectorId: source.connectorId || connector.id,
2288
+ connectorName: source.connectorName || connector.name,
2289
+ channelId: source.channelId || params.inbound.channelId,
2290
+ senderId: source.senderId,
2291
+ senderName: source.senderName,
2292
+ messageId: params.messageId,
2293
+ replyToMessageId: source.replyToMessageId || params.inbound.messageId,
2294
+ threadId: source.threadId || params.inbound.threadId,
2295
+ }
2296
+ }
2297
+ break
2298
+ }
2299
+ persistSessionRecord(session)
2300
+ notify(`messages:${session.id}`)
2301
+ }
2302
+ if (params.state) {
2303
+ await maybeSendStatusReaction(connector, params.inbound, params.state)
2304
+ }
2305
+ }
2306
+
2307
+ export async function performConnectorMessageAction(params: {
2308
+ connectorId?: string
2309
+ platform?: string
2310
+ channelId: string
2311
+ action: 'react' | 'edit' | 'delete' | 'pin'
2312
+ messageId?: string
2313
+ emoji?: string
2314
+ text?: string
2315
+ sessionId?: string | null
2316
+ targetMessage?: 'last_inbound' | 'last_outbound'
2317
+ }): Promise<{ connectorId: string; platform: string; channelId: string; messageId?: string }> {
2318
+ const connectors = loadConnectors()
2319
+ const requestedId = params.connectorId?.trim()
2320
+ let connector: Connector | undefined
2321
+ let connectorId: string | undefined
2322
+
2323
+ if (requestedId) {
2324
+ connector = connectors[requestedId] as Connector | undefined
2325
+ connectorId = requestedId
2326
+ if (!connector) throw new Error(`Connector not found: ${requestedId}`)
2327
+ } else {
2328
+ const candidates = Object.values(connectors) as Connector[]
2329
+ const filtered = candidates.filter((item) => (!params.platform || item.platform === params.platform) && running.has(item.id))
2330
+ if (!filtered.length) throw new Error(`No running connector found${params.platform ? ` for platform "${params.platform}"` : ''}.`)
2331
+ connector = filtered[0]
2332
+ connectorId = connector.id
2333
+ }
2334
+
2335
+ if (!connector || !connectorId) throw new Error('Connector resolution failed.')
2336
+ const instance = running.get(connectorId)
2337
+ if (!instance) throw new Error(`Connector "${connectorId}" is not running.`)
2338
+
2339
+ const targetMessageId = (() => {
2340
+ if (params.messageId?.trim()) return params.messageId.trim()
2341
+ if (!params.sessionId) return ''
2342
+ const session = loadSessions()[params.sessionId]
2343
+ if (!session) return ''
2344
+ if (params.targetMessage === 'last_inbound') return session.connectorContext?.lastInboundMessageId || ''
2345
+ if (params.targetMessage === 'last_outbound' || !params.targetMessage) return session.connectorContext?.lastOutboundMessageId || ''
2346
+ return ''
2347
+ })()
2348
+ if (!targetMessageId) throw new Error('messageId is required for connector message actions.')
2349
+
2350
+ switch (params.action) {
2351
+ case 'react':
2352
+ if (!instance.sendReaction) throw new Error(`Connector "${connector.name}" does not support reactions.`)
2353
+ if (!params.emoji?.trim()) throw new Error('emoji is required for react action.')
2354
+ await instance.sendReaction(params.channelId, targetMessageId, params.emoji.trim())
2355
+ break
2356
+ case 'edit':
2357
+ if (!instance.editMessage) throw new Error(`Connector "${connector.name}" does not support edits.`)
2358
+ if (!params.text?.trim()) throw new Error('text is required for edit action.')
2359
+ await instance.editMessage(params.channelId, targetMessageId, params.text.trim())
2360
+ break
2361
+ case 'delete':
2362
+ if (!instance.deleteMessage) throw new Error(`Connector "${connector.name}" does not support deletes.`)
2363
+ await instance.deleteMessage(params.channelId, targetMessageId)
2364
+ break
2365
+ case 'pin':
2366
+ if (!instance.pinMessage) throw new Error(`Connector "${connector.name}" does not support pinning.`)
2367
+ await instance.pinMessage(params.channelId, targetMessageId)
2368
+ break
2369
+ }
2370
+
2371
+ return {
2372
+ connectorId,
2373
+ platform: connector.platform,
2374
+ channelId: params.channelId,
2375
+ messageId: targetMessageId,
2376
+ }
2377
+ }
2378
+
1585
2379
  /**
1586
2380
  * Send an outbound message through a running connector.
1587
2381
  * Intended for proactive agent notifications (e.g. WhatsApp updates).
@@ -1591,12 +2385,15 @@ export async function sendConnectorMessage(params: {
1591
2385
  platform?: string
1592
2386
  channelId: string
1593
2387
  text: string
2388
+ sessionId?: string | null
1594
2389
  imageUrl?: string
1595
2390
  fileUrl?: string
1596
2391
  mediaPath?: string
1597
2392
  mimeType?: string
1598
2393
  fileName?: string
1599
2394
  caption?: string
2395
+ replyToMessageId?: string
2396
+ threadId?: string
1600
2397
  ptt?: boolean
1601
2398
  }): Promise<{ connectorId: string; platform: string; channelId: string; messageId?: string }> {
1602
2399
  const connectors = loadConnectors()
@@ -1650,6 +2447,8 @@ export async function sendConnectorMessage(params: {
1650
2447
  mimeType: params.mimeType,
1651
2448
  fileName: params.fileName,
1652
2449
  caption: params.caption,
2450
+ replyToMessageId: params.replyToMessageId,
2451
+ threadId: params.threadId,
1653
2452
  ptt: params.ptt,
1654
2453
  }
1655
2454
 
@@ -1668,6 +2467,41 @@ export async function sendConnectorMessage(params: {
1668
2467
  }
1669
2468
 
1670
2469
  const result = await instance.sendMessage(channelId, outboundText, outboundOptions)
2470
+ if (params.sessionId) {
2471
+ const sessions = loadSessions()
2472
+ const session = sessions[params.sessionId]
2473
+ if (session) {
2474
+ session.connectorContext = {
2475
+ ...(session.connectorContext || {}),
2476
+ connectorId,
2477
+ platform: connector.platform,
2478
+ channelId,
2479
+ threadId: params.threadId || session.connectorContext?.threadId || null,
2480
+ lastOutboundAt: Date.now(),
2481
+ lastOutboundMessageId: result?.messageId || session.connectorContext?.lastOutboundMessageId || null,
2482
+ }
2483
+ const history = Array.isArray(session.messages) ? session.messages : []
2484
+ for (let i = history.length - 1; i >= 0; i -= 1) {
2485
+ const entry = history[i]
2486
+ if (entry?.role !== 'assistant') continue
2487
+ const source: Partial<MessageSource> = entry?.source || {}
2488
+ if (source.connectorId !== connectorId) continue
2489
+ if (source.channelId !== channelId) continue
2490
+ if (!source.messageId && result?.messageId) {
2491
+ entry.source = {
2492
+ ...source,
2493
+ messageId: result.messageId,
2494
+ threadId: source.threadId || params.threadId,
2495
+ replyToMessageId: source.replyToMessageId || params.replyToMessageId,
2496
+ }
2497
+ }
2498
+ break
2499
+ }
2500
+ sessions[session.id] = session
2501
+ saveSessions(sessions)
2502
+ notify(`messages:${session.id}`)
2503
+ }
2504
+ }
1671
2505
  return {
1672
2506
  connectorId,
1673
2507
  platform: connector.platform,
@@ -1682,16 +2516,39 @@ export function scheduleConnectorFollowUp(params: {
1682
2516
  channelId: string
1683
2517
  text: string
1684
2518
  delaySec?: number
2519
+ dedupeKey?: string
2520
+ replaceExisting?: boolean
2521
+ sessionId?: string | null
1685
2522
  imageUrl?: string
1686
2523
  fileUrl?: string
1687
2524
  mediaPath?: string
1688
2525
  mimeType?: string
1689
2526
  fileName?: string
1690
2527
  caption?: string
2528
+ replyToMessageId?: string
2529
+ threadId?: string
1691
2530
  ptt?: boolean
1692
2531
  }): { followUpId: string; sendAt: number } {
1693
2532
  const delaySecRaw = Number.isFinite(params.delaySec) ? Number(params.delaySec) : 300
1694
2533
  const delayMs = Math.max(1_000, Math.min(86_400_000, Math.round(delaySecRaw * 1000)))
2534
+ const dedupeKey = params.dedupeKey || [
2535
+ params.connectorId || params.platform || '',
2536
+ params.channelId,
2537
+ params.threadId || '',
2538
+ (params.text || '').trim().slice(0, 160),
2539
+ ].join('|')
2540
+ const existing = scheduledFollowupByDedupe.get(dedupeKey)
2541
+ if (existing && existing.sendAt > Date.now() && !params.replaceExisting) {
2542
+ return { followUpId: existing.id, sendAt: existing.sendAt }
2543
+ }
2544
+ if (existing && params.replaceExisting) {
2545
+ const scheduled = scheduledFollowups.get(existing.id)
2546
+ if (scheduled) {
2547
+ clearTimeout(scheduled.timer)
2548
+ scheduledFollowups.delete(existing.id)
2549
+ }
2550
+ scheduledFollowupByDedupe.delete(dedupeKey)
2551
+ }
1695
2552
  const followUpId = genId()
1696
2553
  const sendAt = Date.now() + delayMs
1697
2554
 
@@ -1701,18 +2558,24 @@ export function scheduleConnectorFollowUp(params: {
1701
2558
  platform: params.platform,
1702
2559
  channelId: params.channelId,
1703
2560
  text: params.text,
2561
+ sessionId: params.sessionId,
1704
2562
  imageUrl: params.imageUrl,
1705
2563
  fileUrl: params.fileUrl,
1706
2564
  mediaPath: params.mediaPath,
1707
2565
  mimeType: params.mimeType,
1708
2566
  fileName: params.fileName,
1709
2567
  caption: params.caption,
2568
+ replyToMessageId: params.replyToMessageId,
2569
+ threadId: params.threadId,
1710
2570
  ptt: params.ptt,
1711
2571
  }).catch((err: unknown) => {
1712
2572
  const msg = err instanceof Error ? err.message : String(err)
1713
2573
  console.warn(`[connector] Scheduled follow-up ${followUpId} failed: ${msg}`)
1714
2574
  }).finally(() => {
1715
2575
  scheduledFollowups.delete(followUpId)
2576
+ if (scheduledFollowupByDedupe.get(dedupeKey)?.id === followUpId) {
2577
+ scheduledFollowupByDedupe.delete(dedupeKey)
2578
+ }
1716
2579
  })
1717
2580
  }, delayMs)
1718
2581
 
@@ -1724,6 +2587,7 @@ export function scheduleConnectorFollowUp(params: {
1724
2587
  sendAt,
1725
2588
  timer,
1726
2589
  })
2590
+ scheduledFollowupByDedupe.set(dedupeKey, { id: followUpId, sendAt })
1727
2591
 
1728
2592
  return { followUpId, sendAt }
1729
2593
  }