@swarmclawai/swarmclaw 0.7.1 → 0.7.3

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 (237) hide show
  1. package/README.md +155 -150
  2. package/package.json +1 -1
  3. package/src/app/api/agents/[id]/route.ts +26 -0
  4. package/src/app/api/agents/[id]/thread/route.ts +37 -9
  5. package/src/app/api/agents/route.ts +13 -2
  6. package/src/app/api/auth/route.ts +76 -7
  7. package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
  8. package/src/app/api/{sessions → chats}/[id]/browser/route.ts +5 -1
  9. package/src/app/api/{sessions → chats}/[id]/chat/route.ts +7 -3
  10. package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
  11. package/src/app/api/chats/[id]/main-loop/route.ts +13 -0
  12. package/src/app/api/{sessions → chats}/[id]/messages/route.ts +19 -13
  13. package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
  14. package/src/app/api/{sessions → chats}/[id]/route.ts +22 -52
  15. package/src/app/api/{sessions → chats}/[id]/stop/route.ts +6 -1
  16. package/src/app/api/{sessions → chats}/route.ts +21 -7
  17. package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
  18. package/src/app/api/connectors/doctor/route.ts +13 -0
  19. package/src/app/api/files/open/route.ts +16 -14
  20. package/src/app/api/memory/maintenance/route.ts +11 -1
  21. package/src/app/api/openclaw/agent-files/route.ts +27 -4
  22. package/src/app/api/openclaw/skills/route.ts +11 -3
  23. package/src/app/api/plugins/dependencies/route.ts +24 -0
  24. package/src/app/api/plugins/install/route.ts +15 -92
  25. package/src/app/api/plugins/route.ts +6 -26
  26. package/src/app/api/plugins/settings/route.ts +40 -0
  27. package/src/app/api/plugins/ui/route.ts +1 -0
  28. package/src/app/api/settings/route.ts +49 -7
  29. package/src/app/api/tasks/[id]/route.ts +15 -6
  30. package/src/app/api/tasks/bulk/route.ts +2 -2
  31. package/src/app/api/tasks/route.ts +9 -4
  32. package/src/app/api/usage/route.ts +30 -0
  33. package/src/app/api/webhooks/[id]/route.ts +8 -1
  34. package/src/app/page.tsx +9 -2
  35. package/src/cli/index.js +39 -33
  36. package/src/cli/index.ts +43 -49
  37. package/src/cli/spec.js +29 -27
  38. package/src/components/agents/agent-card.tsx +16 -13
  39. package/src/components/agents/agent-chat-list.tsx +104 -4
  40. package/src/components/agents/agent-list.tsx +54 -22
  41. package/src/components/agents/agent-sheet.tsx +209 -18
  42. package/src/components/agents/cron-job-form.tsx +3 -3
  43. package/src/components/agents/inspector-panel.tsx +110 -50
  44. package/src/components/auth/access-key-gate.tsx +36 -97
  45. package/src/components/auth/setup-wizard.tsx +5 -38
  46. package/src/components/chat/chat-area.tsx +39 -27
  47. package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +7 -23
  48. package/src/components/chat/chat-header.tsx +299 -314
  49. package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +11 -14
  50. package/src/components/chat/chat-tool-toggles.tsx +26 -17
  51. package/src/components/chat/checkpoint-timeline.tsx +4 -4
  52. package/src/components/chat/message-bubble.tsx +4 -1
  53. package/src/components/chat/message-list.tsx +5 -3
  54. package/src/components/chat/session-debug-panel.tsx +1 -1
  55. package/src/components/chat/tool-request-banner.tsx +3 -3
  56. package/src/components/chatrooms/agent-hover-card.tsx +3 -3
  57. package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
  58. package/src/components/chatrooms/chatroom-view.tsx +347 -205
  59. package/src/components/connectors/connector-list.tsx +265 -127
  60. package/src/components/connectors/connector-sheet.tsx +218 -1
  61. package/src/components/home/home-view.tsx +129 -5
  62. package/src/components/layout/app-layout.tsx +392 -182
  63. package/src/components/layout/mobile-header.tsx +26 -8
  64. package/src/components/plugins/plugin-list.tsx +487 -254
  65. package/src/components/plugins/plugin-sheet.tsx +236 -13
  66. package/src/components/projects/project-detail.tsx +183 -0
  67. package/src/components/settings/gateway-connection-panel.tsx +1 -1
  68. package/src/components/shared/agent-picker-list.tsx +2 -2
  69. package/src/components/shared/command-palette.tsx +111 -25
  70. package/src/components/shared/settings/plugin-manager.tsx +20 -4
  71. package/src/components/shared/settings/section-capability-policy.tsx +105 -0
  72. package/src/components/shared/settings/section-heartbeat.tsx +78 -1
  73. package/src/components/shared/settings/section-orchestrator.tsx +3 -3
  74. package/src/components/shared/settings/section-providers.tsx +1 -1
  75. package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
  76. package/src/components/shared/settings/section-secrets.tsx +6 -6
  77. package/src/components/shared/settings/section-user-preferences.tsx +1 -1
  78. package/src/components/shared/settings/section-voice.tsx +5 -1
  79. package/src/components/shared/settings/section-web-search.tsx +10 -2
  80. package/src/components/shared/settings/settings-page.tsx +244 -56
  81. package/src/components/tasks/approvals-panel.tsx +205 -18
  82. package/src/components/tasks/task-board.tsx +242 -46
  83. package/src/components/usage/metrics-dashboard.tsx +147 -1
  84. package/src/components/wallets/wallet-panel.tsx +17 -5
  85. package/src/components/webhooks/webhook-sheet.tsx +8 -8
  86. package/src/lib/auth.ts +17 -0
  87. package/src/lib/chat-streaming-state.test.ts +108 -0
  88. package/src/lib/chat-streaming-state.ts +108 -0
  89. package/src/lib/chat.ts +1 -1
  90. package/src/lib/{sessions.ts → chats.ts} +28 -18
  91. package/src/lib/openclaw-agent-id.test.ts +14 -0
  92. package/src/lib/openclaw-agent-id.ts +31 -0
  93. package/src/lib/providers/claude-cli.ts +1 -1
  94. package/src/lib/server/agent-assignment.test.ts +112 -0
  95. package/src/lib/server/agent-assignment.ts +169 -0
  96. package/src/lib/server/approval-connector-notify.test.ts +253 -0
  97. package/src/lib/server/approvals-auto-approve.test.ts +205 -0
  98. package/src/lib/server/approvals.ts +483 -75
  99. package/src/lib/server/autonomy-runtime.test.ts +341 -0
  100. package/src/lib/server/browser-state.test.ts +118 -0
  101. package/src/lib/server/browser-state.ts +123 -0
  102. package/src/lib/server/build-llm.test.ts +36 -0
  103. package/src/lib/server/build-llm.ts +11 -4
  104. package/src/lib/server/builtin-plugins.ts +34 -0
  105. package/src/lib/server/capability-router.ts +10 -8
  106. package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
  107. package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
  108. package/src/lib/server/chat-execution.ts +285 -165
  109. package/src/lib/server/chatroom-health.test.ts +26 -0
  110. package/src/lib/server/chatroom-health.ts +2 -3
  111. package/src/lib/server/chatroom-helpers.test.ts +67 -2
  112. package/src/lib/server/chatroom-helpers.ts +48 -8
  113. package/src/lib/server/connectors/discord.ts +175 -11
  114. package/src/lib/server/connectors/doctor.test.ts +80 -0
  115. package/src/lib/server/connectors/doctor.ts +116 -0
  116. package/src/lib/server/connectors/manager.ts +948 -112
  117. package/src/lib/server/connectors/policy.test.ts +222 -0
  118. package/src/lib/server/connectors/policy.ts +452 -0
  119. package/src/lib/server/connectors/slack.ts +188 -9
  120. package/src/lib/server/connectors/telegram.ts +65 -15
  121. package/src/lib/server/connectors/thread-context.test.ts +44 -0
  122. package/src/lib/server/connectors/thread-context.ts +72 -0
  123. package/src/lib/server/connectors/types.ts +41 -11
  124. package/src/lib/server/cost.ts +34 -1
  125. package/src/lib/server/daemon-state.ts +61 -3
  126. package/src/lib/server/data-dir.ts +13 -0
  127. package/src/lib/server/delegation-jobs.test.ts +140 -0
  128. package/src/lib/server/delegation-jobs.ts +248 -0
  129. package/src/lib/server/document-utils.test.ts +47 -0
  130. package/src/lib/server/document-utils.ts +397 -0
  131. package/src/lib/server/heartbeat-service.ts +14 -40
  132. package/src/lib/server/heartbeat-source.test.ts +22 -0
  133. package/src/lib/server/heartbeat-source.ts +7 -0
  134. package/src/lib/server/identity-continuity.test.ts +77 -0
  135. package/src/lib/server/identity-continuity.ts +127 -0
  136. package/src/lib/server/mailbox-utils.ts +347 -0
  137. package/src/lib/server/main-agent-loop.ts +28 -1103
  138. package/src/lib/server/memory-db.ts +4 -6
  139. package/src/lib/server/memory-tiers.ts +40 -0
  140. package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
  141. package/src/lib/server/openclaw-agent-resolver.ts +128 -0
  142. package/src/lib/server/openclaw-exec-config.ts +5 -6
  143. package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
  144. package/src/lib/server/openclaw-skills-normalize.ts +136 -0
  145. package/src/lib/server/openclaw-sync.ts +3 -2
  146. package/src/lib/server/orchestrator-lg.ts +20 -9
  147. package/src/lib/server/orchestrator.ts +7 -7
  148. package/src/lib/server/playwright-proxy.mjs +27 -3
  149. package/src/lib/server/plugins.test.ts +207 -0
  150. package/src/lib/server/plugins.ts +927 -66
  151. package/src/lib/server/provider-health.ts +38 -6
  152. package/src/lib/server/queue.ts +13 -28
  153. package/src/lib/server/scheduler.ts +2 -0
  154. package/src/lib/server/session-archive-memory.test.ts +85 -0
  155. package/src/lib/server/session-archive-memory.ts +230 -0
  156. package/src/lib/server/session-mailbox.ts +8 -18
  157. package/src/lib/server/session-reset-policy.test.ts +99 -0
  158. package/src/lib/server/session-reset-policy.ts +311 -0
  159. package/src/lib/server/session-run-manager.ts +33 -82
  160. package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
  161. package/src/lib/server/session-tools/calendar.ts +366 -0
  162. package/src/lib/server/session-tools/canvas.ts +1 -1
  163. package/src/lib/server/session-tools/chatroom.ts +4 -2
  164. package/src/lib/server/session-tools/connector.ts +114 -10
  165. package/src/lib/server/session-tools/context.ts +21 -5
  166. package/src/lib/server/session-tools/crawl.ts +447 -0
  167. package/src/lib/server/session-tools/crud.ts +74 -28
  168. package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
  169. package/src/lib/server/session-tools/delegate.ts +497 -24
  170. package/src/lib/server/session-tools/discovery.ts +24 -6
  171. package/src/lib/server/session-tools/document.ts +283 -0
  172. package/src/lib/server/session-tools/edit_file.ts +4 -2
  173. package/src/lib/server/session-tools/email.ts +320 -0
  174. package/src/lib/server/session-tools/extract.ts +137 -0
  175. package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
  176. package/src/lib/server/session-tools/file-send.test.ts +84 -1
  177. package/src/lib/server/session-tools/file.ts +241 -25
  178. package/src/lib/server/session-tools/git.ts +1 -1
  179. package/src/lib/server/session-tools/http.ts +1 -1
  180. package/src/lib/server/session-tools/human-loop.ts +227 -0
  181. package/src/lib/server/session-tools/image-gen.ts +380 -0
  182. package/src/lib/server/session-tools/index.ts +130 -50
  183. package/src/lib/server/session-tools/mailbox.ts +276 -0
  184. package/src/lib/server/session-tools/memory.ts +172 -3
  185. package/src/lib/server/session-tools/monitor.ts +151 -8
  186. package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
  187. package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
  188. package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
  189. package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
  190. package/src/lib/server/session-tools/platform.ts +148 -7
  191. package/src/lib/server/session-tools/plugin-creator.ts +89 -26
  192. package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
  193. package/src/lib/server/session-tools/replicate.ts +301 -0
  194. package/src/lib/server/session-tools/sample-ui.ts +1 -1
  195. package/src/lib/server/session-tools/sandbox.ts +4 -2
  196. package/src/lib/server/session-tools/schedule.ts +24 -12
  197. package/src/lib/server/session-tools/session-info.ts +43 -7
  198. package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
  199. package/src/lib/server/session-tools/shell.ts +5 -2
  200. package/src/lib/server/session-tools/subagent.ts +194 -28
  201. package/src/lib/server/session-tools/table.ts +587 -0
  202. package/src/lib/server/session-tools/wallet.ts +42 -12
  203. package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
  204. package/src/lib/server/session-tools/web.ts +926 -91
  205. package/src/lib/server/storage.ts +255 -16
  206. package/src/lib/server/stream-agent-chat.ts +116 -268
  207. package/src/lib/server/structured-extract.test.ts +72 -0
  208. package/src/lib/server/structured-extract.ts +373 -0
  209. package/src/lib/server/task-mention.test.ts +16 -2
  210. package/src/lib/server/task-mention.ts +61 -10
  211. package/src/lib/server/tool-aliases.ts +66 -18
  212. package/src/lib/server/tool-capability-policy.test.ts +9 -9
  213. package/src/lib/server/tool-capability-policy.ts +38 -27
  214. package/src/lib/server/tool-retry.ts +2 -0
  215. package/src/lib/server/watch-jobs.test.ts +173 -0
  216. package/src/lib/server/watch-jobs.ts +532 -0
  217. package/src/lib/server/ws-hub.ts +5 -3
  218. package/src/lib/tool-definitions.ts +4 -0
  219. package/src/lib/validation/schemas.test.ts +26 -0
  220. package/src/lib/validation/schemas.ts +10 -1
  221. package/src/lib/ws-client.ts +14 -12
  222. package/src/proxy.ts +5 -5
  223. package/src/stores/use-app-store.ts +5 -11
  224. package/src/stores/use-chat-store.ts +38 -9
  225. package/src/types/index.ts +352 -47
  226. package/src/app/api/sessions/[id]/main-loop/route.ts +0 -94
  227. package/src/components/sessions/new-session-sheet.tsx +0 -253
  228. package/src/lib/server/main-session.ts +0 -24
  229. package/src/lib/server/session-run-manager.test.ts +0 -23
  230. /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
  231. /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
  232. /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
  233. /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
  234. /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
  235. /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
  236. /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
  237. /package/src/app/api/{sessions → chats}/heartbeat/route.ts +0 -0
@@ -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 { 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
  }
@@ -279,6 +296,24 @@ const followupKey = '__swarmclaw_connector_followups__' as const
279
296
  const scheduledFollowups: Map<string, ScheduledConnectorFollowup> =
280
297
  g[followupKey] ?? (g[followupKey] = new Map<string, ScheduledConnectorFollowup>())
281
298
 
299
+ const inboundDedupeKey = '__swarmclaw_connector_inbound_dedupe__' as const
300
+ const recentInboundByKey: Map<string, number> =
301
+ g[inboundDedupeKey] ?? (g[inboundDedupeKey] = new Map<string, number>())
302
+
303
+ type DebouncedInboundEntry = {
304
+ connector: Connector
305
+ messages: InboundMessage[]
306
+ timer: ReturnType<typeof setTimeout>
307
+ }
308
+
309
+ const inboundDebounceKey = '__swarmclaw_connector_inbound_debounce__' as const
310
+ const pendingInboundDebounce: Map<string, DebouncedInboundEntry> =
311
+ g[inboundDebounceKey] ?? (g[inboundDebounceKey] = new Map<string, DebouncedInboundEntry>())
312
+
313
+ const followupDedupeKey = '__swarmclaw_connector_followup_dedupe__' as const
314
+ const scheduledFollowupByDedupe: Map<string, { id: string; sendAt: number }> =
315
+ g[followupDedupeKey] ?? (g[followupDedupeKey] = new Map<string, { id: string; sendAt: number }>())
316
+
282
317
  /** Reconnect state per connector — tracks backoff and retry attempts for crash recovery */
283
318
  export interface ConnectorReconnectState {
284
319
  attempts: number
@@ -308,11 +343,157 @@ function recordHealthEvent(connectorId: string, event: ConnectorHealthEventType,
308
343
  })
309
344
  }
310
345
 
346
+ function statusReactionForPlatform(platform: string, state: 'processing' | 'sent' | 'silent'): string {
347
+ if (platform === 'slack') {
348
+ if (state === 'processing') return 'eyes'
349
+ if (state === 'sent') return 'white_check_mark'
350
+ return 'zipper_mouth_face'
351
+ }
352
+ if (state === 'processing') return '👀'
353
+ if (state === 'sent') return '✅'
354
+ return '🤐'
355
+ }
356
+
357
+ function pruneTransientConnectorState(now = Date.now()): void {
358
+ for (const [key, seenAt] of recentInboundByKey.entries()) {
359
+ if (now - seenAt > 120_000) recentInboundByKey.delete(key)
360
+ }
361
+ for (const [key, entry] of scheduledFollowupByDedupe.entries()) {
362
+ if (entry.sendAt <= now) scheduledFollowupByDedupe.delete(key)
363
+ }
364
+ }
365
+
366
+ function rememberRecentInbound(key: string, now = Date.now(), ttlMs = 120_000): boolean {
367
+ pruneTransientConnectorState(now)
368
+ const previous = recentInboundByKey.get(key) || 0
369
+ if (previous && now - previous < ttlMs) return false
370
+ recentInboundByKey.set(key, now)
371
+ return true
372
+ }
373
+
374
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
375
+ function findDirectSessionForInbound(connector: Connector, msg: InboundMessage): Record<string, any> | null {
376
+ if (connector.chatroomId) return null
377
+ const effectiveAgentId = msg.agentIdOverride || connector.agentId
378
+ const sessions = Object.values(loadSessions()) as Array<Record<string, any>>
379
+ const candidates = sessions.filter((session) =>
380
+ session?.agentId === effectiveAgentId
381
+ && session?.connectorContext?.connectorId === connector.id
382
+ && session?.connectorContext?.channelId === msg.channelId,
383
+ )
384
+ if (msg.threadId) {
385
+ const threadExact = candidates.find((session) => session?.connectorContext?.threadId === msg.threadId)
386
+ if (threadExact) return threadExact
387
+ }
388
+ const senderExact = candidates.find((session) => session?.connectorContext?.senderId === msg.senderId)
389
+ if (senderExact) return senderExact
390
+ return candidates[0] || null
391
+ }
392
+
393
+ async function maybeSendStatusReaction(
394
+ connector: Connector,
395
+ msg: InboundMessage,
396
+ state: 'processing' | 'sent' | 'silent',
397
+ ): Promise<void> {
398
+ if (!msg.messageId) return
399
+ const session = findDirectSessionForInbound(connector, msg)
400
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
401
+ if (!policy.statusReactions) return
402
+ const instance = running.get(connector.id)
403
+ if (!instance?.sendReaction) return
404
+ try {
405
+ await instance.sendReaction(msg.channelId, msg.messageId, statusReactionForPlatform(connector.platform, state))
406
+ } catch {
407
+ // Ignore reaction failures — connectors vary widely here.
408
+ }
409
+ }
410
+
411
+ function startConnectorTypingLoop(connector: Connector, msg: InboundMessage): (() => void) | null {
412
+ const session = findDirectSessionForInbound(connector, msg)
413
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
414
+ if (!policy.typingIndicators) return null
415
+ const instance = running.get(connector.id)
416
+ if (!instance?.sendTyping) return null
417
+ const replyOptions = shouldReplyToInboundMessage({ msg, session, policy })
418
+
419
+ const sendTyping = () => {
420
+ void instance.sendTyping?.(msg.channelId, { threadId: replyOptions.threadId }).catch(() => {
421
+ // Best effort only.
422
+ })
423
+ }
424
+
425
+ sendTyping()
426
+ const timer = setInterval(sendTyping, 4_000)
427
+ timer.unref?.()
428
+ return () => clearInterval(timer)
429
+ }
430
+
311
431
  type RouteMessageHandler = (connector: Connector, msg: InboundMessage) => Promise<string>
312
432
  const routeHandlerKey = '__swarmclaw_connector_route_handler__' as const
313
433
  const routeMessageHandlerRef: { current: RouteMessageHandler } =
314
434
  g[routeHandlerKey] ?? (g[routeHandlerKey] = { current: async () => '[Error] Connector router unavailable.' })
315
435
 
436
+ async function flushDebouncedInbound(key: string): Promise<void> {
437
+ const entry = pendingInboundDebounce.get(key)
438
+ if (!entry) return
439
+ pendingInboundDebounce.delete(key)
440
+ clearTimeout(entry.timer)
441
+ const merged = mergeInboundMessages(entry.messages)
442
+ const response = await routeMessageHandlerRef.current(entry.connector, merged)
443
+ if (isNoMessage(response)) {
444
+ return
445
+ }
446
+ const replyOptions = getConnectorReplySendOptions({ connectorId: entry.connector.id, inbound: merged })
447
+ const session = findDirectSessionForInbound(entry.connector, merged)
448
+ await sendConnectorMessage({
449
+ connectorId: entry.connector.id,
450
+ channelId: merged.channelId,
451
+ text: response,
452
+ sessionId: session?.id,
453
+ replyToMessageId: replyOptions.replyToMessageId,
454
+ threadId: replyOptions.threadId,
455
+ })
456
+ await maybeSendStatusReaction(entry.connector, merged, 'sent')
457
+ }
458
+
459
+ async function routeOrDebounceInbound(connector: Connector, msg: InboundMessage): Promise<string> {
460
+ const dedupeKey = buildInboundDedupeKey(connector, msg)
461
+ const dedupeTtlMs = dedupeKey.startsWith('msg:') ? 120_000 : 15_000
462
+ if (!rememberRecentInbound(dedupeKey, Date.now(), dedupeTtlMs)) return NO_MESSAGE_SENTINEL
463
+
464
+ const session = findDirectSessionForInbound(connector, msg)
465
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
466
+ if (policy.inboundDebounceMs <= 0) {
467
+ return routeMessageHandlerRef.current(connector, msg)
468
+ }
469
+
470
+ const debounceKey = buildInboundDebounceKey(connector, msg)
471
+ const pending = pendingInboundDebounce.get(debounceKey)
472
+ if (pending) {
473
+ pending.messages.push(msg)
474
+ clearTimeout(pending.timer)
475
+ pending.timer = setTimeout(() => {
476
+ void flushDebouncedInbound(debounceKey).catch((err: unknown) => {
477
+ console.warn(`[connector] Debounced inbound flush failed: ${err instanceof Error ? err.message : String(err)}`)
478
+ })
479
+ }, policy.inboundDebounceMs)
480
+ pending.timer.unref?.()
481
+ } else {
482
+ const timer = setTimeout(() => {
483
+ void flushDebouncedInbound(debounceKey).catch((err: unknown) => {
484
+ console.warn(`[connector] Debounced inbound flush failed: ${err instanceof Error ? err.message : String(err)}`)
485
+ })
486
+ }, policy.inboundDebounceMs)
487
+ timer.unref?.()
488
+ pendingInboundDebounce.set(debounceKey, {
489
+ connector,
490
+ messages: [msg],
491
+ timer,
492
+ })
493
+ }
494
+ return NO_MESSAGE_SENTINEL
495
+ }
496
+
316
497
  function dispatchInboundConnectorMessage(
317
498
  connectorId: string,
318
499
  fallbackConnector: Connector,
@@ -320,7 +501,7 @@ function dispatchInboundConnectorMessage(
320
501
  ): Promise<string> {
321
502
  const connectors = loadConnectors()
322
503
  const currentConnector = connectors[connectorId] as Connector | undefined
323
- return routeMessageHandlerRef.current(currentConnector ?? fallbackConnector, msg)
504
+ return routeOrDebounceInbound(currentConnector ?? fallbackConnector, msg)
324
505
  }
325
506
 
326
507
  /** Get the current generation number for a connector (0 if never started) */
@@ -404,7 +585,17 @@ export function formatInboundUserText(msg: InboundMessage): string {
404
585
  return lines.join('\n').trim()
405
586
  }
406
587
 
407
- type ConnectorCommandName = 'help' | 'status' | 'new' | 'reset' | 'compact' | 'think' | 'pair'
588
+ type ConnectorCommandName =
589
+ | 'help'
590
+ | 'status'
591
+ | 'new'
592
+ | 'reset'
593
+ | 'compact'
594
+ | 'think'
595
+ | 'pair'
596
+ | 'session'
597
+ | 'focus'
598
+ | 'doctor'
408
599
 
409
600
  interface ParsedConnectorCommand {
410
601
  name: ConnectorCommandName
@@ -425,12 +616,301 @@ function parseConnectorCommand(text: string): ParsedConnectorCommand | null {
425
616
  case 'compact':
426
617
  case 'think':
427
618
  case 'pair':
619
+ case 'session':
620
+ case 'focus':
621
+ case 'doctor':
428
622
  return { name, args } as ParsedConnectorCommand
429
623
  default:
430
624
  return null
431
625
  }
432
626
  }
433
627
 
628
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
629
+ function persistSessionRecord(session: Record<string, any>): void {
630
+ const sessions = loadSessions()
631
+ sessions[session.id] = session
632
+ saveSessions(sessions)
633
+ }
634
+
635
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
636
+ function updateSessionConnectorContext(session: Record<string, any>, connector: Connector, msg: InboundMessage, sessionKey: string): void {
637
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
638
+ session.connectorContext = {
639
+ ...(session.connectorContext || {}),
640
+ connectorId: connector.id,
641
+ platform: connector.platform,
642
+ channelId: msg.channelId,
643
+ senderId: msg.senderId,
644
+ senderName: msg.senderName,
645
+ sessionKey,
646
+ peerKey: msg.senderId,
647
+ scope: policy.scope,
648
+ replyMode: policy.replyMode,
649
+ threadBinding: policy.threadBinding,
650
+ groupPolicy: policy.groupPolicy,
651
+ threadId: msg.threadId || session.connectorContext?.threadId || null,
652
+ threadTitle: msg.threadTitle || session.connectorContext?.threadTitle || null,
653
+ threadPersonaLabel: resolveThreadPersonaLabel(msg) || session.connectorContext?.threadPersonaLabel || null,
654
+ threadParentChannelId: msg.threadParentChannelId || session.connectorContext?.threadParentChannelId || null,
655
+ threadParentChannelName: msg.threadParentChannelName || session.connectorContext?.threadParentChannelName || null,
656
+ isGroup: !!msg.isGroup,
657
+ lastInboundAt: Date.now(),
658
+ lastInboundMessageId: msg.messageId || null,
659
+ lastInboundReplyToMessageId: msg.replyToMessageId || null,
660
+ lastInboundThreadId: msg.threadId || null,
661
+ lastOutboundAt: session.connectorContext?.lastOutboundAt ?? null,
662
+ lastOutboundMessageId: session.connectorContext?.lastOutboundMessageId ?? null,
663
+ lastResetAt: session.connectorContext?.lastResetAt ?? null,
664
+ lastResetReason: session.connectorContext?.lastResetReason ?? null,
665
+ }
666
+ }
667
+
668
+ function describeSessionControls(session: Record<string, any>, connector: Connector, msg: InboundMessage): string {
669
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
670
+ const context = session.connectorContext || {}
671
+ const sessionAgeSec = Math.max(0, Math.round((Date.now() - (session.createdAt || Date.now())) / 1000))
672
+ const idleSec = Math.max(0, Math.round((Date.now() - (session.lastActiveAt || Date.now())) / 1000))
673
+ return [
674
+ `Session controls for ${connector.platform}/${connector.name}:`,
675
+ `- Session: ${session.id}`,
676
+ `- Scope: ${policy.scope}`,
677
+ `- Reply mode: ${policy.replyMode}`,
678
+ `- Thread binding: ${policy.threadBinding}`,
679
+ `- Group policy: ${policy.groupPolicy}`,
680
+ `- Reset mode: ${policy.resetMode}`,
681
+ `- Idle timeout: ${policy.idleTimeoutSec ?? 0}s`,
682
+ `- Max age: ${policy.maxAgeSec ?? 0}s`,
683
+ `- Daily reset: ${policy.dailyResetAt || 'off'}`,
684
+ `- Reset timezone: ${policy.resetTimezone || 'local'}`,
685
+ `- Debounce: ${policy.inboundDebounceMs}ms`,
686
+ `- Typing indicators: ${policy.typingIndicators ? 'on' : 'off'}`,
687
+ `- Thinking: ${policy.thinkingLevel || session.thinkingLevel || 'inherit'}`,
688
+ `- Model: ${session.provider}/${session.model}`,
689
+ `- Last outbound message: ${context.lastOutboundMessageId || 'none'}`,
690
+ `- Thread: ${context.threadId || 'none'}`,
691
+ `- Thread title: ${context.threadTitle || 'none'}`,
692
+ `- Thread persona: ${context.threadPersonaLabel || 'none'}`,
693
+ `- Session age: ${sessionAgeSec}s`,
694
+ `- Idle for: ${idleSec}s`,
695
+ ].join('\n')
696
+ }
697
+
698
+ function normalizeSessionSettingKey(raw: string): string {
699
+ return raw.trim().toLowerCase().replace(/[_-]+/g, '')
700
+ }
701
+
702
+ function applySessionSetting(session: Record<string, any>, keyRaw: string, valueRaw: string, msg: InboundMessage): string {
703
+ const key = normalizeSessionSettingKey(keyRaw)
704
+ const value = valueRaw.trim()
705
+ const asInt = () => {
706
+ const parsed = Number.parseInt(value, 10)
707
+ if (!Number.isFinite(parsed) || parsed < 0) {
708
+ throw new Error(`Invalid numeric value for ${keyRaw}: ${valueRaw}`)
709
+ }
710
+ return parsed
711
+ }
712
+
713
+ switch (key) {
714
+ case 'think':
715
+ case 'thinkinglevel':
716
+ session.connectorThinkLevel = value || null
717
+ return `Connector thinking level set to ${session.connectorThinkLevel || 'inherit'}.`
718
+ case 'reply':
719
+ case 'replymode':
720
+ session.connectorReplyMode = value || null
721
+ return `Reply mode set to ${session.connectorReplyMode || 'inherit'}.`
722
+ case 'scope':
723
+ case 'sessionscope':
724
+ session.connectorSessionScope = value || null
725
+ return `Session scope set to ${session.connectorSessionScope || 'inherit'}.`
726
+ case 'thread':
727
+ case 'threadbinding':
728
+ session.connectorThreadBinding = value || null
729
+ if (!value) {
730
+ session.connectorContext = { ...(session.connectorContext || {}), threadId: null }
731
+ } else if (value === 'strict' && msg.threadId) {
732
+ session.connectorContext = { ...(session.connectorContext || {}), threadId: msg.threadId }
733
+ }
734
+ return `Thread binding set to ${session.connectorThreadBinding || 'inherit'}.`
735
+ case 'group':
736
+ case 'grouppolicy':
737
+ session.connectorGroupPolicy = value || null
738
+ return `Group policy set to ${session.connectorGroupPolicy || 'inherit'}.`
739
+ case 'idle':
740
+ case 'idletimeout':
741
+ session.connectorIdleTimeoutSec = asInt()
742
+ return `Idle timeout set to ${session.connectorIdleTimeoutSec}s.`
743
+ case 'maxage':
744
+ session.connectorMaxAgeSec = asInt()
745
+ return `Max age set to ${session.connectorMaxAgeSec}s.`
746
+ case 'reset':
747
+ case 'resetmode': {
748
+ const normalized = value.toLowerCase()
749
+ if (!value) {
750
+ session.sessionResetMode = null
751
+ return 'Reset mode set to inherit.'
752
+ }
753
+ if (normalized !== 'idle' && normalized !== 'daily') {
754
+ throw new Error('Reset mode must be "idle" or "daily".')
755
+ }
756
+ session.sessionResetMode = normalized
757
+ return `Reset mode set to ${session.sessionResetMode}.`
758
+ }
759
+ case 'daily':
760
+ case 'dailyreset':
761
+ case 'dailyresetat':
762
+ if (!value) {
763
+ session.sessionDailyResetAt = null
764
+ return 'Daily reset time cleared.'
765
+ }
766
+ if (!/^\d{1,2}:\d{2}$/.test(value)) {
767
+ throw new Error('Daily reset time must be in HH:MM format.')
768
+ }
769
+ session.sessionDailyResetAt = value
770
+ return `Daily reset time set to ${session.sessionDailyResetAt}.`
771
+ case 'timezone':
772
+ case 'resettimezone':
773
+ session.sessionResetTimezone = value || null
774
+ return `Reset timezone set to ${session.sessionResetTimezone || 'inherit/local'}.`
775
+ case 'model':
776
+ session.model = value
777
+ return `Model set to ${session.model}.`
778
+ case 'provider':
779
+ session.provider = value
780
+ session.apiEndpoint = getProvider(value)?.defaultEndpoint || session.apiEndpoint || null
781
+ return `Provider set to ${session.provider}.`
782
+ default:
783
+ throw new Error(`Unknown session setting "${keyRaw}".`)
784
+ }
785
+ }
786
+
787
+ function evaluateGroupPolicy(params: {
788
+ connector: Connector
789
+ msg: InboundMessage
790
+ session?: Record<string, any> | null
791
+ aliases: string[]
792
+ }): { allowed: boolean; reason: string } {
793
+ const { connector, msg, session, aliases } = params
794
+ if (!msg.isGroup) return { allowed: true, reason: 'dm' }
795
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
796
+ if (policy.groupPolicy === 'open') return { allowed: true, reason: 'open' }
797
+ if (policy.groupPolicy === 'disabled') return { allowed: false, reason: 'disabled' }
798
+ const mentioned = !!msg.mentionsBot || textMentionsAlias(msg.text || '', aliases)
799
+ const replied = isReplyToLastOutbound(msg, session)
800
+ if (policy.groupPolicy === 'mention') {
801
+ return { allowed: mentioned, reason: mentioned ? 'mentioned' : 'mention_required' }
802
+ }
803
+ const allowed = mentioned || replied
804
+ return { allowed, reason: allowed ? (mentioned ? 'mentioned' : 'reply') : 'reply_or_mention_required' }
805
+ }
806
+
807
+ function applyConnectorRuntimeDefaults(session: Record<string, any>, defaults: {
808
+ provider: string
809
+ model: string
810
+ apiEndpoint: string | null
811
+ thinkingLevel: string | null
812
+ }): void {
813
+ session.provider = defaults.provider
814
+ session.model = defaults.model
815
+ session.apiEndpoint = defaults.apiEndpoint
816
+ session.connectorThinkLevel = defaults.thinkingLevel
817
+ }
818
+
819
+ function resolveDirectSession(params: {
820
+ connector: Connector
821
+ msg: InboundMessage
822
+ agent: Record<string, any>
823
+ }): { session: Record<string, any>; sessionKey: string; wasCreated: boolean; staleReason?: string | null; clearedMessages?: number } {
824
+ const { connector, msg, agent } = params
825
+ const policySeed = resolveConnectorSessionPolicy(connector, msg)
826
+ const providerInfo = policySeed.providerOverride ? getProvider(policySeed.providerOverride) : null
827
+ const defaultProvider = policySeed.providerOverride || (agent.provider === 'claude-cli' ? 'anthropic' : agent.provider)
828
+ const defaultModel = policySeed.modelOverride || agent.model
829
+ const defaultApiEndpoint = agent.apiEndpoint || providerInfo?.defaultEndpoint || null
830
+ const runtimeDefaults = {
831
+ provider: defaultProvider,
832
+ model: defaultModel,
833
+ apiEndpoint: defaultApiEndpoint,
834
+ thinkingLevel: policySeed.thinkingLevel || null,
835
+ }
836
+ const sessionKey = buildConnectorConversationKey({
837
+ connector,
838
+ msg,
839
+ agentId: agent.id,
840
+ policy: policySeed,
841
+ })
842
+ const sessions = loadSessions()
843
+ let session = Object.values(sessions).find((item: any) => item?.name === sessionKey) as Record<string, any> | undefined
844
+ let wasCreated = false
845
+ if (!session) {
846
+ const id = genId()
847
+ session = {
848
+ id,
849
+ name: sessionKey,
850
+ cwd: WORKSPACE_DIR,
851
+ user: 'connector',
852
+ provider: defaultProvider,
853
+ model: defaultModel,
854
+ credentialId: agent.credentialId || null,
855
+ fallbackCredentialIds: Array.isArray(agent.fallbackCredentialIds) ? [...agent.fallbackCredentialIds] : [],
856
+ apiEndpoint: defaultApiEndpoint,
857
+ claudeSessionId: null,
858
+ codexThreadId: null,
859
+ opencodeSessionId: null,
860
+ delegateResumeIds: {
861
+ claudeCode: null,
862
+ codex: null,
863
+ opencode: null,
864
+ gemini: null,
865
+ },
866
+ messages: [],
867
+ createdAt: Date.now(),
868
+ lastActiveAt: Date.now(),
869
+ sessionType: 'human' as const,
870
+ agentId: agent.id,
871
+ plugins: agent.plugins || agent.tools || [],
872
+ thinkingLevel: agent.thinkingLevel || null,
873
+ connectorThinkLevel: policySeed.thinkingLevel || null,
874
+ }
875
+ wasCreated = true
876
+ }
877
+ session.name = sessionKey
878
+ session.agentId = agent.id
879
+ session.plugins = Array.isArray(session.plugins) ? session.plugins : (agent.plugins || agent.tools || [])
880
+ if (!session.provider) session.provider = defaultProvider
881
+ if (!session.model) session.model = defaultModel
882
+ if (session.credentialId === undefined) session.credentialId = agent.credentialId || null
883
+ if (!Array.isArray(session.fallbackCredentialIds) && Array.isArray(agent.fallbackCredentialIds)) {
884
+ session.fallbackCredentialIds = [...agent.fallbackCredentialIds]
885
+ }
886
+ if (session.apiEndpoint === undefined || session.apiEndpoint === null) session.apiEndpoint = defaultApiEndpoint
887
+ if ((session.connectorThinkLevel === undefined || session.connectorThinkLevel === null) && policySeed.thinkingLevel) {
888
+ session.connectorThinkLevel = policySeed.thinkingLevel
889
+ }
890
+
891
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
892
+ const staleness = getConnectorSessionStaleness(session, policy)
893
+ let clearedMessages = 0
894
+ if (staleness.stale) {
895
+ try { syncSessionArchiveMemory(session as any, { agent }) } catch { /* archive sync is best-effort */ }
896
+ clearedMessages = resetConnectorSessionRuntime(session as any, staleness.reason || 'session_refresh')
897
+ applyConnectorRuntimeDefaults(session, {
898
+ ...runtimeDefaults,
899
+ thinkingLevel: policySeed.thinkingLevel || session.connectorThinkLevel || null,
900
+ })
901
+ }
902
+ updateSessionConnectorContext(session, connector, msg, sessionKey)
903
+ sessions[session.id] = session
904
+ saveSessions(sessions)
905
+ return {
906
+ session,
907
+ sessionKey,
908
+ wasCreated,
909
+ staleReason: staleness.reason || null,
910
+ clearedMessages,
911
+ }
912
+ }
913
+
434
914
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
435
915
  function pushSessionMessage(session: Record<string, any>, role: 'user' | 'assistant', text: string): void {
436
916
  if (!text.trim()) return
@@ -609,6 +1089,10 @@ async function handleConnectorCommand(params: {
609
1089
  '/new or /reset — Clear this connector conversation thread',
610
1090
  '/compact [keepLastN] — Summarize older history and keep recent messages (default 10)',
611
1091
  '/think <minimal|low|medium|high> — Set connector thread reasoning guidance',
1092
+ '/session — Show session controls',
1093
+ '/session set <scope|reply|thread|group|idle|maxAge|resetMode|dailyResetAt|timezone|think|model|provider> <value> — Patch this connector session',
1094
+ '/focus here|clear — Bind or clear focus on the current thread/topic',
1095
+ '/doctor — Show autonomy and safety warnings for this connector/session',
612
1096
  '/pair — Pairing/access controls (status, request, list, approve, allow)',
613
1097
  '/help — Show this list',
614
1098
  ].join('\n')
@@ -619,10 +1103,11 @@ async function handleConnectorCommand(params: {
619
1103
  }
620
1104
 
621
1105
  if (command.name === 'status') {
1106
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
622
1107
  const all = Array.isArray(session.messages) ? session.messages : []
623
1108
  const userCount = all.filter((m: { role?: string }) => m?.role === 'user').length
624
1109
  const assistantCount = all.filter((m: { role?: string }) => m?.role === 'assistant').length
625
- const toolsCount = Array.isArray(session.tools) ? session.tools.length : 0
1110
+ const toolsCount = Array.isArray(session.plugins) ? session.plugins.length : 0
626
1111
  const statusText = [
627
1112
  `Status for ${connector.platform} / ${connector.name}:`,
628
1113
  `- Agent: ${agentName}`,
@@ -632,6 +1117,9 @@ async function handleConnectorCommand(params: {
632
1117
  `- Tools enabled: ${toolsCount}`,
633
1118
  `- Channel: ${msg.channelName || msg.channelId}`,
634
1119
  `- Last active: ${new Date(session.lastActiveAt || session.createdAt || Date.now()).toLocaleString()}`,
1120
+ `- Reset mode: ${policy.resetMode}`,
1121
+ `- Reply mode: ${policy.replyMode}`,
1122
+ `- Scope: ${policy.scope}`,
635
1123
  ].join('\n')
636
1124
  pushSessionMessage(session, 'user', inboundText)
637
1125
  pushSessionMessage(session, 'assistant', statusText)
@@ -640,13 +1128,17 @@ async function handleConnectorCommand(params: {
640
1128
  }
641
1129
 
642
1130
  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 }
649
- session.lastActiveAt = Date.now()
1131
+ try { syncSessionArchiveMemory(session as any, { agent: loadAgents()[session.agentId] }) } catch { /* best effort */ }
1132
+ const cleared = resetConnectorSessionRuntime(session as any, 'manual_reset')
1133
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
1134
+ const providerInfo = policy.providerOverride ? getProvider(policy.providerOverride) : null
1135
+ applyConnectorRuntimeDefaults(session, {
1136
+ provider: policy.providerOverride || session.provider,
1137
+ model: policy.modelOverride || session.model,
1138
+ apiEndpoint: providerInfo?.defaultEndpoint || session.apiEndpoint || null,
1139
+ thinkingLevel: policy.thinkingLevel || session.connectorThinkLevel || null,
1140
+ })
1141
+ updateSessionConnectorContext(session, connector, msg, session.name || session.id)
650
1142
  persistSession(session)
651
1143
  return `Reset complete for ${connector.platform} channel thread. Cleared ${cleared} message(s).`
652
1144
  }
@@ -683,8 +1175,9 @@ async function handleConnectorCommand(params: {
683
1175
  const requested = command.args.trim().toLowerCase()
684
1176
  const allowed = new Set(['minimal', 'low', 'medium', 'high'])
685
1177
  if (!requested) {
686
- const current = typeof session.connectorThinkLevel === 'string' && allowed.has(session.connectorThinkLevel)
687
- ? session.connectorThinkLevel
1178
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
1179
+ const current = typeof policy.thinkingLevel === 'string' && allowed.has(policy.thinkingLevel)
1180
+ ? policy.thinkingLevel
688
1181
  : 'medium'
689
1182
  const text = `Current /think level: ${current}. Usage: /think <minimal|low|medium|high>.`
690
1183
  pushSessionMessage(session, 'user', inboundText)
@@ -708,6 +1201,77 @@ async function handleConnectorCommand(params: {
708
1201
  return text
709
1202
  }
710
1203
 
1204
+ if (command.name === 'doctor') {
1205
+ const warnings = buildConnectorDoctorWarnings({ connector, msg, session })
1206
+ const text = warnings.length
1207
+ ? ['Connector doctor:', ...warnings.map((item) => `- ${item}`)].join('\n')
1208
+ : 'Connector doctor: no obvious autonomy or safety issues detected.'
1209
+ pushSessionMessage(session, 'user', inboundText)
1210
+ pushSessionMessage(session, 'assistant', text)
1211
+ persistSession(session)
1212
+ return text
1213
+ }
1214
+
1215
+ if (command.name === 'session') {
1216
+ const parts = command.args.split(/\s+/).map((item) => item.trim()).filter(Boolean)
1217
+ if (!parts.length || parts[0].toLowerCase() === 'show' || parts[0].toLowerCase() === 'status') {
1218
+ const text = describeSessionControls(session, connector, msg)
1219
+ pushSessionMessage(session, 'user', inboundText)
1220
+ pushSessionMessage(session, 'assistant', text)
1221
+ persistSession(session)
1222
+ return text
1223
+ }
1224
+ if (parts[0].toLowerCase() === 'reset') {
1225
+ try { syncSessionArchiveMemory(session as any, { agent: loadAgents()[session.agentId] }) } catch { /* best effort */ }
1226
+ const cleared = resetConnectorSessionRuntime(session as any, 'manual_reset')
1227
+ const policy = resolveConnectorSessionPolicy(connector, msg, session)
1228
+ const providerInfo = policy.providerOverride ? getProvider(policy.providerOverride) : null
1229
+ applyConnectorRuntimeDefaults(session, {
1230
+ provider: policy.providerOverride || session.provider,
1231
+ model: policy.modelOverride || session.model,
1232
+ apiEndpoint: providerInfo?.defaultEndpoint || session.apiEndpoint || null,
1233
+ thinkingLevel: policy.thinkingLevel || session.connectorThinkLevel || null,
1234
+ })
1235
+ updateSessionConnectorContext(session, connector, msg, session.name || session.id)
1236
+ persistSession(session)
1237
+ return `Connector session reset. Cleared ${cleared} message(s).`
1238
+ }
1239
+ if (parts[0].toLowerCase() === 'set') {
1240
+ const key = parts[1] || ''
1241
+ const value = parts.slice(2).join(' ').trim()
1242
+ if (!key) return 'Usage: /session set <scope|reply|thread|group|idle|maxAge|resetMode|dailyResetAt|timezone|think|model|provider> <value>'
1243
+ try {
1244
+ const text = applySessionSetting(session, key, value, msg)
1245
+ updateSessionConnectorContext(session, connector, msg, session.name || session.id)
1246
+ persistSession(session)
1247
+ return text
1248
+ } catch (err: unknown) {
1249
+ return err instanceof Error ? err.message : String(err)
1250
+ }
1251
+ }
1252
+ return 'Usage: /session, /session show, /session set <key> <value>, /session reset'
1253
+ }
1254
+
1255
+ if (command.name === 'focus') {
1256
+ const subcommand = command.args.trim().toLowerCase()
1257
+ if (subcommand === 'clear') {
1258
+ session.connectorThreadBinding = null
1259
+ session.connectorSessionScope = null
1260
+ session.connectorContext = { ...(session.connectorContext || {}), threadId: null }
1261
+ persistSession(session)
1262
+ return 'Cleared connector thread focus.'
1263
+ }
1264
+ if (!msg.threadId) {
1265
+ return 'Focus can only be set from a threaded or topic-bound message.'
1266
+ }
1267
+ session.connectorThreadBinding = 'strict'
1268
+ session.connectorSessionScope = 'thread'
1269
+ session.connectorReplyMode = session.connectorReplyMode || 'all'
1270
+ session.connectorContext = { ...(session.connectorContext || {}), threadId: msg.threadId }
1271
+ persistSession(session)
1272
+ return `Focused this connector session on thread ${msg.threadId}.`
1273
+ }
1274
+
711
1275
  return 'Unknown command.'
712
1276
  }
713
1277
 
@@ -721,6 +1285,9 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
721
1285
  if (!chatroom) return '[Error] Chatroom not found.'
722
1286
 
723
1287
  const agents = loadAgents()
1288
+ const chatroomAgentAliases = chatroom.agentIds
1289
+ .map((agentId) => agents[agentId]?.name)
1290
+ .filter((name): name is string => typeof name === 'string' && !!name.trim())
724
1291
  const preferredCredentialId = (() => {
725
1292
  if (connector.agentId && agents[connector.agentId]?.credentialId) {
726
1293
  return agents[connector.agentId].credentialId as string
@@ -735,6 +1302,16 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
735
1302
  msg,
736
1303
  preferredCredentialId,
737
1304
  })
1305
+ const groupGate = evaluateGroupPolicy({
1306
+ connector,
1307
+ msg,
1308
+ aliases: [connector.name, ...chatroomAgentAliases],
1309
+ })
1310
+ if (!groupGate.allowed) return NO_MESSAGE_SENTINEL
1311
+
1312
+ await maybeSendStatusReaction(connector, msg, 'processing')
1313
+ const stopTyping = startConnectorTypingLoop(connector, msg)
1314
+ try {
738
1315
 
739
1316
  const source: MessageSource = {
740
1317
  platform: connector.platform,
@@ -743,10 +1320,14 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
743
1320
  channelId: msg.channelId,
744
1321
  senderId: msg.senderId,
745
1322
  senderName: msg.senderName,
1323
+ messageId: msg.messageId,
1324
+ replyToMessageId: msg.replyToMessageId,
1325
+ threadId: msg.threadId,
746
1326
  }
747
1327
  const inboundText = formatInboundUserText(msg)
748
1328
  const inboundAttachmentPaths = buildInboundAttachmentPaths(msg)
749
1329
  const firstImagePath = msg.media?.find((m) => m.type === 'image')?.localPath
1330
+ const threadContextBlock = buildConnectorThreadContextBlock(msg)
750
1331
 
751
1332
  // Parse mentions from the message text
752
1333
  let mentions = parseMentions(msg.text || '', agents, chatroom.agentIds)
@@ -824,7 +1405,7 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
824
1405
  const syntheticSession = buildSyntheticSession(agent, chatroomId)
825
1406
  const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent)
826
1407
  const chatroomContext = buildChatroomSystemPrompt(freshChatroom, agents, agent.id)
827
- const fullSystemPrompt = [agentSystemPrompt, chatroomContext].filter(Boolean).join('\n\n')
1408
+ const fullSystemPrompt = [agentSystemPrompt, chatroomContext, threadContextBlock].filter(Boolean).join('\n\n')
828
1409
  const history = buildHistoryForAgent(freshChatroom, agent.id)
829
1410
 
830
1411
  try {
@@ -882,7 +1463,10 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
882
1463
  }
883
1464
  }
884
1465
 
885
- if (responses.length === 0) return NO_MESSAGE_SENTINEL
1466
+ if (responses.length === 0) {
1467
+ await maybeSendStatusReaction(connector, msg, 'silent')
1468
+ return NO_MESSAGE_SENTINEL
1469
+ }
886
1470
 
887
1471
  const joined = responses.join('\n\n')
888
1472
  // Extract embedded media from agent responses and send them via connector
@@ -891,9 +1475,18 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
891
1475
  if (filesToSend.length > 0) {
892
1476
  const inst = running.get(connector.id)
893
1477
  if (inst?.sendMessage) {
1478
+ const replyOptions = getConnectorReplySendOptions({ connectorId: connector.id, inbound: msg })
894
1479
  for (const file of filesToSend) {
895
1480
  try {
896
- await inst.sendMessage(msg.channelId, '', { mediaPath: file.path, caption: file.alt || undefined })
1481
+ await sendConnectorMessage({
1482
+ connectorId: connector.id,
1483
+ channelId: msg.channelId,
1484
+ text: '',
1485
+ mediaPath: file.path,
1486
+ caption: file.alt || undefined,
1487
+ replyToMessageId: replyOptions.replyToMessageId,
1488
+ threadId: replyOptions.threadId,
1489
+ })
897
1490
  console.log(`[connector] Sent chatroom media to ${msg.platform}: ${path.basename(file.path)}`)
898
1491
  } catch (err: unknown) {
899
1492
  console.error(`[connector] Failed to send chatroom media ${path.basename(file.path)}:`, err instanceof Error ? err.message : String(err))
@@ -903,6 +1496,9 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
903
1496
  return extracted.cleanText || '(no response)'
904
1497
  }
905
1498
  return joined
1499
+ } finally {
1500
+ stopTyping?.()
1501
+ }
906
1502
  }
907
1503
 
908
1504
  /** Route an inbound message through the assigned agent and return the response */
@@ -927,84 +1523,11 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
927
1523
  preferredCredentialId: agent.credentialId || null,
928
1524
  })
929
1525
 
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
- tools: agent.tools || [],
1004
- }
1005
- sessions[id] = session
1006
- saveSessions(sessions)
1007
- }
1526
+ const { session, sessionKey, wasCreated, staleReason, clearedMessages } = resolveDirectSession({
1527
+ connector,
1528
+ msg,
1529
+ agent,
1530
+ })
1008
1531
 
1009
1532
  const parsedCommand = parseConnectorCommand(msg.text || '')
1010
1533
  if (parsedCommand?.name === 'pair') {
@@ -1039,6 +1562,26 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1039
1562
  return accessPolicyResult
1040
1563
  }
1041
1564
 
1565
+ const groupGate = evaluateGroupPolicy({
1566
+ connector,
1567
+ msg,
1568
+ session,
1569
+ aliases: [agent.name, connector.name],
1570
+ })
1571
+ if (!groupGate.allowed) {
1572
+ logExecution(session.id, 'decision', 'Connector inbound blocked by group policy', {
1573
+ agentId: agent.id,
1574
+ detail: {
1575
+ platform: msg.platform,
1576
+ channelId: msg.channelId,
1577
+ senderId: msg.senderId,
1578
+ groupPolicy: resolveConnectorSessionPolicy(connector, msg, session).groupPolicy,
1579
+ reason: groupGate.reason,
1580
+ },
1581
+ })
1582
+ return NO_MESSAGE_SENTINEL
1583
+ }
1584
+
1042
1585
  if (parsedCommand) {
1043
1586
  const commandResult = await handleConnectorCommand({
1044
1587
  command: parsedCommand,
@@ -1059,6 +1602,58 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1059
1602
  return commandResult
1060
1603
  }
1061
1604
 
1605
+ await maybeSendStatusReaction(connector, msg, 'processing')
1606
+ const stopTyping = startConnectorTypingLoop(connector, msg)
1607
+ try {
1608
+ // Enqueue system event + heartbeat wake for the agent only after access/gating checks pass.
1609
+ const preview = (msg.text || '').slice(0, 80)
1610
+ enqueueSystemEvent(
1611
+ sessionKey,
1612
+ `Inbound message from ${msg.platform}: ${preview}`,
1613
+ 'connector-message',
1614
+ )
1615
+ requestHeartbeatNow({ agentId: effectiveAgentId, reason: 'connector-message' })
1616
+
1617
+ logExecution(session.id, 'trigger', `${msg.platform} message from ${msg.senderName}`, {
1618
+ agentId: agent.id,
1619
+ detail: {
1620
+ source: 'connector',
1621
+ platform: msg.platform,
1622
+ connectorId: connector.id,
1623
+ channelId: msg.channelId,
1624
+ senderName: msg.senderName,
1625
+ sessionKey,
1626
+ messagePreview: (msg.text || '').slice(0, 200),
1627
+ hasMedia: !!(msg.media?.length || msg.imageUrl),
1628
+ staleReason: staleReason || null,
1629
+ clearedMessages: clearedMessages || 0,
1630
+ },
1631
+ })
1632
+
1633
+ // Resolve API key for the effective session provider, preferring matching fallback credentials.
1634
+ let apiKey: string | null = null
1635
+ const sessionCredentialIds = [
1636
+ session.credentialId,
1637
+ ...(Array.isArray(session.fallbackCredentialIds) ? session.fallbackCredentialIds : []),
1638
+ ].filter(Boolean) as string[]
1639
+ if (sessionCredentialIds.length > 0) {
1640
+ const creds = loadCredentials()
1641
+ const matching = sessionCredentialIds.find((credentialId) => creds[credentialId]?.provider === session.provider)
1642
+ const ordered = matching
1643
+ ? [matching, ...sessionCredentialIds.filter((credentialId) => credentialId !== matching)]
1644
+ : sessionCredentialIds
1645
+ for (const credentialId of ordered) {
1646
+ const cred = creds[credentialId]
1647
+ if (!cred?.encryptedKey) continue
1648
+ try {
1649
+ apiKey = decryptKey(cred.encryptedKey)
1650
+ break
1651
+ } catch {
1652
+ // Try the next candidate.
1653
+ }
1654
+ }
1655
+ }
1656
+
1062
1657
  // Build system prompt: [identity] \n\n [userPrompt] \n\n [soul] \n\n [systemPrompt]
1063
1658
  const settings = loadSettings()
1064
1659
  const promptParts: string[] = []
@@ -1067,6 +1662,8 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1067
1662
  if (agent.description) identityLines.push(agent.description)
1068
1663
  identityLines.push('I should always refer to myself by this name. I am not "Assistant" — I have my own name and identity.')
1069
1664
  promptParts.push(identityLines.join(' '))
1665
+ const continuityBlock = buildIdentityContinuityContext(session as Session, agent)
1666
+ if (continuityBlock) promptParts.push(continuityBlock)
1070
1667
  if (settings.userPrompt) promptParts.push(settings.userPrompt)
1071
1668
  promptParts.push(buildCurrentDateTimePromptContext())
1072
1669
  if (agent.soul) promptParts.push(agent.soul)
@@ -1078,12 +1675,12 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
1078
1675
  if (skill?.content) promptParts.push(`## Skill: ${skill.name}\n${skill.content}`)
1079
1676
  }
1080
1677
  }
1081
- const thinkLevel = typeof session.connectorThinkLevel === 'string'
1082
- ? session.connectorThinkLevel.trim().toLowerCase()
1083
- : ''
1678
+ const thinkLevel = resolveConnectorSessionPolicy(connector, msg, session).thinkingLevel || ''
1084
1679
  if (thinkLevel) {
1085
1680
  promptParts.push(`Connector thinking guidance: ${thinkLevel}. Keep responses concise and useful for chat.`)
1086
1681
  }
1682
+ const threadContextBlock = buildConnectorThreadContextBlock(msg, { isFirstThreadTurn: wasCreated })
1683
+ if (threadContextBlock) promptParts.push(threadContextBlock)
1087
1684
  // Add connector context
1088
1685
  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
1686
 
@@ -1128,6 +1725,9 @@ If media sending fails, report the exact error and retry with a corrected path/t
1128
1725
  channelId: msg.channelId,
1129
1726
  senderId: msg.senderId,
1130
1727
  senderName: msg.senderName,
1728
+ messageId: msg.messageId,
1729
+ replyToMessageId: msg.replyToMessageId,
1730
+ threadId: msg.threadId,
1131
1731
  }
1132
1732
  session.messages.push({
1133
1733
  role: 'user',
@@ -1139,23 +1739,23 @@ If media sending fails, report the exact error and retry with a corrected path/t
1139
1739
  source: messageSource,
1140
1740
  })
1141
1741
  session.lastActiveAt = Date.now()
1142
- const s1 = loadSessions()
1143
- s1[session.id] = session
1144
- saveSessions(s1)
1742
+ updateSessionConnectorContext(session, connector, msg, sessionKey)
1743
+ persistSessionRecord(session)
1145
1744
  notify(`messages:${session.id}`)
1146
1745
 
1147
1746
  // Stream the response
1148
1747
  let fullText = ''
1149
1748
  let mediaExtractionText = ''
1150
1749
  let connectorToolDeliveredCurrentChannel = false
1151
- const hasTools = session.tools?.length && session.provider !== 'claude-cli'
1152
- console.log(`[connector] Routing message to agent "${agent.name}" (${agent.provider}/${agent.model}), hasTools=${!!hasTools}`)
1750
+ let connectorToolDeliveredMessageId: string | undefined
1751
+ const hasTools = session.plugins?.length && session.provider !== 'claude-cli'
1752
+ console.log(`[connector] Routing message to agent "${agent.name}" (${session.provider}/${session.model}), hasTools=${!!hasTools}`)
1153
1753
 
1154
1754
  if (hasTools) {
1155
1755
  try {
1156
1756
  const toolMediaOutputs: string[] = []
1157
1757
  const result = await streamAgentChat({
1158
- session,
1758
+ session: session as Session,
1159
1759
  message: modelInputText,
1160
1760
  imagePath: firstImagePath,
1161
1761
  attachedFiles: inboundAttachmentPaths.length ? inboundAttachmentPaths : undefined,
@@ -1180,6 +1780,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
1180
1780
  : parsed.to
1181
1781
  if (inboundTarget && outboundTarget && inboundTarget === outboundTarget) {
1182
1782
  connectorToolDeliveredCurrentChannel = true
1783
+ if (parsed.messageId) connectorToolDeliveredMessageId = parsed.messageId
1183
1784
  }
1184
1785
  }
1185
1786
  }
@@ -1202,7 +1803,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
1202
1803
  if (!provider) return '[Error] Provider not found.'
1203
1804
 
1204
1805
  await provider.handler.streamChat({
1205
- session,
1806
+ session: session as Session,
1206
1807
  message: modelInputText,
1207
1808
  imagePath: firstImagePath,
1208
1809
  apiKey,
@@ -1225,6 +1826,17 @@ If media sending fails, report the exact error and retry with a corrected path/t
1225
1826
  // If the agent chose NO_MESSAGE, skip saving it to history — the user's message
1226
1827
  // is already recorded, and saving the sentinel would pollute the LLM's context
1227
1828
  if (isNoMessage(fullText)) {
1829
+ if (connectorToolDeliveredCurrentChannel) {
1830
+ session.connectorContext = {
1831
+ ...(session.connectorContext || {}),
1832
+ lastOutboundAt: Date.now(),
1833
+ lastOutboundMessageId: connectorToolDeliveredMessageId || session.connectorContext?.lastOutboundMessageId || null,
1834
+ }
1835
+ persistSessionRecord(session)
1836
+ await maybeSendStatusReaction(connector, msg, 'sent')
1837
+ } else {
1838
+ await maybeSendStatusReaction(connector, msg, 'silent')
1839
+ }
1228
1840
  console.log(`[connector] Agent returned NO_MESSAGE — suppressing outbound reply`)
1229
1841
  logExecution(session.id, 'decision', 'Agent suppressed outbound (NO_MESSAGE)', {
1230
1842
  agentId: agent.id,
@@ -1251,13 +1863,13 @@ If media sending fails, report the exact error and retry with a corrected path/t
1251
1863
  connectorId: connector.id,
1252
1864
  connectorName: connector.name,
1253
1865
  channelId: msg.channelId,
1866
+ replyToMessageId: msg.messageId,
1867
+ threadId: msg.threadId,
1254
1868
  }
1255
1869
  if (fullText.trim()) {
1256
1870
  session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now(), source: assistantSource })
1257
1871
  session.lastActiveAt = Date.now()
1258
- const s2 = loadSessions()
1259
- s2[session.id] = session
1260
- saveSessions(s2)
1872
+ persistSessionRecord(session)
1261
1873
  notify(`messages:${session.id}`)
1262
1874
  }
1263
1875
 
@@ -1275,9 +1887,19 @@ If media sending fails, report the exact error and retry with a corrected path/t
1275
1887
  if (filesToSend.length > 0) {
1276
1888
  const inst = running.get(connector.id)
1277
1889
  if (inst?.sendMessage) {
1890
+ const replyOptions = getConnectorReplySendOptions({ connectorId: connector.id, inbound: msg })
1278
1891
  for (const file of filesToSend) {
1279
1892
  try {
1280
- await inst.sendMessage(msg.channelId, '', { mediaPath: file.path, caption: file.alt || undefined })
1893
+ await sendConnectorMessage({
1894
+ connectorId: connector.id,
1895
+ channelId: msg.channelId,
1896
+ text: '',
1897
+ sessionId: session.id,
1898
+ mediaPath: file.path,
1899
+ caption: file.alt || undefined,
1900
+ replyToMessageId: replyOptions.replyToMessageId,
1901
+ threadId: replyOptions.threadId,
1902
+ })
1281
1903
  console.log(`[connector] Sent media to ${msg.platform}: ${path.basename(file.path)}`)
1282
1904
  logExecution(session.id, 'outbound', 'Connector media sent', {
1283
1905
  agentId: agent.id,
@@ -1317,8 +1939,11 @@ If media sending fails, report the exact error and retry with a corrected path/t
1317
1939
  return extractedFromReply.cleanText || '(no response)'
1318
1940
  }
1319
1941
 
1320
- if (connectorToolDeliveredCurrentChannel) return NO_MESSAGE_SENTINEL
1321
- return fullText || '(no response)'
1942
+ if (connectorToolDeliveredCurrentChannel) return NO_MESSAGE_SENTINEL
1943
+ return fullText || '(no response)'
1944
+ } finally {
1945
+ stopTyping?.()
1946
+ }
1322
1947
  }
1323
1948
 
1324
1949
  routeMessageHandlerRef.current = routeMessage
@@ -1427,11 +2052,22 @@ export async function stopConnector(connectorId: string): Promise<void> {
1427
2052
  running.delete(connectorId)
1428
2053
  }
1429
2054
 
2055
+ for (const [debounceKey, entry] of pendingInboundDebounce.entries()) {
2056
+ if (entry.connector.id !== connectorId) continue
2057
+ clearTimeout(entry.timer)
2058
+ pendingInboundDebounce.delete(debounceKey)
2059
+ }
2060
+
1430
2061
  for (const [followupId, followup] of scheduledFollowups.entries()) {
1431
2062
  if (followup.connectorId !== connectorId) continue
1432
2063
  clearTimeout(followup.timer)
1433
2064
  scheduledFollowups.delete(followupId)
1434
2065
  }
2066
+ for (const [key, entry] of scheduledFollowupByDedupe.entries()) {
2067
+ if (!scheduledFollowups.has(entry.id)) {
2068
+ scheduledFollowupByDedupe.delete(key)
2069
+ }
2070
+ }
1435
2071
 
1436
2072
  const connectors = loadConnectors()
1437
2073
  const connector = connectors[connectorId]
@@ -1582,6 +2218,136 @@ export function getRunningInstance(connectorId: string): ConnectorInstance | und
1582
2218
  return running.get(connectorId)
1583
2219
  }
1584
2220
 
2221
+ export function getConnectorReplySendOptions(params: {
2222
+ connectorId: string
2223
+ inbound: InboundMessage
2224
+ }): { replyToMessageId?: string; threadId?: string } {
2225
+ const connectors = loadConnectors()
2226
+ const connector = connectors[params.connectorId] as Connector | undefined
2227
+ if (!connector) return {}
2228
+ const session = findDirectSessionForInbound(connector, params.inbound)
2229
+ const policy = resolveConnectorSessionPolicy(connector, params.inbound, session)
2230
+ return shouldReplyToInboundMessage({
2231
+ msg: params.inbound,
2232
+ session,
2233
+ policy,
2234
+ })
2235
+ }
2236
+
2237
+ export async function recordConnectorOutboundDelivery(params: {
2238
+ connectorId: string
2239
+ inbound: InboundMessage
2240
+ messageId?: string
2241
+ state?: 'sent' | 'silent'
2242
+ }): Promise<void> {
2243
+ const connectors = loadConnectors()
2244
+ const connector = connectors[params.connectorId] as Connector | undefined
2245
+ if (!connector) return
2246
+ const session = findDirectSessionForInbound(connector, params.inbound)
2247
+ if (session) {
2248
+ session.connectorContext = {
2249
+ ...(session.connectorContext || {}),
2250
+ lastOutboundAt: Date.now(),
2251
+ lastOutboundMessageId: params.messageId || session.connectorContext?.lastOutboundMessageId || null,
2252
+ threadId: params.inbound.threadId || session.connectorContext?.threadId || null,
2253
+ }
2254
+ const history = Array.isArray(session.messages) ? session.messages : []
2255
+ for (let i = history.length - 1; i >= 0; i -= 1) {
2256
+ const entry = history[i]
2257
+ if (entry?.role !== 'assistant') continue
2258
+ const source = entry?.source || {}
2259
+ if (source.connectorId !== connector.id) continue
2260
+ if (source.channelId !== params.inbound.channelId) continue
2261
+ if (!source.messageId && params.messageId) {
2262
+ entry.source = {
2263
+ ...source,
2264
+ messageId: params.messageId,
2265
+ replyToMessageId: source.replyToMessageId || params.inbound.messageId,
2266
+ threadId: source.threadId || params.inbound.threadId,
2267
+ }
2268
+ }
2269
+ break
2270
+ }
2271
+ persistSessionRecord(session)
2272
+ notify(`messages:${session.id}`)
2273
+ }
2274
+ if (params.state) {
2275
+ await maybeSendStatusReaction(connector, params.inbound, params.state)
2276
+ }
2277
+ }
2278
+
2279
+ export async function performConnectorMessageAction(params: {
2280
+ connectorId?: string
2281
+ platform?: string
2282
+ channelId: string
2283
+ action: 'react' | 'edit' | 'delete' | 'pin'
2284
+ messageId?: string
2285
+ emoji?: string
2286
+ text?: string
2287
+ sessionId?: string | null
2288
+ targetMessage?: 'last_inbound' | 'last_outbound'
2289
+ }): Promise<{ connectorId: string; platform: string; channelId: string; messageId?: string }> {
2290
+ const connectors = loadConnectors()
2291
+ const requestedId = params.connectorId?.trim()
2292
+ let connector: Connector | undefined
2293
+ let connectorId: string | undefined
2294
+
2295
+ if (requestedId) {
2296
+ connector = connectors[requestedId] as Connector | undefined
2297
+ connectorId = requestedId
2298
+ if (!connector) throw new Error(`Connector not found: ${requestedId}`)
2299
+ } else {
2300
+ const candidates = Object.values(connectors) as Connector[]
2301
+ const filtered = candidates.filter((item) => (!params.platform || item.platform === params.platform) && running.has(item.id))
2302
+ if (!filtered.length) throw new Error(`No running connector found${params.platform ? ` for platform "${params.platform}"` : ''}.`)
2303
+ connector = filtered[0]
2304
+ connectorId = connector.id
2305
+ }
2306
+
2307
+ if (!connector || !connectorId) throw new Error('Connector resolution failed.')
2308
+ const instance = running.get(connectorId)
2309
+ if (!instance) throw new Error(`Connector "${connectorId}" is not running.`)
2310
+
2311
+ const targetMessageId = (() => {
2312
+ if (params.messageId?.trim()) return params.messageId.trim()
2313
+ if (!params.sessionId) return ''
2314
+ const session = loadSessions()[params.sessionId]
2315
+ if (!session) return ''
2316
+ if (params.targetMessage === 'last_inbound') return session.connectorContext?.lastInboundMessageId || ''
2317
+ if (params.targetMessage === 'last_outbound' || !params.targetMessage) return session.connectorContext?.lastOutboundMessageId || ''
2318
+ return ''
2319
+ })()
2320
+ if (!targetMessageId) throw new Error('messageId is required for connector message actions.')
2321
+
2322
+ switch (params.action) {
2323
+ case 'react':
2324
+ if (!instance.sendReaction) throw new Error(`Connector "${connector.name}" does not support reactions.`)
2325
+ if (!params.emoji?.trim()) throw new Error('emoji is required for react action.')
2326
+ await instance.sendReaction(params.channelId, targetMessageId, params.emoji.trim())
2327
+ break
2328
+ case 'edit':
2329
+ if (!instance.editMessage) throw new Error(`Connector "${connector.name}" does not support edits.`)
2330
+ if (!params.text?.trim()) throw new Error('text is required for edit action.')
2331
+ await instance.editMessage(params.channelId, targetMessageId, params.text.trim())
2332
+ break
2333
+ case 'delete':
2334
+ if (!instance.deleteMessage) throw new Error(`Connector "${connector.name}" does not support deletes.`)
2335
+ await instance.deleteMessage(params.channelId, targetMessageId)
2336
+ break
2337
+ case 'pin':
2338
+ if (!instance.pinMessage) throw new Error(`Connector "${connector.name}" does not support pinning.`)
2339
+ await instance.pinMessage(params.channelId, targetMessageId)
2340
+ break
2341
+ }
2342
+
2343
+ return {
2344
+ connectorId,
2345
+ platform: connector.platform,
2346
+ channelId: params.channelId,
2347
+ messageId: targetMessageId,
2348
+ }
2349
+ }
2350
+
1585
2351
  /**
1586
2352
  * Send an outbound message through a running connector.
1587
2353
  * Intended for proactive agent notifications (e.g. WhatsApp updates).
@@ -1591,12 +2357,15 @@ export async function sendConnectorMessage(params: {
1591
2357
  platform?: string
1592
2358
  channelId: string
1593
2359
  text: string
2360
+ sessionId?: string | null
1594
2361
  imageUrl?: string
1595
2362
  fileUrl?: string
1596
2363
  mediaPath?: string
1597
2364
  mimeType?: string
1598
2365
  fileName?: string
1599
2366
  caption?: string
2367
+ replyToMessageId?: string
2368
+ threadId?: string
1600
2369
  ptt?: boolean
1601
2370
  }): Promise<{ connectorId: string; platform: string; channelId: string; messageId?: string }> {
1602
2371
  const connectors = loadConnectors()
@@ -1650,6 +2419,8 @@ export async function sendConnectorMessage(params: {
1650
2419
  mimeType: params.mimeType,
1651
2420
  fileName: params.fileName,
1652
2421
  caption: params.caption,
2422
+ replyToMessageId: params.replyToMessageId,
2423
+ threadId: params.threadId,
1653
2424
  ptt: params.ptt,
1654
2425
  }
1655
2426
 
@@ -1668,6 +2439,41 @@ export async function sendConnectorMessage(params: {
1668
2439
  }
1669
2440
 
1670
2441
  const result = await instance.sendMessage(channelId, outboundText, outboundOptions)
2442
+ if (params.sessionId) {
2443
+ const sessions = loadSessions()
2444
+ const session = sessions[params.sessionId]
2445
+ if (session) {
2446
+ session.connectorContext = {
2447
+ ...(session.connectorContext || {}),
2448
+ connectorId,
2449
+ platform: connector.platform,
2450
+ channelId,
2451
+ threadId: params.threadId || session.connectorContext?.threadId || null,
2452
+ lastOutboundAt: Date.now(),
2453
+ lastOutboundMessageId: result?.messageId || session.connectorContext?.lastOutboundMessageId || null,
2454
+ }
2455
+ const history = Array.isArray(session.messages) ? session.messages : []
2456
+ for (let i = history.length - 1; i >= 0; i -= 1) {
2457
+ const entry = history[i]
2458
+ if (entry?.role !== 'assistant') continue
2459
+ const source = entry?.source || {}
2460
+ if (source.connectorId !== connectorId) continue
2461
+ if (source.channelId !== channelId) continue
2462
+ if (!source.messageId && result?.messageId) {
2463
+ entry.source = {
2464
+ ...source,
2465
+ messageId: result.messageId,
2466
+ threadId: source.threadId || params.threadId,
2467
+ replyToMessageId: source.replyToMessageId || params.replyToMessageId,
2468
+ }
2469
+ }
2470
+ break
2471
+ }
2472
+ sessions[session.id] = session
2473
+ saveSessions(sessions)
2474
+ notify(`messages:${session.id}`)
2475
+ }
2476
+ }
1671
2477
  return {
1672
2478
  connectorId,
1673
2479
  platform: connector.platform,
@@ -1682,16 +2488,39 @@ export function scheduleConnectorFollowUp(params: {
1682
2488
  channelId: string
1683
2489
  text: string
1684
2490
  delaySec?: number
2491
+ dedupeKey?: string
2492
+ replaceExisting?: boolean
2493
+ sessionId?: string | null
1685
2494
  imageUrl?: string
1686
2495
  fileUrl?: string
1687
2496
  mediaPath?: string
1688
2497
  mimeType?: string
1689
2498
  fileName?: string
1690
2499
  caption?: string
2500
+ replyToMessageId?: string
2501
+ threadId?: string
1691
2502
  ptt?: boolean
1692
2503
  }): { followUpId: string; sendAt: number } {
1693
2504
  const delaySecRaw = Number.isFinite(params.delaySec) ? Number(params.delaySec) : 300
1694
2505
  const delayMs = Math.max(1_000, Math.min(86_400_000, Math.round(delaySecRaw * 1000)))
2506
+ const dedupeKey = params.dedupeKey || [
2507
+ params.connectorId || params.platform || '',
2508
+ params.channelId,
2509
+ params.threadId || '',
2510
+ (params.text || '').trim().slice(0, 160),
2511
+ ].join('|')
2512
+ const existing = scheduledFollowupByDedupe.get(dedupeKey)
2513
+ if (existing && existing.sendAt > Date.now() && !params.replaceExisting) {
2514
+ return { followUpId: existing.id, sendAt: existing.sendAt }
2515
+ }
2516
+ if (existing && params.replaceExisting) {
2517
+ const scheduled = scheduledFollowups.get(existing.id)
2518
+ if (scheduled) {
2519
+ clearTimeout(scheduled.timer)
2520
+ scheduledFollowups.delete(existing.id)
2521
+ }
2522
+ scheduledFollowupByDedupe.delete(dedupeKey)
2523
+ }
1695
2524
  const followUpId = genId()
1696
2525
  const sendAt = Date.now() + delayMs
1697
2526
 
@@ -1701,18 +2530,24 @@ export function scheduleConnectorFollowUp(params: {
1701
2530
  platform: params.platform,
1702
2531
  channelId: params.channelId,
1703
2532
  text: params.text,
2533
+ sessionId: params.sessionId,
1704
2534
  imageUrl: params.imageUrl,
1705
2535
  fileUrl: params.fileUrl,
1706
2536
  mediaPath: params.mediaPath,
1707
2537
  mimeType: params.mimeType,
1708
2538
  fileName: params.fileName,
1709
2539
  caption: params.caption,
2540
+ replyToMessageId: params.replyToMessageId,
2541
+ threadId: params.threadId,
1710
2542
  ptt: params.ptt,
1711
2543
  }).catch((err: unknown) => {
1712
2544
  const msg = err instanceof Error ? err.message : String(err)
1713
2545
  console.warn(`[connector] Scheduled follow-up ${followUpId} failed: ${msg}`)
1714
2546
  }).finally(() => {
1715
2547
  scheduledFollowups.delete(followUpId)
2548
+ if (scheduledFollowupByDedupe.get(dedupeKey)?.id === followUpId) {
2549
+ scheduledFollowupByDedupe.delete(dedupeKey)
2550
+ }
1716
2551
  })
1717
2552
  }, delayMs)
1718
2553
 
@@ -1724,6 +2559,7 @@ export function scheduleConnectorFollowUp(params: {
1724
2559
  sendAt,
1725
2560
  timer,
1726
2561
  })
2562
+ scheduledFollowupByDedupe.set(dedupeKey, { id: followUpId, sendAt })
1727
2563
 
1728
2564
  return { followUpId, sendAt }
1729
2565
  }