@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,341 @@
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-autonomy-test-'))
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('browser session persistence', () => {
36
+ it('isolates browser profiles by default and stores observations', () => {
37
+ const output = runWithTempDataDir(`
38
+ const storage = (await import('./src/lib/server/storage.ts')).default
39
+ const browserState = (await import('./src/lib/server/browser-state.ts')).default
40
+
41
+ const now = Date.now()
42
+ storage.saveSessions({
43
+ parent: {
44
+ id: 'parent',
45
+ name: 'parent',
46
+ cwd: process.cwd(),
47
+ user: 'tester',
48
+ provider: 'openai',
49
+ model: 'gpt-test',
50
+ claudeSessionId: null,
51
+ messages: [],
52
+ createdAt: now,
53
+ lastActiveAt: now,
54
+ browserProfileId: 'shared-profile',
55
+ },
56
+ child: {
57
+ id: 'child',
58
+ name: 'child',
59
+ cwd: process.cwd(),
60
+ user: 'tester',
61
+ provider: 'openai',
62
+ model: 'gpt-test',
63
+ claudeSessionId: null,
64
+ messages: [],
65
+ createdAt: now,
66
+ lastActiveAt: now,
67
+ parentSessionId: 'parent',
68
+ },
69
+ })
70
+
71
+ const resolved = browserState.ensureSessionBrowserProfileId('child')
72
+ browserState.upsertBrowserSessionRecord({ sessionId: 'child', status: 'active', lastAction: 'navigate' })
73
+ browserState.recordBrowserObservation('child', {
74
+ capturedAt: now,
75
+ url: 'https://example.com',
76
+ title: 'Example',
77
+ textPreview: 'hello world',
78
+ links: [],
79
+ forms: [],
80
+ tables: [],
81
+ })
82
+ browserState.markBrowserSessionClosed('child', 'finished')
83
+
84
+ console.log(JSON.stringify({
85
+ resolved,
86
+ session: storage.loadSessions().child,
87
+ state: browserState.loadBrowserSessionRecord('child'),
88
+ }))
89
+ `)
90
+
91
+ assert.equal(output.resolved.profileId, 'child')
92
+ assert.equal(output.resolved.inheritedFromSessionId, null)
93
+ assert.equal(output.session.browserProfileId, 'child')
94
+ assert.equal(output.state.currentUrl, 'https://example.com')
95
+ assert.equal(output.state.pageTitle, 'Example')
96
+ assert.equal(output.state.status, 'error')
97
+ assert.equal(output.state.lastError, 'finished')
98
+ })
99
+
100
+ it('isolates subagent browser profiles by default unless sharing is explicitly requested', () => {
101
+ const output = runWithTempDataDir(`
102
+ const mod = await import('./src/lib/server/session-tools/subagent.ts')
103
+ const { resolveSubagentBrowserProfileId } = mod.default || mod['module.exports'] || mod
104
+
105
+ const parent = {
106
+ id: 'parent-session',
107
+ browserProfileId: 'shared-profile',
108
+ }
109
+
110
+ console.log(JSON.stringify({
111
+ isolated: resolveSubagentBrowserProfileId(parent, 'child-session', false),
112
+ shared: resolveSubagentBrowserProfileId(parent, 'child-session', true),
113
+ }))
114
+ `)
115
+
116
+ assert.equal(output.isolated, 'child-session')
117
+ assert.equal(output.shared, 'shared-profile')
118
+ })
119
+ })
120
+
121
+ describe('durable watch jobs', () => {
122
+ it('triggers time, file, task, http, and webhook watches', () => {
123
+ const output = runWithTempDataDir(`
124
+ import fs from 'node:fs'
125
+ import path from 'node:path'
126
+ const storage = (await import('./src/lib/server/storage.ts')).default
127
+ const watchJobs = (await import('./src/lib/server/watch-jobs.ts')).default
128
+
129
+ const watchFile = path.join(process.env.DATA_DIR, 'watch.txt')
130
+ fs.writeFileSync(watchFile, 'build succeeded')
131
+
132
+ storage.saveTasks({
133
+ task_done: {
134
+ id: 'task_done',
135
+ title: 'Done',
136
+ status: 'completed',
137
+ result: 'ok',
138
+ createdAt: Date.now(),
139
+ updatedAt: Date.now(),
140
+ },
141
+ })
142
+
143
+ globalThis.fetch = async () => new Response('service healthy', { status: 200 })
144
+
145
+ const timeJob = await watchJobs.createWatchJob({
146
+ type: 'time',
147
+ resumeMessage: 'time wake',
148
+ target: {},
149
+ condition: {},
150
+ runAt: Date.now() - 1000,
151
+ })
152
+ const fileJob = await watchJobs.createWatchJob({
153
+ type: 'file',
154
+ resumeMessage: 'file wake',
155
+ target: { path: watchFile },
156
+ condition: { containsText: 'succeeded' },
157
+ })
158
+ const taskJob = await watchJobs.createWatchJob({
159
+ type: 'task',
160
+ resumeMessage: 'task wake',
161
+ target: { taskId: 'task_done' },
162
+ condition: { statusIn: ['completed'] },
163
+ })
164
+ const httpJob = await watchJobs.createWatchJob({
165
+ type: 'http',
166
+ resumeMessage: 'http wake',
167
+ target: { url: 'https://example.com/health' },
168
+ condition: { regex: 'healthy', threshold: 0 },
169
+ })
170
+ const webhookJob = await watchJobs.createWatchJob({
171
+ type: 'webhook',
172
+ resumeMessage: 'webhook wake',
173
+ target: { webhookId: 'wh_test' },
174
+ condition: { event: 'deploy.finished' },
175
+ })
176
+
177
+ const summary = await watchJobs.processDueWatchJobs(Date.now())
178
+ const webhookTriggered = watchJobs.triggerWebhookWatchJobs({
179
+ webhookId: 'wh_test',
180
+ event: 'deploy.finished',
181
+ payloadPreview: '{"ok":true}',
182
+ })
183
+
184
+ console.log(JSON.stringify({
185
+ summary,
186
+ time: watchJobs.getWatchJob(timeJob.id),
187
+ file: watchJobs.getWatchJob(fileJob.id),
188
+ task: watchJobs.getWatchJob(taskJob.id),
189
+ http: watchJobs.getWatchJob(httpJob.id),
190
+ webhook: watchJobs.getWatchJob(webhookJob.id),
191
+ webhookTriggeredCount: webhookTriggered.length,
192
+ }))
193
+ `)
194
+
195
+ assert.equal(output.summary.triggered >= 4, true)
196
+ assert.equal(output.time.status, 'triggered')
197
+ assert.equal(output.file.status, 'triggered')
198
+ assert.equal(output.task.status, 'triggered')
199
+ assert.equal(output.http.status, 'triggered')
200
+ assert.equal(output.http.result.regex, 'healthy')
201
+ assert.equal(output.webhook.status, 'triggered')
202
+ assert.equal(output.webhookTriggeredCount, 1)
203
+ })
204
+
205
+ it('triggers mailbox and approval waits from human-loop events', () => {
206
+ const output = runWithTempDataDir(`
207
+ const storage = (await import('./src/lib/server/storage.ts')).default
208
+ const watchJobs = (await import('./src/lib/server/watch-jobs.ts')).default
209
+ const mailboxMod = await import('./src/lib/server/session-mailbox.ts')
210
+ const approvalsMod = await import('./src/lib/server/approvals.ts')
211
+ const mailbox = mailboxMod.default || mailboxMod
212
+ const approvals = approvalsMod.default || approvalsMod
213
+
214
+ const now = Date.now()
215
+ storage.saveSessions({
216
+ human: {
217
+ id: 'human',
218
+ name: 'Human Session',
219
+ cwd: process.cwd(),
220
+ user: 'tester',
221
+ provider: 'openai',
222
+ model: 'gpt-test',
223
+ claudeSessionId: null,
224
+ messages: [],
225
+ mailbox: [],
226
+ createdAt: now,
227
+ lastActiveAt: now,
228
+ },
229
+ })
230
+
231
+ const mailboxJob = await watchJobs.createWatchJob({
232
+ type: 'mailbox',
233
+ resumeMessage: 'mailbox wake',
234
+ target: { sessionId: 'human' },
235
+ condition: { type: 'human_reply', correlationId: 'corr-1' },
236
+ })
237
+
238
+ const approval = approvals.requestApproval({
239
+ category: 'human_loop',
240
+ sessionId: 'human',
241
+ title: 'Approve deployment',
242
+ data: { env: 'prod' },
243
+ })
244
+
245
+ const approvalJob = await watchJobs.createWatchJob({
246
+ type: 'approval',
247
+ resumeMessage: 'approval wake',
248
+ target: { approvalId: approval.id },
249
+ condition: { statusIn: ['approved'] },
250
+ })
251
+
252
+ const envelope = mailbox.sendMailboxEnvelope({
253
+ toSessionId: 'human',
254
+ type: 'human_reply',
255
+ payload: 'ship it',
256
+ correlationId: 'corr-1',
257
+ })
258
+
259
+ await approvals.submitDecision(approval.id, true)
260
+ await new Promise((resolve) => setTimeout(resolve, 25))
261
+
262
+ console.log(JSON.stringify({
263
+ envelope,
264
+ mailboxJob: watchJobs.getWatchJob(mailboxJob.id),
265
+ approvalJob: watchJobs.getWatchJob(approvalJob.id),
266
+ approval: storage.loadApprovals()[approval.id],
267
+ mailboxState: storage.loadSessions().human.mailbox,
268
+ }))
269
+ `)
270
+
271
+ assert.equal(output.envelope.type, 'human_reply')
272
+ assert.equal(output.mailboxJob.status, 'triggered')
273
+ assert.equal(output.mailboxJob.result.type, 'human_reply')
274
+ assert.equal(output.approvalJob.status, 'triggered')
275
+ assert.equal(output.approvalJob.result.status, 'approved')
276
+ assert.equal(output.approval.status, 'approved')
277
+ assert.equal(Array.isArray(output.mailboxState), true)
278
+ assert.equal(output.mailboxState.length, 1)
279
+ })
280
+ })
281
+
282
+ describe('delegation jobs', () => {
283
+ it('preserves cancellation and recovers stale jobs', () => {
284
+ const output = runWithTempDataDir(`
285
+ const delegationJobs = (await import('./src/lib/server/delegation-jobs.ts')).default
286
+ const storage = (await import('./src/lib/server/storage.ts')).default
287
+
288
+ let cancelledCalls = 0
289
+ const cancelledJob = delegationJobs.createDelegationJob({
290
+ kind: 'delegate',
291
+ backend: 'codex',
292
+ parentSessionId: 'session-1',
293
+ task: 'cancel me',
294
+ })
295
+ delegationJobs.startDelegationJob(cancelledJob.id, { backend: 'codex' })
296
+ delegationJobs.registerDelegationRuntime(cancelledJob.id, {
297
+ cancel: () => { cancelledCalls += 1 },
298
+ })
299
+ delegationJobs.cancelDelegationJob(cancelledJob.id)
300
+ delegationJobs.completeDelegationJob(cancelledJob.id, 'should not override')
301
+ delegationJobs.failDelegationJob(cancelledJob.id, 'should also not override')
302
+
303
+ const completedJob = delegationJobs.createDelegationJob({
304
+ kind: 'subagent',
305
+ parentSessionId: 'session-1',
306
+ task: 'complete me',
307
+ })
308
+ delegationJobs.startDelegationJob(completedJob.id, { childSessionId: 'session-2' })
309
+ delegationJobs.completeDelegationJob(completedJob.id, 'done')
310
+
311
+ const staleJob = delegationJobs.createDelegationJob({
312
+ kind: 'delegate',
313
+ parentSessionId: 'session-1',
314
+ task: 'stale work',
315
+ })
316
+ delegationJobs.startDelegationJob(staleJob.id)
317
+ const staleRecord = delegationJobs.getDelegationJob(staleJob.id)
318
+ storage.upsertDelegationJob(staleJob.id, {
319
+ ...staleRecord,
320
+ updatedAt: Date.now() - 20 * 60_000,
321
+ })
322
+ const recovered = delegationJobs.recoverStaleDelegationJobs(15 * 60_000)
323
+
324
+ console.log(JSON.stringify({
325
+ cancelledCalls,
326
+ cancelled: delegationJobs.getDelegationJob(cancelledJob.id),
327
+ completed: delegationJobs.getDelegationJob(completedJob.id),
328
+ stale: delegationJobs.getDelegationJob(staleJob.id),
329
+ recovered,
330
+ }))
331
+ `)
332
+
333
+ assert.equal(output.cancelledCalls, 1)
334
+ assert.equal(output.cancelled.status, 'cancelled')
335
+ assert.equal(output.cancelled.result, null)
336
+ assert.equal(output.completed.status, 'completed')
337
+ assert.equal(output.completed.resultPreview, 'done')
338
+ assert.equal(output.stale.status, 'failed')
339
+ assert.equal(output.recovered, 1)
340
+ })
341
+ })
@@ -0,0 +1,118 @@
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 { after, before, describe, it } from 'node:test'
6
+
7
+ const originalEnv = {
8
+ BROWSER_PROFILES_DIR: process.env.BROWSER_PROFILES_DIR,
9
+ DATA_DIR: process.env.DATA_DIR,
10
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
11
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
12
+ }
13
+
14
+ let tempDir = ''
15
+ let browserState: typeof import('./browser-state')
16
+ let storage: typeof import('./storage')
17
+
18
+ function baseSession(id: string, extra: Record<string, unknown> = {}) {
19
+ const now = Date.now()
20
+ return {
21
+ id,
22
+ name: id,
23
+ cwd: process.cwd(),
24
+ user: 'tester',
25
+ provider: 'openai',
26
+ model: 'gpt-test',
27
+ claudeSessionId: null,
28
+ messages: [],
29
+ createdAt: now,
30
+ lastActiveAt: now,
31
+ ...extra,
32
+ }
33
+ }
34
+
35
+ before(async () => {
36
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-browser-state-'))
37
+ process.env.DATA_DIR = path.join(tempDir, 'data')
38
+ process.env.BROWSER_PROFILES_DIR = path.join(tempDir, 'browser-profiles')
39
+ process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
40
+ process.env.SWARMCLAW_BUILD_MODE = '1'
41
+ browserState = await import('./browser-state')
42
+ storage = await import('./storage')
43
+ })
44
+
45
+ after(() => {
46
+ if (originalEnv.BROWSER_PROFILES_DIR === undefined) delete process.env.BROWSER_PROFILES_DIR
47
+ else process.env.BROWSER_PROFILES_DIR = originalEnv.BROWSER_PROFILES_DIR
48
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
49
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
50
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
51
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
52
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
53
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
54
+ fs.rmSync(tempDir, { recursive: true, force: true })
55
+ })
56
+
57
+ describe('browser-state', () => {
58
+ it('defaults child sessions to their own browser profile id unless sharing is explicit', () => {
59
+ storage.saveSessions({
60
+ parent: baseSession('parent', { browserProfileId: 'shared-browser' }),
61
+ child: baseSession('child', { parentSessionId: 'parent' }),
62
+ })
63
+
64
+ const resolved = browserState.ensureSessionBrowserProfileId('child')
65
+ const sessions = storage.loadSessions()
66
+
67
+ assert.equal(resolved.profileId, 'child')
68
+ assert.equal(resolved.inheritedFromSessionId, null)
69
+ assert.equal(sessions.child.browserProfileId, 'child')
70
+ })
71
+
72
+ it('persists browser observations and close state', () => {
73
+ storage.saveSessions({
74
+ solo: baseSession('solo'),
75
+ })
76
+
77
+ const profile = browserState.ensureSessionBrowserProfileId('solo')
78
+ const profileDir = browserState.getBrowserProfileDir(profile.profileId)
79
+ assert.equal(fs.existsSync(profileDir), true)
80
+
81
+ browserState.upsertBrowserSessionRecord({
82
+ sessionId: 'solo',
83
+ profileId: profile.profileId,
84
+ profileDir,
85
+ status: 'active',
86
+ lastAction: 'browser_open',
87
+ })
88
+
89
+ browserState.recordBrowserObservation('solo', {
90
+ capturedAt: Date.now(),
91
+ url: 'https://example.com',
92
+ title: 'Example',
93
+ textPreview: 'Example domain',
94
+ links: [{ text: 'More information', href: 'https://iana.org/domains/example' }],
95
+ forms: [],
96
+ tables: [],
97
+ errors: [],
98
+ })
99
+
100
+ const observed = browserState.loadBrowserSessionRecord('solo')
101
+ assert.equal(observed?.currentUrl, 'https://example.com')
102
+ assert.equal(observed?.pageTitle, 'Example')
103
+ assert.equal(observed?.lastObservation?.links?.length, 1)
104
+
105
+ const closed = browserState.markBrowserSessionClosed('solo', 'browser crashed')
106
+ assert.equal(closed?.status, 'error')
107
+ assert.equal(closed?.lastError, 'browser crashed')
108
+
109
+ browserState.removeBrowserSessionRecord('solo')
110
+ assert.equal(browserState.loadBrowserSessionRecord('solo'), null)
111
+ })
112
+
113
+ it('creates profile directories under the configured data dir', () => {
114
+ const dir = browserState.getBrowserProfileDir('profile with spaces')
115
+ assert.equal(fs.existsSync(dir), true)
116
+ assert.equal(dir.startsWith(process.env.BROWSER_PROFILES_DIR!), true)
117
+ })
118
+ })
@@ -0,0 +1,123 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import type { BrowserObservation, BrowserSessionRecord, Session } from '@/types'
4
+ import { BROWSER_PROFILES_DIR } from './data-dir'
5
+ import {
6
+ deleteBrowserSession,
7
+ loadBrowserSessions,
8
+ loadSessions,
9
+ saveSessions,
10
+ upsertBrowserSession,
11
+ } from './storage'
12
+
13
+ function sanitizeToken(value: string): string {
14
+ const trimmed = value.trim()
15
+ const safe = trimmed.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
16
+ return safe || 'default'
17
+ }
18
+
19
+ export function normalizeBrowserProfileId(value: unknown): string {
20
+ return typeof value === 'string' && value.trim() ? sanitizeToken(value) : ''
21
+ }
22
+
23
+ export function getBrowserProfileDir(profileId: string): string {
24
+ if (!fs.existsSync(BROWSER_PROFILES_DIR)) fs.mkdirSync(BROWSER_PROFILES_DIR, { recursive: true })
25
+ const dir = path.join(BROWSER_PROFILES_DIR, sanitizeToken(profileId))
26
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
27
+ return dir
28
+ }
29
+
30
+ export function resolveBrowserProfileInfo(session: Session | Record<string, unknown> | null | undefined): {
31
+ profileId: string
32
+ inheritedFromSessionId: string | null
33
+ } {
34
+ const current = session && typeof session === 'object' ? session as Record<string, unknown> : {}
35
+ const direct = normalizeBrowserProfileId(current.browserProfileId)
36
+ if (direct) return { profileId: direct, inheritedFromSessionId: null }
37
+
38
+ const sessionId = typeof current.id === 'string' && current.id.trim() ? current.id.trim() : 'default'
39
+ return { profileId: sanitizeToken(sessionId), inheritedFromSessionId: null }
40
+ }
41
+
42
+ export function ensureSessionBrowserProfileId(sessionId: string): {
43
+ profileId: string
44
+ inheritedFromSessionId: string | null
45
+ } {
46
+ const sessions = loadSessions()
47
+ const session = sessions[sessionId]
48
+ if (!session) return { profileId: sanitizeToken(sessionId), inheritedFromSessionId: null }
49
+ const resolved = resolveBrowserProfileInfo(session)
50
+ if (session.browserProfileId !== resolved.profileId) {
51
+ session.browserProfileId = resolved.profileId
52
+ session.updatedAt = Date.now()
53
+ sessions[sessionId] = session
54
+ saveSessions(sessions)
55
+ }
56
+ return resolved
57
+ }
58
+
59
+ export function loadBrowserSessionRecord(sessionId: string): BrowserSessionRecord | null {
60
+ const all = loadBrowserSessions()
61
+ const raw = all[sessionId]
62
+ if (!raw || typeof raw !== 'object') return null
63
+ return raw as BrowserSessionRecord
64
+ }
65
+
66
+ function mergeArtifacts(current: BrowserSessionRecord['artifacts'], next: BrowserSessionRecord['artifacts']): BrowserSessionRecord['artifacts'] {
67
+ const merged = [...(current || []), ...(next || [])]
68
+ return merged.slice(-24)
69
+ }
70
+
71
+ export function upsertBrowserSessionRecord(input: Partial<BrowserSessionRecord> & { sessionId: string }): BrowserSessionRecord {
72
+ const now = Date.now()
73
+ const current = loadBrowserSessionRecord(input.sessionId)
74
+ const baseProfile = input.profileId
75
+ || current?.profileId
76
+ || ensureSessionBrowserProfileId(input.sessionId).profileId
77
+ const next: BrowserSessionRecord = {
78
+ id: input.sessionId,
79
+ sessionId: input.sessionId,
80
+ profileId: baseProfile,
81
+ profileDir: input.profileDir || current?.profileDir || getBrowserProfileDir(baseProfile),
82
+ status: input.status || current?.status || 'idle',
83
+ inheritedFromSessionId: input.inheritedFromSessionId ?? current?.inheritedFromSessionId ?? null,
84
+ currentUrl: input.currentUrl ?? current?.currentUrl ?? null,
85
+ pageTitle: input.pageTitle ?? current?.pageTitle ?? null,
86
+ activeTabIndex: input.activeTabIndex ?? current?.activeTabIndex ?? null,
87
+ tabs: input.tabs ?? current?.tabs ?? [],
88
+ lastAction: input.lastAction ?? current?.lastAction ?? null,
89
+ lastError: input.lastError ?? current?.lastError ?? null,
90
+ lastObservation: input.lastObservation ?? current?.lastObservation ?? null,
91
+ artifacts: mergeArtifacts(current?.artifacts, input.artifacts),
92
+ createdAt: current?.createdAt || input.createdAt || now,
93
+ updatedAt: now,
94
+ lastUsedAt: input.lastUsedAt || now,
95
+ }
96
+ upsertBrowserSession(next.id, next)
97
+ return next
98
+ }
99
+
100
+ export function recordBrowserObservation(sessionId: string, observation: BrowserObservation): BrowserSessionRecord {
101
+ return upsertBrowserSessionRecord({
102
+ sessionId,
103
+ currentUrl: observation.url ?? null,
104
+ pageTitle: observation.title ?? null,
105
+ activeTabIndex: observation.activeTabIndex ?? null,
106
+ tabs: observation.tabs ?? [],
107
+ lastObservation: observation,
108
+ })
109
+ }
110
+
111
+ export function markBrowserSessionClosed(sessionId: string, error?: string | null): BrowserSessionRecord | null {
112
+ const current = loadBrowserSessionRecord(sessionId)
113
+ if (!current) return null
114
+ return upsertBrowserSessionRecord({
115
+ sessionId,
116
+ status: error ? 'error' : 'closed',
117
+ lastError: error ?? null,
118
+ })
119
+ }
120
+
121
+ export function removeBrowserSessionRecord(sessionId: string): void {
122
+ deleteBrowserSession(sessionId)
123
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { ChatOpenAI } from '@langchain/openai'
4
+ import {
5
+ buildChatModel,
6
+ OPENAI_COMPAT_MODEL_MAX_RETRIES,
7
+ OPENAI_COMPAT_MODEL_TIMEOUT_MS,
8
+ } from './build-llm'
9
+
10
+ type ChatOpenAiInternals = ChatOpenAI & {
11
+ timeout?: number
12
+ caller?: { maxRetries?: number }
13
+ clientConfig?: { defaultHeaders?: Record<string, string> }
14
+ }
15
+
16
+ describe('buildChatModel', () => {
17
+ it('applies bounded timeout and disables internal retries for openai-compatible models', () => {
18
+ const llm = buildChatModel({
19
+ provider: 'openai',
20
+ model: 'gpt-4o',
21
+ apiKey: 'test-key',
22
+ })
23
+ const model = llm as ChatOpenAiInternals
24
+
25
+ assert.equal(llm instanceof ChatOpenAI, true)
26
+ assert.equal(model.timeout, OPENAI_COMPAT_MODEL_TIMEOUT_MS)
27
+ assert.equal(model.caller?.maxRetries, OPENAI_COMPAT_MODEL_MAX_RETRIES)
28
+ })
29
+
30
+ it('preserves openclaw headers while applying the same timeout policy', () => {
31
+ const llm = buildChatModel({
32
+ provider: 'openclaw',
33
+ model: 'gpt-4o',
34
+ apiKey: 'test-key',
35
+ apiEndpoint: 'https://example.com/v1',
36
+ })
37
+ const model = llm as ChatOpenAiInternals
38
+
39
+ assert.equal(llm instanceof ChatOpenAI, true)
40
+ assert.equal(model.timeout, OPENAI_COMPAT_MODEL_TIMEOUT_MS)
41
+ assert.equal(model.caller?.maxRetries, OPENAI_COMPAT_MODEL_MAX_RETRIES)
42
+ assert.deepEqual(model.clientConfig?.defaultHeaders, { 'Content-Type': 'text/plain' })
43
+ })
44
+ })
@@ -7,6 +7,8 @@ import { NON_LANGGRAPH_PROVIDER_IDS } from '../provider-sets'
7
7
 
8
8
  const OLLAMA_CLOUD_URL = 'https://ollama.com/v1'
9
9
  const OLLAMA_LOCAL_URL = 'http://localhost:11434/v1'
10
+ export const OPENAI_COMPAT_MODEL_TIMEOUT_MS = 180_000
11
+ export const OPENAI_COMPAT_MODEL_MAX_RETRIES = 0
10
12
 
11
13
  /**
12
14
  * Build a LangChain chat model from provider config.
@@ -50,12 +52,19 @@ export function buildChatModel(opts: {
50
52
  return new ChatOpenAI({
51
53
  model: model || 'qwen3.5',
52
54
  apiKey: apiKey || 'ollama',
55
+ timeout: OPENAI_COMPAT_MODEL_TIMEOUT_MS,
56
+ maxRetries: OPENAI_COMPAT_MODEL_MAX_RETRIES,
53
57
  configuration: { baseURL },
54
58
  })
55
59
  }
56
60
 
57
61
  // All other providers — OpenAI-compatible with their registered endpoint
58
- const config: any = { model: model || 'gpt-4o', apiKey: apiKey || undefined }
62
+ const config: any = {
63
+ model: model || 'gpt-4o',
64
+ apiKey: apiKey || undefined,
65
+ timeout: OPENAI_COMPAT_MODEL_TIMEOUT_MS,
66
+ maxRetries: OPENAI_COMPAT_MODEL_MAX_RETRIES,
67
+ }
59
68
  // Map thinking level to reasoning_effort for OpenAI o-series models
60
69
  if (thinkingLevel && provider === 'openai' && /^o\d/.test(model || '')) {
61
70
  const effortMap = { minimal: 'low', low: 'low', medium: 'medium', high: 'high' }
@@ -70,9 +79,7 @@ export function buildChatModel(opts: {
70
79
  config.configuration.defaultHeaders = { 'Content-Type': 'text/plain' }
71
80
  }
72
81
  }
73
- return config.configuration
74
- ? new ChatOpenAI(config)
75
- : new ChatOpenAI({ model: config.model, apiKey: config.apiKey })
82
+ return new ChatOpenAI(config)
76
83
  }
77
84
 
78
85
  function resolveApiKeyFromCredential(credentialId: string | null | undefined): string | null {