@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
@@ -0,0 +1,141 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import type { Agent, GatewayProfile } from '@/types'
4
+ import { normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
5
+ import {
6
+ applyResolvedRoute,
7
+ resolveAgentRouteCandidatesWithProfiles,
8
+ } from './agent-runtime-config'
9
+
10
+ function makeGateway(overrides: Partial<GatewayProfile> = {}): GatewayProfile {
11
+ const now = Date.now()
12
+ return {
13
+ id: 'gateway-default',
14
+ name: 'Gateway Default',
15
+ provider: 'openclaw',
16
+ endpoint: 'https://gateway.example.com/v1',
17
+ wsUrl: 'wss://gateway.example.com',
18
+ credentialId: 'cred-gateway',
19
+ status: 'healthy',
20
+ tags: [],
21
+ isDefault: true,
22
+ createdAt: now,
23
+ updatedAt: now,
24
+ ...overrides,
25
+ }
26
+ }
27
+
28
+ function makeAgent(overrides: Partial<Agent> = {}): Agent {
29
+ const now = Date.now()
30
+ return {
31
+ id: 'agent-1',
32
+ name: 'OpenClaw Ops',
33
+ description: '',
34
+ systemPrompt: '',
35
+ provider: 'openclaw',
36
+ model: '',
37
+ createdAt: now,
38
+ updatedAt: now,
39
+ ...overrides,
40
+ }
41
+ }
42
+
43
+ test('resolveAgentRouteCandidatesWithProfiles applies the default OpenClaw gateway profile to base agents', () => {
44
+ const gateways = [
45
+ makeGateway(),
46
+ makeGateway({
47
+ id: 'gateway-secondary',
48
+ name: 'Gateway Secondary',
49
+ endpoint: 'https://secondary.example.com/v1',
50
+ wsUrl: 'wss://secondary.example.com',
51
+ credentialId: 'cred-secondary',
52
+ isDefault: false,
53
+ }),
54
+ ]
55
+
56
+ const [route] = resolveAgentRouteCandidatesWithProfiles(makeAgent(), gateways)
57
+ assert.ok(route)
58
+ assert.equal(route.provider, 'openclaw')
59
+ assert.equal(route.model, 'default')
60
+ assert.equal(route.gatewayProfileId, 'gateway-default')
61
+ assert.equal(route.credentialId, 'cred-gateway')
62
+ assert.equal(route.apiEndpoint, normalizeProviderEndpoint('openclaw', 'https://gateway.example.com/v1'))
63
+ })
64
+
65
+ test('resolveAgentRouteCandidatesWithProfiles respects routing strategy but deprioritizes cooling providers', () => {
66
+ const gateways = [
67
+ makeGateway({
68
+ id: 'gateway-economy',
69
+ name: 'Economy Gateway',
70
+ endpoint: 'https://economy.example.com/v1',
71
+ wsUrl: 'wss://economy.example.com',
72
+ credentialId: 'cred-economy',
73
+ isDefault: false,
74
+ }),
75
+ ]
76
+
77
+ const agent = makeAgent({
78
+ provider: 'openai',
79
+ model: 'gpt-4o',
80
+ gatewayProfileId: null,
81
+ routingStrategy: 'economy',
82
+ routingTargets: [
83
+ {
84
+ id: 'economy-route',
85
+ label: 'Economy',
86
+ provider: 'openclaw',
87
+ model: 'default',
88
+ gatewayProfileId: 'gateway-economy',
89
+ role: 'economy',
90
+ },
91
+ {
92
+ id: 'premium-route',
93
+ label: 'Premium',
94
+ provider: 'openai',
95
+ model: 'gpt-5',
96
+ role: 'premium',
97
+ },
98
+ ],
99
+ })
100
+
101
+ const preferred = resolveAgentRouteCandidatesWithProfiles(agent, gateways)
102
+ assert.equal(preferred[0]?.id, 'economy-route')
103
+ assert.equal(preferred[0]?.apiEndpoint, normalizeProviderEndpoint('openclaw', 'https://economy.example.com/v1'))
104
+
105
+ const cooled = resolveAgentRouteCandidatesWithProfiles(agent, gateways, undefined, (providerId) => providerId === 'openclaw')
106
+ assert.equal(cooled[0]?.id, 'base')
107
+ assert.equal(cooled[0]?.provider, 'openai')
108
+ })
109
+
110
+ test('applyResolvedRoute copies gateway, endpoint, and fallback credentials onto a target session-like object', () => {
111
+ const target = {
112
+ provider: 'claude-cli' as const,
113
+ model: 'claude-sonnet-4-5',
114
+ credentialId: null,
115
+ fallbackCredentialIds: [] as string[],
116
+ apiEndpoint: null,
117
+ gatewayProfileId: null,
118
+ }
119
+
120
+ const next = applyResolvedRoute(target, {
121
+ id: 'route-1',
122
+ label: 'Gateway route',
123
+ provider: 'openclaw',
124
+ model: 'default',
125
+ credentialId: 'cred-1',
126
+ fallbackCredentialIds: ['cred-2', 'cred-3'],
127
+ apiEndpoint: 'https://gateway.example.com/v1',
128
+ gatewayProfileId: 'gateway-1',
129
+ priority: 0,
130
+ source: 'routing-target',
131
+ })
132
+
133
+ assert.deepEqual(next, {
134
+ provider: 'openclaw',
135
+ model: 'default',
136
+ credentialId: 'cred-1',
137
+ fallbackCredentialIds: ['cred-2', 'cred-3'],
138
+ apiEndpoint: 'https://gateway.example.com/v1',
139
+ gatewayProfileId: 'gateway-1',
140
+ })
141
+ })
@@ -0,0 +1,277 @@
1
+ import type {
2
+ Agent,
3
+ AgentRoutingStrategy,
4
+ AgentRoutingTarget,
5
+ GatewayProfile,
6
+ ProviderType,
7
+ } from '@/types'
8
+ import { deriveOpenClawWsUrl, normalizeProviderEndpoint } from '@/lib/openclaw-endpoint'
9
+ import { loadGatewayProfiles } from './storage'
10
+ import { isProviderCoolingDown } from './provider-health'
11
+
12
+ const DEFAULT_OPENCLAW_ENDPOINT = 'http://localhost:18789/v1'
13
+ const DEFAULT_OPENCLAW_MODEL = 'default'
14
+
15
+ export interface ResolvedAgentRoute {
16
+ id: string
17
+ label: string
18
+ provider: ProviderType
19
+ model: string
20
+ credentialId?: string | null
21
+ fallbackCredentialIds: string[]
22
+ apiEndpoint?: string | null
23
+ gatewayProfileId?: string | null
24
+ role?: NonNullable<AgentRoutingTarget['role']>
25
+ priority: number
26
+ source: 'agent' | 'routing-target'
27
+ }
28
+
29
+ interface RouteSeed {
30
+ id: string
31
+ label?: string
32
+ provider?: ProviderType | null
33
+ model?: string | null
34
+ credentialId?: string | null
35
+ fallbackCredentialIds?: string[]
36
+ apiEndpoint?: string | null
37
+ gatewayProfileId?: string | null
38
+ role?: AgentRoutingTarget['role']
39
+ priority?: number
40
+ source: ResolvedAgentRoute['source']
41
+ }
42
+
43
+ function ensureStringArray(value: unknown): string[] {
44
+ if (!Array.isArray(value)) return []
45
+ return value
46
+ .map((item) => (typeof item === 'string' ? item.trim() : ''))
47
+ .filter(Boolean)
48
+ }
49
+
50
+ function normalizeGateway(raw: unknown, id: string): GatewayProfile | null {
51
+ if (!raw || typeof raw !== 'object') return null
52
+ const gateway = raw as Partial<GatewayProfile> & Record<string, unknown>
53
+ const endpoint = normalizeProviderEndpoint(
54
+ 'openclaw',
55
+ typeof gateway.endpoint === 'string' && gateway.endpoint.trim()
56
+ ? gateway.endpoint
57
+ : DEFAULT_OPENCLAW_ENDPOINT,
58
+ )
59
+ if (!endpoint) return null
60
+ return {
61
+ id,
62
+ name: typeof gateway.name === 'string' && gateway.name.trim() ? gateway.name.trim() : id,
63
+ provider: 'openclaw',
64
+ endpoint,
65
+ wsUrl: typeof gateway.wsUrl === 'string' && gateway.wsUrl.trim() ? gateway.wsUrl.trim() : deriveOpenClawWsUrl(endpoint),
66
+ credentialId: typeof gateway.credentialId === 'string' && gateway.credentialId.trim() ? gateway.credentialId.trim() : null,
67
+ status: gateway.status === 'healthy' || gateway.status === 'degraded' || gateway.status === 'offline' || gateway.status === 'pending' ? gateway.status : 'unknown',
68
+ notes: typeof gateway.notes === 'string' ? gateway.notes : null,
69
+ tags: ensureStringArray(gateway.tags),
70
+ lastError: typeof gateway.lastError === 'string' ? gateway.lastError : null,
71
+ lastCheckedAt: typeof gateway.lastCheckedAt === 'number' ? gateway.lastCheckedAt : null,
72
+ lastModelCount: typeof gateway.lastModelCount === 'number' ? gateway.lastModelCount : null,
73
+ discoveredHost: typeof gateway.discoveredHost === 'string' ? gateway.discoveredHost : null,
74
+ discoveredPort: typeof gateway.discoveredPort === 'number' ? gateway.discoveredPort : null,
75
+ isDefault: gateway.isDefault === true,
76
+ createdAt: typeof gateway.createdAt === 'number' ? gateway.createdAt : Date.now(),
77
+ updatedAt: typeof gateway.updatedAt === 'number' ? gateway.updatedAt : Date.now(),
78
+ }
79
+ }
80
+
81
+ function findGatewayProfile(
82
+ gatewayProfiles: GatewayProfile[],
83
+ profileId?: string | null,
84
+ ): GatewayProfile | null {
85
+ const id = typeof profileId === 'string' ? profileId.trim() : ''
86
+ if (!id) return null
87
+ return gatewayProfiles.find((profile) => profile.id === id) || null
88
+ }
89
+
90
+ export function getGatewayProfiles(provider: GatewayProfile['provider'] | null = null): GatewayProfile[] {
91
+ const all = loadGatewayProfiles()
92
+ return Object.entries(all)
93
+ .map(([id, value]) => normalizeGateway(value, id))
94
+ .filter((value): value is GatewayProfile => Boolean(value))
95
+ .filter((value) => !provider || value.provider === provider)
96
+ .sort((a, b) => {
97
+ if ((a.isDefault === true) !== (b.isDefault === true)) return a.isDefault ? -1 : 1
98
+ return a.name.localeCompare(b.name)
99
+ })
100
+ }
101
+
102
+ export function getGatewayProfile(profileId?: string | null): GatewayProfile | null {
103
+ return findGatewayProfile(getGatewayProfiles(), profileId)
104
+ }
105
+
106
+ function defaultGatewayProfile(gatewayProfiles: GatewayProfile[]): GatewayProfile | null {
107
+ return gatewayProfiles.find((profile) => profile.isDefault) || gatewayProfiles[0] || null
108
+ }
109
+
110
+ function roleWeight(strategy: AgentRoutingStrategy, role?: AgentRoutingTarget['role']): number {
111
+ const normalized = role || 'primary'
112
+ const matrix: Record<AgentRoutingStrategy, Record<string, number>> = {
113
+ single: { primary: 0, backup: 10, premium: 20, reasoning: 30, economy: 40 },
114
+ balanced: { primary: 0, premium: 4, economy: 4, reasoning: 6, backup: 12 },
115
+ economy: { economy: 0, primary: 10, backup: 18, premium: 28, reasoning: 36 },
116
+ premium: { premium: 0, reasoning: 4, primary: 10, backup: 18, economy: 28 },
117
+ reasoning: { reasoning: 0, premium: 4, primary: 10, backup: 18, economy: 28 },
118
+ }
119
+ return matrix[strategy][normalized] ?? 50
120
+ }
121
+
122
+ function dedupeCredentialIds(primary: string | null | undefined, candidates: string[] | undefined): string[] {
123
+ const seen = new Set<string>()
124
+ const normalizedPrimary = typeof primary === 'string' && primary.trim() ? primary.trim() : null
125
+ const result: string[] = []
126
+ for (const value of ensureStringArray(candidates)) {
127
+ if (normalizedPrimary && value === normalizedPrimary) continue
128
+ if (seen.has(value)) continue
129
+ seen.add(value)
130
+ result.push(value)
131
+ }
132
+ return result
133
+ }
134
+
135
+ function buildRouteFromSeed(
136
+ seed: RouteSeed,
137
+ gatewayProfiles: GatewayProfile[],
138
+ agentGatewayProfileId?: string | null,
139
+ ): ResolvedAgentRoute | null {
140
+ const provider = (seed.provider || 'claude-cli') as ProviderType
141
+ let gatewayProfileId = seed.gatewayProfileId ?? null
142
+ if (!gatewayProfileId && provider === 'openclaw') {
143
+ gatewayProfileId = agentGatewayProfileId ?? defaultGatewayProfile(gatewayProfiles)?.id ?? null
144
+ }
145
+ const gatewayProfile = findGatewayProfile(gatewayProfiles, gatewayProfileId)
146
+
147
+ const providerFromGateway = gatewayProfile?.provider === 'openclaw' ? 'openclaw' : provider
148
+ const apiEndpoint = normalizeProviderEndpoint(
149
+ providerFromGateway,
150
+ seed.apiEndpoint ?? gatewayProfile?.endpoint ?? null,
151
+ )
152
+ const model = (seed.model || '').trim() || (providerFromGateway === 'openclaw' ? DEFAULT_OPENCLAW_MODEL : '')
153
+ if (!providerFromGateway || !model) return null
154
+
155
+ const credentialId = seed.credentialId ?? gatewayProfile?.credentialId ?? null
156
+ return {
157
+ id: seed.id,
158
+ label: seed.label?.trim() || (gatewayProfile?.name || `${providerFromGateway}:${model}`),
159
+ provider: providerFromGateway,
160
+ model,
161
+ credentialId,
162
+ fallbackCredentialIds: dedupeCredentialIds(credentialId, seed.fallbackCredentialIds),
163
+ apiEndpoint,
164
+ gatewayProfileId,
165
+ role: seed.role,
166
+ priority: typeof seed.priority === 'number' ? seed.priority : 100,
167
+ source: seed.source,
168
+ }
169
+ }
170
+
171
+ function dedupeRoutes(routes: ResolvedAgentRoute[]): ResolvedAgentRoute[] {
172
+ const seen = new Set<string>()
173
+ const deduped: ResolvedAgentRoute[] = []
174
+ for (const route of routes) {
175
+ const key = [
176
+ route.provider,
177
+ route.model,
178
+ route.credentialId || '',
179
+ route.apiEndpoint || '',
180
+ route.gatewayProfileId || '',
181
+ ].join('::')
182
+ if (seen.has(key)) continue
183
+ seen.add(key)
184
+ deduped.push(route)
185
+ }
186
+ return deduped
187
+ }
188
+
189
+ export function resolveAgentRouteCandidates(
190
+ agent: Agent | null | undefined,
191
+ preferredStrategy?: AgentRoutingStrategy | null,
192
+ ): ResolvedAgentRoute[] {
193
+ return resolveAgentRouteCandidatesWithProfiles(agent, getGatewayProfiles('openclaw'), preferredStrategy)
194
+ }
195
+
196
+ export function resolveAgentRouteCandidatesWithProfiles(
197
+ agent: Agent | null | undefined,
198
+ gatewayProfiles: GatewayProfile[],
199
+ preferredStrategy?: AgentRoutingStrategy | null,
200
+ isCoolingDown: (providerId: string) => boolean = isProviderCoolingDown,
201
+ ): ResolvedAgentRoute[] {
202
+ if (!agent) return []
203
+ const strategy = preferredStrategy || agent.routingStrategy || 'single'
204
+ const seeds: RouteSeed[] = [
205
+ {
206
+ id: 'base',
207
+ label: agent.name,
208
+ provider: agent.provider,
209
+ model: agent.model,
210
+ credentialId: agent.credentialId ?? null,
211
+ fallbackCredentialIds: agent.fallbackCredentialIds || [],
212
+ apiEndpoint: agent.apiEndpoint ?? null,
213
+ gatewayProfileId: agent.gatewayProfileId ?? null,
214
+ role: 'primary',
215
+ priority: 0,
216
+ source: 'agent',
217
+ },
218
+ ...((agent.routingTargets || []).map((target, index) => ({
219
+ id: target.id || `route-${index + 1}`,
220
+ label: target.label,
221
+ provider: target.provider,
222
+ model: target.model,
223
+ credentialId: target.credentialId ?? null,
224
+ fallbackCredentialIds: target.fallbackCredentialIds || [],
225
+ apiEndpoint: target.apiEndpoint ?? null,
226
+ gatewayProfileId: target.gatewayProfileId ?? null,
227
+ role: target.role,
228
+ priority: typeof target.priority === 'number' ? target.priority : index + 1,
229
+ source: 'routing-target' as const,
230
+ }))),
231
+ ]
232
+
233
+ return dedupeRoutes(
234
+ seeds
235
+ .map((seed) => buildRouteFromSeed(seed, gatewayProfiles, agent.gatewayProfileId ?? null))
236
+ .filter((route): route is ResolvedAgentRoute => Boolean(route)),
237
+ ).sort((left, right) => {
238
+ const leftCooling = isCoolingDown(left.provider)
239
+ const rightCooling = isCoolingDown(right.provider)
240
+ if (leftCooling !== rightCooling) return leftCooling ? 1 : -1
241
+ const leftRole = roleWeight(strategy, left.role)
242
+ const rightRole = roleWeight(strategy, right.role)
243
+ if (leftRole !== rightRole) return leftRole - rightRole
244
+ if (left.priority !== right.priority) return left.priority - right.priority
245
+ return left.label.localeCompare(right.label)
246
+ })
247
+ }
248
+
249
+ export function resolvePrimaryAgentRoute(
250
+ agent: Agent | null | undefined,
251
+ preferredStrategy?: AgentRoutingStrategy | null,
252
+ ): ResolvedAgentRoute | null {
253
+ return resolveAgentRouteCandidates(agent, preferredStrategy)[0] || null
254
+ }
255
+
256
+ export function applyResolvedRoute<T extends {
257
+ provider: ProviderType
258
+ model: string
259
+ credentialId?: string | null
260
+ fallbackCredentialIds?: string[]
261
+ apiEndpoint?: string | null
262
+ gatewayProfileId?: string | null
263
+ }>(
264
+ target: T,
265
+ route: ResolvedAgentRoute | null | undefined,
266
+ ): T {
267
+ if (!route) return target
268
+ return {
269
+ ...target,
270
+ provider: route.provider,
271
+ model: route.model,
272
+ credentialId: route.credentialId ?? null,
273
+ fallbackCredentialIds: [...route.fallbackCredentialIds],
274
+ apiEndpoint: route.apiEndpoint ?? null,
275
+ gatewayProfileId: route.gatewayProfileId ?? null,
276
+ }
277
+ }
@@ -0,0 +1,253 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+ import { describe, it } from 'node:test'
7
+
8
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../..')
9
+
10
+ function runWithTempDataDir(script: string) {
11
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-approval-connector-'))
12
+ try {
13
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
14
+ cwd: repoRoot,
15
+ env: {
16
+ ...process.env,
17
+ DATA_DIR: tempDir,
18
+ WORKSPACE_DIR: path.join(tempDir, 'workspace'),
19
+ },
20
+ encoding: 'utf-8',
21
+ })
22
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
23
+ const lines = (result.stdout || '')
24
+ .trim()
25
+ .split('\n')
26
+ .map((line) => line.trim())
27
+ .filter(Boolean)
28
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
29
+ return JSON.parse(jsonLine || '{}')
30
+ } finally {
31
+ fs.rmSync(tempDir, { recursive: true, force: true })
32
+ }
33
+ }
34
+
35
+ describe('approval connector reminders', () => {
36
+ it('resolves a due approval to the session connector target and records one-shot delivery state', () => {
37
+ const output = runWithTempDataDir(`
38
+ const storageMod = await import('./src/lib/server/storage.ts')
39
+ const approvalsMod = await import('./src/lib/server/approvals.ts')
40
+ const storage = storageMod.default || storageMod
41
+ const approvals = approvalsMod.default || approvalsMod
42
+
43
+ const now = Date.now()
44
+ storage.saveSettings({
45
+ approvalConnectorNotifyEnabled: true,
46
+ approvalConnectorNotifyDelaySec: 60,
47
+ })
48
+ storage.saveAgents({
49
+ agent_1: {
50
+ id: 'agent_1',
51
+ name: 'Molly',
52
+ description: 'Test agent',
53
+ systemPrompt: 'You are Molly.',
54
+ provider: 'openai',
55
+ model: 'gpt-test',
56
+ plugins: [],
57
+ createdAt: now,
58
+ updatedAt: now,
59
+ },
60
+ })
61
+ storage.saveSessions({
62
+ session_1: {
63
+ id: 'session_1',
64
+ name: 'Connector session',
65
+ cwd: process.cwd(),
66
+ user: 'tester',
67
+ provider: 'openai',
68
+ model: 'gpt-test',
69
+ credentialId: null,
70
+ apiEndpoint: null,
71
+ claudeSessionId: null,
72
+ codexThreadId: null,
73
+ opencodeSessionId: null,
74
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
75
+ messages: [
76
+ {
77
+ role: 'user',
78
+ text: 'Please ask me before spending money.',
79
+ time: now - 1_000,
80
+ source: { connectorId: 'conn-1', channelId: 'chat-42', threadId: 'topic-7' },
81
+ },
82
+ ],
83
+ createdAt: now - 120_000,
84
+ lastActiveAt: now - 1_000,
85
+ sessionType: 'human',
86
+ agentId: 'agent_1',
87
+ plugins: [],
88
+ connectorContext: {
89
+ connectorId: 'conn-1',
90
+ platform: 'telegram',
91
+ channelId: 'chat-42',
92
+ threadId: 'topic-7',
93
+ lastInboundAt: now - 1_000,
94
+ },
95
+ },
96
+ })
97
+
98
+ const approval = approvals.requestApproval({
99
+ category: 'human_loop',
100
+ title: 'Approve plugin install',
101
+ description: 'Need permission to install a plugin.',
102
+ data: {},
103
+ sessionId: 'session_1',
104
+ agentId: 'agent_1',
105
+ })
106
+
107
+ const dueAt = approval.createdAt + 61_000
108
+ const reminders = approvals.listPendingApprovalsNeedingConnectorNotification({
109
+ now: dueAt,
110
+ runningConnectors: [
111
+ { id: 'conn-1', agentId: 'agent_1', supportsSend: true, configuredTargets: [], recentChannelId: 'chat-42' },
112
+ ],
113
+ })
114
+
115
+ approvals.markApprovalConnectorNotificationSent(approval.id, {
116
+ at: dueAt,
117
+ connectorId: 'conn-1',
118
+ channelId: 'chat-42',
119
+ threadId: 'topic-7',
120
+ messageId: 'msg-9',
121
+ })
122
+
123
+ const afterSend = approvals.listPendingApprovalsNeedingConnectorNotification({
124
+ now: dueAt + 1_000,
125
+ runningConnectors: [
126
+ { id: 'conn-1', agentId: 'agent_1', supportsSend: true, configuredTargets: [], recentChannelId: 'chat-42' },
127
+ ],
128
+ })
129
+
130
+ const storedApproval = storage.loadApprovals()[approval.id]
131
+ console.log(JSON.stringify({
132
+ reminderCount: reminders.length,
133
+ reminder: reminders[0],
134
+ afterSendCount: afterSend.length,
135
+ storedApproval,
136
+ }))
137
+ `)
138
+
139
+ assert.equal(output.reminderCount, 1)
140
+ assert.equal(output.reminder.connectorId, 'conn-1')
141
+ assert.equal(output.reminder.channelId, 'chat-42')
142
+ assert.equal(output.reminder.threadId, 'topic-7')
143
+ assert.match(output.reminder.text, /Molly is waiting for your approval/i)
144
+ assert.equal(output.afterSendCount, 0)
145
+ assert.equal(output.storedApproval.connectorNotification.sentAt > 0, true)
146
+ assert.equal(output.storedApproval.connectorNotification.messageId, 'msg-9')
147
+ })
148
+
149
+ it('falls back to a running owned connector and respects retry cooldowns after failed sends', () => {
150
+ const output = runWithTempDataDir(`
151
+ const storageMod = await import('./src/lib/server/storage.ts')
152
+ const approvalsMod = await import('./src/lib/server/approvals.ts')
153
+ const storage = storageMod.default || storageMod
154
+ const approvals = approvalsMod.default || approvalsMod
155
+
156
+ const now = Date.now()
157
+ storage.saveSettings({
158
+ approvalConnectorNotifyEnabled: true,
159
+ approvalConnectorNotifyDelaySec: 30,
160
+ })
161
+ storage.saveAgents({
162
+ agent_2: {
163
+ id: 'agent_2',
164
+ name: 'Writer',
165
+ description: 'Test agent',
166
+ systemPrompt: 'You are Writer.',
167
+ provider: 'openai',
168
+ model: 'gpt-test',
169
+ plugins: [],
170
+ createdAt: now,
171
+ updatedAt: now,
172
+ },
173
+ })
174
+ storage.saveSessions({
175
+ session_plain: {
176
+ id: 'session_plain',
177
+ name: 'Non-connector session',
178
+ cwd: process.cwd(),
179
+ user: 'tester',
180
+ provider: 'openai',
181
+ model: 'gpt-test',
182
+ credentialId: null,
183
+ apiEndpoint: null,
184
+ claudeSessionId: null,
185
+ codexThreadId: null,
186
+ opencodeSessionId: null,
187
+ delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
188
+ messages: [],
189
+ createdAt: now - 60_000,
190
+ lastActiveAt: now - 1_000,
191
+ sessionType: 'human',
192
+ agentId: 'agent_2',
193
+ plugins: [],
194
+ },
195
+ })
196
+
197
+ const approval = approvals.requestApproval({
198
+ category: 'task_tool',
199
+ title: 'Approve outbound outreach',
200
+ description: 'Need your approval before sending a message.',
201
+ data: {},
202
+ sessionId: 'session_plain',
203
+ agentId: 'agent_2',
204
+ })
205
+
206
+ const dueAt = approval.createdAt + 31_000
207
+ const first = approvals.listPendingApprovalsNeedingConnectorNotification({
208
+ now: dueAt,
209
+ runningConnectors: [
210
+ { id: 'conn-fallback', agentId: 'agent_2', supportsSend: true, configuredTargets: [], recentChannelId: 'dm-88' },
211
+ ],
212
+ })
213
+
214
+ approvals.markApprovalConnectorNotificationAttempt(approval.id, {
215
+ at: dueAt,
216
+ connectorId: 'conn-fallback',
217
+ channelId: 'dm-88',
218
+ lastError: 'connector temporarily unavailable',
219
+ })
220
+
221
+ const withinCooldown = approvals.listPendingApprovalsNeedingConnectorNotification({
222
+ now: dueAt + 5_000,
223
+ runningConnectors: [
224
+ { id: 'conn-fallback', agentId: 'agent_2', supportsSend: true, configuredTargets: [], recentChannelId: 'dm-88' },
225
+ ],
226
+ })
227
+
228
+ const afterCooldown = approvals.listPendingApprovalsNeedingConnectorNotification({
229
+ now: dueAt + (10 * 60_000) + 1_000,
230
+ runningConnectors: [
231
+ { id: 'conn-fallback', agentId: 'agent_2', supportsSend: true, configuredTargets: [], recentChannelId: 'dm-88' },
232
+ ],
233
+ })
234
+
235
+ const storedApproval = storage.loadApprovals()[approval.id]
236
+ console.log(JSON.stringify({
237
+ firstCount: first.length,
238
+ fallbackConnectorId: first[0]?.connectorId || null,
239
+ fallbackChannelId: first[0]?.channelId || null,
240
+ withinCooldownCount: withinCooldown.length,
241
+ afterCooldownCount: afterCooldown.length,
242
+ storedApproval,
243
+ }))
244
+ `)
245
+
246
+ assert.equal(output.firstCount, 1)
247
+ assert.equal(output.fallbackConnectorId, 'conn-fallback')
248
+ assert.equal(output.fallbackChannelId, 'dm-88')
249
+ assert.equal(output.withinCooldownCount, 0)
250
+ assert.equal(output.afterCooldownCount, 1)
251
+ assert.equal(output.storedApproval.connectorNotification.lastError, 'connector temporarily unavailable')
252
+ })
253
+ })