@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
@@ -13,6 +13,14 @@ import { withRetry } from '../tool-retry'
13
13
  import type { Plugin, PluginHooks } from '@/types'
14
14
  import { getPluginManager } from '../plugins'
15
15
  import { normalizeToolInputArgs } from './normalize-tool-args'
16
+ import {
17
+ ensureSessionBrowserProfileId,
18
+ getBrowserProfileDir,
19
+ markBrowserSessionClosed,
20
+ recordBrowserObservation,
21
+ removeBrowserSessionRecord,
22
+ upsertBrowserSessionRecord,
23
+ } from '../browser-state'
16
24
 
17
25
  // --- Search result compression logic ---
18
26
  async function compressSearchResults(results: any[], query: string, bctx: any): Promise<string | null> {
@@ -48,13 +56,91 @@ async function compressSearchResults(results: any[], query: string, bctx: any):
48
56
  return compressed.trim() || null
49
57
  }
50
58
 
51
- export const activeBrowsers = new Map<string, { client: any; server: any; createdAt: number }>()
59
+ type BrowserRuntimeEntry = {
60
+ client: any
61
+ server: any
62
+ createdAt: number
63
+ profileId: string
64
+ profileDir: string
65
+ refCount: number
66
+ }
67
+
68
+ export const activeBrowsers = new Map<string, BrowserRuntimeEntry>()
69
+ const pendingBrowserInitializations = new Map<string, Promise<BrowserRuntimeEntry>>()
70
+
71
+ export function buildBrowserConnectionOptions(profileDir: string) {
72
+ return {
73
+ browser: {
74
+ userDataDir: profileDir,
75
+ launchOptions: { headless: true },
76
+ contextOptions: {
77
+ viewport: { width: 1440, height: 900 },
78
+ },
79
+ },
80
+ imageResponses: 'allow' as const,
81
+ capabilities: ['core', 'pdf', 'vision', 'network', 'storage'],
82
+ // Keep browser state isolated per session/profile. The upstream shared
83
+ // context mode is process-global and causes unrelated agent sessions to
84
+ // contend with each other.
85
+ sharedBrowserContext: false,
86
+ timeouts: {
87
+ action: 15_000,
88
+ navigation: 60_000,
89
+ },
90
+ }
91
+ }
92
+
93
+ export function buildBrowserStdioServerParams(profileDir: string) {
94
+ const cliCandidates = [
95
+ path.join(process.cwd(), 'node_modules', '@playwright', 'mcp', 'cli.js'),
96
+ path.join(process.cwd(), '[project]', 'node_modules', '@playwright', 'mcp', 'cli.js'),
97
+ ]
98
+ const cliPath = cliCandidates.find((candidate) => fs.existsSync(candidate)) || cliCandidates[0]
99
+ const outputDir = path.join(profileDir, 'mcp-output')
100
+ const env = sanitizePlaywrightMcpEnv()
101
+ return {
102
+ command: process.execPath,
103
+ args: [
104
+ cliPath,
105
+ '--headless',
106
+ '--user-data-dir', profileDir,
107
+ '--output-dir', outputDir,
108
+ '--caps', 'vision,pdf',
109
+ '--image-responses', 'allow',
110
+ '--output-mode', 'file',
111
+ '--timeout-action', '15000',
112
+ '--timeout-navigation', '60000',
113
+ ],
114
+ env: {
115
+ ...env,
116
+ PLAYWRIGHT_MCP_USER_DATA_DIR: profileDir,
117
+ PLAYWRIGHT_MCP_HEADLESS: '1',
118
+ PLAYWRIGHT_MCP_IMAGE_RESPONSES: 'allow',
119
+ PLAYWRIGHT_MCP_OUTPUT_DIR: outputDir,
120
+ PLAYWRIGHT_MCP_OUTPUT_MODE: 'file',
121
+ PLAYWRIGHT_MCP_TIMEOUT_ACTION: '15000',
122
+ PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION: '60000',
123
+ },
124
+ stderr: 'inherit' as const,
125
+ }
126
+ }
127
+
128
+ export function sanitizePlaywrightMcpEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
129
+ const env: NodeJS.ProcessEnv = { ...baseEnv }
130
+ for (const key of Object.keys(env)) {
131
+ if (!key.toUpperCase().startsWith('PLAYWRIGHT_MCP_')) continue
132
+ delete env[key]
133
+ }
134
+ return env
135
+ }
52
136
  export function sweepOrphanedBrowsers(maxAgeMs = 30 * 60 * 1000): number {
53
137
  const now = Date.now(); let cleaned = 0
54
138
  for (const [key, entry] of activeBrowsers) {
55
139
  if (now - entry.createdAt > maxAgeMs) {
56
140
  try { entry.client?.close?.() } catch { /* ignore */ }
57
141
  try { entry.server?.close?.() } catch { /* ignore */ }
142
+ pendingBrowserInitializations.delete(key)
143
+ markBrowserSessionClosed(key, 'Browser was swept after inactivity.')
58
144
  activeBrowsers.delete(key); cleaned++
59
145
  }
60
146
  }
@@ -66,6 +152,8 @@ export function cleanupSessionBrowser(sessionId: string): void {
66
152
  try { entry.client?.close?.() } catch { /* ignore */ }
67
153
  try { entry.server?.close?.() } catch { /* ignore */ }
68
154
  activeBrowsers.delete(sessionId)
155
+ pendingBrowserInitializations.delete(sessionId)
156
+ markBrowserSessionClosed(sessionId)
69
157
  }
70
158
  }
71
159
  export function getActiveBrowserCount(): number { return activeBrowsers.size }
@@ -134,7 +222,7 @@ const WebPlugin: Plugin = {
134
222
  name: 'Core Web',
135
223
  description: 'Search the web and fetch content from URLs.',
136
224
  hooks: {
137
- getCapabilityDescription: () => 'I can search the web (`web_search`) for research, fact-checking, and discovery.',
225
+ getCapabilityDescription: () => 'I can use the unified `web` tool with action `search` for research and action `fetch` for reading a URL.',
138
226
  } as PluginHooks,
139
227
  tools: [
140
228
  {
@@ -180,35 +268,108 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
180
268
  // Browser tool (kept as direct injection for now due to complexity)
181
269
  if (bctx.hasPlugin('browser')) {
182
270
  const sessionKey = ctx?.sessionId || `anon-${Date.now()}`
271
+ const currentSession = bctx.resolveCurrentSession?.()
272
+ const profileInfo = currentSession?.id
273
+ ? ensureSessionBrowserProfileId(sessionKey)
274
+ : { profileId: sessionKey, inheritedFromSessionId: null as string | null }
275
+ const profileDir = getBrowserProfileDir(profileInfo.profileId)
183
276
  let mcpClient: any = null
184
277
  let mcpServer: any = null
185
278
  let mcpInitializing: Promise<void> | null = null
279
+ let browserLeaseHeld = false
280
+
281
+ upsertBrowserSessionRecord({
282
+ sessionId: sessionKey,
283
+ profileId: profileInfo.profileId,
284
+ profileDir,
285
+ inheritedFromSessionId: profileInfo.inheritedFromSessionId,
286
+ status: 'idle',
287
+ })
186
288
 
187
289
  const ensureMcp = (): Promise<void> => {
188
290
  if (mcpClient) return Promise.resolve()
189
291
  if (mcpInitializing) return mcpInitializing
292
+ const acquireExistingEntry = (entry: BrowserRuntimeEntry) => {
293
+ mcpClient = entry.client
294
+ mcpServer = entry.server
295
+ if (!browserLeaseHeld) {
296
+ entry.refCount = Math.max(0, entry.refCount || 0) + 1
297
+ activeBrowsers.set(sessionKey, entry)
298
+ browserLeaseHeld = true
299
+ }
300
+ }
301
+ const existing = activeBrowsers.get(sessionKey)
302
+ if (existing) {
303
+ acquireExistingEntry(existing)
304
+ return Promise.resolve()
305
+ }
190
306
  mcpInitializing = (async () => {
191
- const { createConnection } = await import('@playwright/mcp')
192
- const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
193
- const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js')
194
- const server = await createConnection({
195
- browser: { launchOptions: { headless: true }, isolated: true },
196
- imageResponses: 'allow', capabilities: ['core', 'pdf', 'vision', 'network'],
197
- })
198
- const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
199
- const client = new Client({ name: 'swarmclaw', version: '1.0' })
200
- await Promise.all([client.connect(clientTransport), server.connect(serverTransport)])
201
- mcpClient = client; mcpServer = server
202
- activeBrowsers.set(sessionKey, { client, server, createdAt: Date.now() })
307
+ try {
308
+ const pending = pendingBrowserInitializations.get(sessionKey)
309
+ if (pending) {
310
+ acquireExistingEntry(await pending)
311
+ return
312
+ }
313
+
314
+ const connectPromise = (async () => {
315
+ const { Client } = await import('@modelcontextprotocol/sdk/client/index.js')
316
+ const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js')
317
+ const transport = new StdioClientTransport(buildBrowserStdioServerParams(profileDir))
318
+ const client = new Client({ name: 'swarmclaw', version: '1.0' })
319
+ await client.connect(transport)
320
+ return {
321
+ client,
322
+ server: transport,
323
+ createdAt: Date.now(),
324
+ profileId: profileInfo.profileId,
325
+ profileDir,
326
+ refCount: 0,
327
+ }
328
+ })()
329
+ pendingBrowserInitializations.set(sessionKey, connectPromise)
330
+ const entry = await connectPromise
331
+ acquireExistingEntry(entry)
332
+ upsertBrowserSessionRecord({
333
+ sessionId: sessionKey,
334
+ profileId: profileInfo.profileId,
335
+ profileDir,
336
+ inheritedFromSessionId: profileInfo.inheritedFromSessionId,
337
+ status: 'active',
338
+ lastAction: 'browser_open',
339
+ })
340
+ } finally {
341
+ if (pendingBrowserInitializations.get(sessionKey)) {
342
+ pendingBrowserInitializations.delete(sessionKey)
343
+ }
344
+ mcpInitializing = null
345
+ }
203
346
  })()
204
347
  return mcpInitializing
205
348
  }
206
349
 
207
350
  cleanupFns.push(async () => {
208
- try { mcpClient?.close?.() } catch { /* ignore */ }
209
- try { mcpServer?.close?.() } catch { /* ignore */ }
210
- activeBrowsers.delete(sessionKey)
211
- mcpClient = null; mcpServer = null
351
+ pendingBrowserInitializations.delete(sessionKey)
352
+ const entry = activeBrowsers.get(sessionKey)
353
+ const ownsActiveEntry = !!entry && entry.client === mcpClient && entry.server === mcpServer
354
+ if (ownsActiveEntry && browserLeaseHeld) {
355
+ entry.refCount = Math.max(0, (entry.refCount || 1) - 1)
356
+ if (entry.refCount === 0) {
357
+ try { entry.client?.close?.() } catch { /* ignore */ }
358
+ try { entry.server?.close?.() } catch { /* ignore */ }
359
+ activeBrowsers.delete(sessionKey)
360
+ markBrowserSessionClosed(sessionKey)
361
+ } else {
362
+ activeBrowsers.set(sessionKey, entry)
363
+ }
364
+ } else {
365
+ try { mcpClient?.close?.() } catch { /* ignore */ }
366
+ try { mcpServer?.close?.() } catch { /* ignore */ }
367
+ if (browserLeaseHeld) markBrowserSessionClosed(sessionKey)
368
+ }
369
+ mcpClient = null
370
+ mcpServer = null
371
+ mcpInitializing = null
372
+ browserLeaseHeld = false
212
373
  })
213
374
 
214
375
  const cleanPlaywrightOutput = (text: string): string => {
@@ -222,148 +383,826 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
222
383
  return text.replace(/\n{3,}/g, '\n').trim()
223
384
  }
224
385
 
386
+ const extractJsonPayload = (text: string): Record<string, unknown> | unknown[] | null => {
387
+ const candidates = [
388
+ [text.indexOf('{'), text.lastIndexOf('}')],
389
+ [text.indexOf('['), text.lastIndexOf(']')],
390
+ ]
391
+ for (const [start, end] of candidates) {
392
+ if (start === -1 || end === -1 || end <= start) continue
393
+ try {
394
+ return JSON.parse(text.slice(start, end + 1))
395
+ } catch {
396
+ // try next candidate
397
+ }
398
+ }
399
+ return null
400
+ }
401
+
402
+ const stringifyStructured = (value: unknown): string => truncate(JSON.stringify(value, null, 2), MAX_OUTPUT)
403
+ const callBrowserEvaluate = (fn: string) => callMcpTool('browser_evaluate', {
404
+ function: fn,
405
+ })
406
+
407
+ const captureStructuredObservation = async () => {
408
+ const expression = `() => {
409
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
410
+ const visible = (el) => {
411
+ if (!el) return false;
412
+ const style = window.getComputedStyle(el);
413
+ return style && style.display !== 'none' && style.visibility !== 'hidden';
414
+ };
415
+ const links = Array.from(document.querySelectorAll('a[href]'))
416
+ .filter(visible)
417
+ .slice(0, 25)
418
+ .map((a) => ({
419
+ text: normalize(a.innerText || a.textContent || a.getAttribute('aria-label')),
420
+ href: a.href || a.getAttribute('href') || '',
421
+ }))
422
+ .filter((entry) => entry.href);
423
+ const forms = Array.from(document.forms).slice(0, 5).map((form, index) => ({
424
+ index,
425
+ action: form.getAttribute('action') || form.action || null,
426
+ method: normalize(form.getAttribute('method') || form.method || 'get') || 'get',
427
+ fields: Array.from(form.elements).slice(0, 20).map((el) => ({
428
+ name: el.getAttribute?.('name') || null,
429
+ label: normalize(el.labels?.[0]?.innerText || el.getAttribute?.('aria-label') || el.getAttribute?.('placeholder')) || null,
430
+ type: normalize(el.getAttribute?.('type') || el.tagName || 'field').toLowerCase(),
431
+ required: !!el.required,
432
+ })),
433
+ }));
434
+ const tables = Array.from(document.querySelectorAll('table')).slice(0, 3).map((table, index) => {
435
+ const headerCells = Array.from(table.querySelectorAll('thead th')).map((th) => normalize(th.innerText || th.textContent));
436
+ const bodyRows = Array.from(table.querySelectorAll('tbody tr')).slice(0, 5).map((tr) =>
437
+ Array.from(tr.querySelectorAll('th, td')).map((cell) => normalize(cell.innerText || cell.textContent))
438
+ );
439
+ return {
440
+ index,
441
+ headers: headerCells,
442
+ rowCount: table.querySelectorAll('tbody tr').length,
443
+ rows: bodyRows,
444
+ };
445
+ });
446
+ const errors = Array.from(document.querySelectorAll('[aria-invalid="true"], .error, .field-error, .invalid, [role="alert"]'))
447
+ .filter(visible)
448
+ .slice(0, 10)
449
+ .map((el) => normalize(el.innerText || el.textContent))
450
+ .filter(Boolean);
451
+ const textPreview = normalize(document.body?.innerText || document.body?.textContent || '').slice(0, 1200);
452
+ const lowerPreview = textPreview.toLowerCase();
453
+ const notices = [];
454
+ if (/ask the human|out-of-band|do not guess|verification code required/.test(lowerPreview)) {
455
+ notices.push({
456
+ type: 'human_input_required',
457
+ message: 'This page requires human-provided input. Ask the human instead of guessing or repeatedly submitting blank values.',
458
+ });
459
+ }
460
+ return {
461
+ url: window.location.href,
462
+ title: document.title || null,
463
+ textPreview,
464
+ links,
465
+ forms,
466
+ tables,
467
+ errors,
468
+ notices,
469
+ };
470
+ }`
471
+ const raw = await callBrowserEvaluate(expression)
472
+ const parsed = extractJsonPayload(raw)
473
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
474
+ const observation = {
475
+ capturedAt: Date.now(),
476
+ ...parsed,
477
+ } as any
478
+ recordBrowserObservation(sessionKey, observation)
479
+ return observation
480
+ }
481
+ const fallback = {
482
+ capturedAt: Date.now(),
483
+ url: null,
484
+ title: null,
485
+ textPreview: cleanPlaywrightOutput(raw).slice(0, 1200),
486
+ }
487
+ recordBrowserObservation(sessionKey, fallback)
488
+ return fallback
489
+ }
490
+
225
491
  const MCP_CALL_TIMEOUT_MS = 30000 // 30s timeout per browser action
226
492
  const callMcpTool = async (toolName: string, args: Record<string, any>, options?: { saveTo?: string }): Promise<string> => {
227
- await ensureMcp()
228
- const result = await Promise.race([
229
- mcpClient.callTool({ name: toolName, arguments: args }),
230
- new Promise<never>((_resolve, reject) =>
231
- setTimeout(() => reject(new Error(`Browser action "${toolName}" timed out after ${MCP_CALL_TIMEOUT_MS / 1000}s`)), MCP_CALL_TIMEOUT_MS)
232
- ),
233
- ])
234
- const isError = result?.isError === true; const content = result?.content; const savedPaths: string[] = []
235
- const saveArtifact = (buffer: Buffer, suggestedExt: string): void => {
236
- const rawSaveTo = options?.saveTo?.trim()
237
- if (!rawSaveTo) return
238
- let resolved = safePath(cwd, rawSaveTo)
239
- if (!path.extname(resolved) && suggestedExt) resolved = `${resolved}.${suggestedExt}`
240
- fs.mkdirSync(path.dirname(resolved), { recursive: true }); fs.writeFileSync(resolved, buffer)
241
- savedPaths.push(resolved)
242
- }
243
- if (Array.isArray(content)) {
244
- let parts: string[] = []
245
- const isScreenshotTool = toolName === 'browser_take_screenshot'
246
- const contentHasBinaryImage = content.some((c) => c.type === 'image' && !!c.data)
247
- for (const c of content) {
248
- if (c.type === 'image' && c.data) {
249
- const imageBuffer = Buffer.from(c.data, 'base64'); const filename = `screenshot-${Date.now()}.png`
250
- const filepath = path.join(UPLOAD_DIR, filename); fs.writeFileSync(filepath, imageBuffer)
251
- saveArtifact(imageBuffer, 'png'); parts.push(`![Screenshot](/api/uploads/${filename})`)
252
- } else if (c.type === 'resource' && c.resource?.blob) {
253
- const ext = c.resource.mimeType?.includes('pdf') ? 'pdf' : 'bin'
254
- const resourceBuffer = Buffer.from(c.resource.blob, 'base64'); const filename = `browser-${Date.now()}.${ext}`
255
- const filepath = path.join(UPLOAD_DIR, filename); fs.writeFileSync(filepath, resourceBuffer)
256
- saveArtifact(resourceBuffer, ext); parts.push(`[Download ${filename}](/api/uploads/${filename})`)
257
- } else {
258
- let text = c.text || ''
259
- const fileMatch = text.match(/\]\((\.\.\/[^\s)]+|\/[^\s)]+\.(pdf|png|jpg|jpeg|gif|webp|html|mp4|webm))\)/)
260
- if (fileMatch) {
261
- const rawPath = fileMatch[1]; const srcPath = rawPath.startsWith('/') ? rawPath : path.resolve(process.cwd(), rawPath)
262
- if (fs.existsSync(srcPath)) {
263
- const ext = path.extname(srcPath).slice(1).toLowerCase(); const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp']
264
- if (IMAGE_EXTS.includes(ext) && contentHasBinaryImage) parts.push(isError ? text : cleanPlaywrightOutput(text))
265
- else {
266
- const filename = `browser-${Date.now()}.${ext}`; const destPath = path.join(UPLOAD_DIR, filename); fs.copyFileSync(srcPath, destPath)
267
- if (options?.saveTo?.trim()) {
268
- let targetPath = safePath(cwd, options.saveTo.trim())
269
- if (!path.extname(targetPath)) targetPath = `${targetPath}.${ext}`
270
- fs.mkdirSync(path.dirname(targetPath), { recursive: true }); fs.copyFileSync(srcPath, targetPath)
271
- savedPaths.push(targetPath)
493
+ const rawCall = async (): Promise<string> => {
494
+ try {
495
+ await ensureMcp()
496
+ const result = await Promise.race([
497
+ mcpClient.callTool({ name: toolName, arguments: args }),
498
+ new Promise<never>((_resolve, reject) =>
499
+ setTimeout(() => reject(new Error(`Browser action "${toolName}" timed out after ${MCP_CALL_TIMEOUT_MS / 1000}s`)), MCP_CALL_TIMEOUT_MS),
500
+ ),
501
+ ])
502
+ const isError = result?.isError === true
503
+ const content = result?.content
504
+ const savedPaths: string[] = []
505
+ const artifacts: Array<{ kind: 'snapshot' | 'screenshot' | 'download' | 'pdf'; path: string; url?: string | null; filename?: string | null; createdAt: number }> = []
506
+ const saveArtifact = (buffer: Buffer, suggestedExt: string): void => {
507
+ const rawSaveTo = options?.saveTo?.trim()
508
+ if (!rawSaveTo) return
509
+ let resolved = safePath(cwd, rawSaveTo)
510
+ if (!path.extname(resolved) && suggestedExt) resolved = `${resolved}.${suggestedExt}`
511
+ fs.mkdirSync(path.dirname(resolved), { recursive: true })
512
+ fs.writeFileSync(resolved, buffer)
513
+ savedPaths.push(resolved)
514
+ }
515
+ if (Array.isArray(content)) {
516
+ let parts: string[] = []
517
+ const isScreenshotTool = toolName === 'browser_take_screenshot'
518
+ const contentHasBinaryImage = content.some((c) => c.type === 'image' && !!c.data)
519
+ for (const c of content) {
520
+ if (c.type === 'image' && c.data) {
521
+ const imageBuffer = Buffer.from(c.data, 'base64')
522
+ const filename = `screenshot-${Date.now()}.png`
523
+ const filepath = path.join(UPLOAD_DIR, filename)
524
+ fs.writeFileSync(filepath, imageBuffer)
525
+ saveArtifact(imageBuffer, 'png')
526
+ artifacts.push({ kind: 'screenshot', path: filepath, url: `/api/uploads/${filename}`, filename, createdAt: Date.now() })
527
+ parts.push(`Screenshot saved to /api/uploads/${filename}`)
528
+ parts.push(`![Screenshot](/api/uploads/${filename})`)
529
+ } else if (c.type === 'resource' && c.resource?.blob) {
530
+ const ext = c.resource.mimeType?.includes('pdf') ? 'pdf' : 'bin'
531
+ const resourceBuffer = Buffer.from(c.resource.blob, 'base64')
532
+ const filename = `browser-${Date.now()}.${ext}`
533
+ const filepath = path.join(UPLOAD_DIR, filename)
534
+ fs.writeFileSync(filepath, resourceBuffer)
535
+ saveArtifact(resourceBuffer, ext)
536
+ artifacts.push({
537
+ kind: ext === 'pdf' ? 'pdf' : 'download',
538
+ path: filepath,
539
+ url: `/api/uploads/${filename}`,
540
+ filename,
541
+ createdAt: Date.now(),
542
+ })
543
+ parts.push(`[Download ${filename}](/api/uploads/${filename})`)
544
+ } else {
545
+ const text = c.text || ''
546
+ const fileMatch = text.match(/\]\((\.\.\/[^\s)]+|\/[^\s)]+\.(pdf|png|jpg|jpeg|gif|webp|html|mp4|webm))\)/)
547
+ if (fileMatch) {
548
+ const rawPath = fileMatch[1]
549
+ const srcPath = rawPath.startsWith('/') ? rawPath : path.resolve(process.cwd(), rawPath)
550
+ if (fs.existsSync(srcPath)) {
551
+ const ext = path.extname(srcPath).slice(1).toLowerCase()
552
+ const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp']
553
+ if (IMAGE_EXTS.includes(ext) && contentHasBinaryImage) {
554
+ continue
555
+ } else {
556
+ const filename = `browser-${Date.now()}.${ext}`
557
+ const destPath = path.join(UPLOAD_DIR, filename)
558
+ fs.copyFileSync(srcPath, destPath)
559
+ if (options?.saveTo?.trim()) {
560
+ let targetPath = safePath(cwd, options.saveTo.trim())
561
+ if (!path.extname(targetPath)) targetPath = `${targetPath}.${ext}`
562
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true })
563
+ fs.copyFileSync(srcPath, targetPath)
564
+ savedPaths.push(targetPath)
565
+ }
566
+ artifacts.push({
567
+ kind: ext === 'pdf' ? 'pdf' : 'download',
568
+ path: destPath,
569
+ url: `/api/uploads/${filename}`,
570
+ filename,
571
+ createdAt: Date.now(),
572
+ })
573
+ parts.push(IMAGE_EXTS.includes(ext) ? `![Screenshot](/api/uploads/${filename})` : `[Download ${filename}](/api/uploads/${filename})`)
574
+ }
575
+ } else {
576
+ parts.push(isError ? text : cleanPlaywrightOutput(text))
272
577
  }
273
- parts.push(IMAGE_EXTS.includes(ext) ? `![Screenshot](/api/uploads/${filename})` : `[Download ${filename}](/api/uploads/${filename})`)
578
+ } else {
579
+ parts.push(isError ? text : cleanPlaywrightOutput(text))
274
580
  }
275
- } else parts.push(isError ? text : cleanPlaywrightOutput(text))
276
- } else parts.push(isError ? text : cleanPlaywrightOutput(text))
581
+ }
582
+ }
583
+ if (isScreenshotTool) parts = dedupeScreenshotMarkdownLines(parts)
584
+ if (savedPaths.length > 0) {
585
+ const unique = Array.from(new Set(savedPaths))
586
+ parts.push(`Saved to: ${unique.map((p) => path.relative(cwd, p) || '.').join(', ')}`)
587
+ }
588
+ upsertBrowserSessionRecord({
589
+ sessionId: sessionKey,
590
+ profileId: profileInfo.profileId,
591
+ profileDir,
592
+ status: 'active',
593
+ lastAction: toolName,
594
+ lastError: isError ? parts.join('\n').slice(0, 1000) : null,
595
+ artifacts,
596
+ })
597
+ return parts.join('\n')
277
598
  }
599
+ const fallback = JSON.stringify(result)
600
+ upsertBrowserSessionRecord({
601
+ sessionId: sessionKey,
602
+ profileId: profileInfo.profileId,
603
+ profileDir,
604
+ status: 'active',
605
+ lastAction: toolName,
606
+ lastError: isError ? fallback.slice(0, 1000) : null,
607
+ })
608
+ return fallback
609
+ } catch (err: unknown) {
610
+ const message = err instanceof Error ? err.message : String(err)
611
+ upsertBrowserSessionRecord({
612
+ sessionId: sessionKey,
613
+ profileId: profileInfo.profileId,
614
+ profileDir,
615
+ status: 'error',
616
+ lastAction: toolName,
617
+ lastError: message,
618
+ })
619
+ return `Error: ${message}`
278
620
  }
279
- if (isScreenshotTool) parts = dedupeScreenshotMarkdownLines(parts)
280
- if (savedPaths.length > 0) {
281
- const unique = Array.from(new Set(savedPaths))
282
- parts.push(`Saved to: ${unique.map((p) => path.relative(cwd, p) || '.').join(', ')}`)
283
- }
284
- return parts.join('\n')
285
621
  }
286
- return JSON.stringify(result)
622
+
623
+ return withRetry(rawCall, undefined, {
624
+ maxAttempts: 3,
625
+ backoffMs: 1000,
626
+ retryable: [
627
+ /timed out/i,
628
+ /ERR_ABORTED/i,
629
+ /Target closed/i,
630
+ /Execution context was destroyed/i,
631
+ /SharedContextFactory already exists/i,
632
+ /ECONNRESET/i,
633
+ /temporarily unavailable/i,
634
+ ],
635
+ onRetry: async (_attempt, result) => {
636
+ if (/SharedContextFactory already exists/i.test(result)) {
637
+ cleanupSessionBrowser(sessionKey)
638
+ upsertBrowserSessionRecord({
639
+ sessionId: sessionKey,
640
+ profileId: profileInfo.profileId,
641
+ profileDir,
642
+ inheritedFromSessionId: profileInfo.inheritedFromSessionId,
643
+ status: 'idle',
644
+ lastAction: 'browser_recover',
645
+ lastError: 'Recovered browser transport after Playwright shared-context startup conflict.',
646
+ })
647
+ }
648
+ },
649
+ })
287
650
  }
288
651
 
289
652
  const dismissCookieBanners = async (mcpCall: (toolName: string, args: Record<string, unknown>) => Promise<string>) => {
290
653
  await new Promise((r) => setTimeout(r, 1500))
291
- const js = `(() => {
654
+ const js = `() => {
292
655
  const sel = ['button[id*="reject" i]', 'button[class*="reject" i]', 'a[id*="reject" i]', 'a[class*="reject" i]', '#onetrust-reject-all-handler', '#CybotCookiebotDialogBodyButtonDecline', '#didomi-notice-disagree-button', '.qc-cmp2-summary-buttons button:first-child', 'button.sp_choice_type_12'];
293
656
  for (const s of sel) { const el = document.querySelector(s); if (el && el.offsetParent !== null) { el.click(); return 'dismissed:' + s; } }
294
657
  const btns = [...document.querySelectorAll('button, a[role="button"]')]; const rejectRe = /^(reject|reject all|decline|deny|refuse|no,? thanks|only necessary|necessary only)$/i;
295
658
  for (const b of btns) { const txt = (b.textContent || '').trim(); if (rejectRe.test(txt) && b.offsetParent !== null) { b.click(); return 'dismissed:text=' + txt; } }
296
659
  return 'none';
297
- })()`
298
- await mcpCall('browser_evaluate', { expression: js })
660
+ }`
661
+ await mcpCall('browser_evaluate', { function: js })
662
+ }
663
+
664
+ const performFillForm = async (params: Record<string, unknown>) => {
665
+ const fields = Array.isArray(params.fields)
666
+ ? params.fields
667
+ : (() => {
668
+ const form = params.form
669
+ if (!form || typeof form !== 'object' || Array.isArray(form)) return []
670
+ return Object.entries(form as Record<string, unknown>).map(([key, value]) => {
671
+ const escapedId = String(key).replace(/[^a-zA-Z0-9_-]/g, '')
672
+ const escapedAttr = String(key).replace(/["\\]/g, '\\$&')
673
+ const inferredType = typeof value === 'boolean'
674
+ ? 'checkbox'
675
+ : /password/i.test(key)
676
+ ? 'password'
677
+ : 'text'
678
+ return {
679
+ element: escapedId
680
+ ? `#${escapedId}, [name="${escapedAttr}"]`
681
+ : `[name="${escapedAttr}"]`,
682
+ type: inferredType,
683
+ value,
684
+ }
685
+ })
686
+ })()
687
+ if (fields.length === 0) return { ok: false, error: 'fields is required for fill_form.' }
688
+ const filled: Array<Record<string, unknown>> = []
689
+ for (const field of fields) {
690
+ if (!field || typeof field !== 'object') continue
691
+ const entry = field as Record<string, unknown>
692
+ const ref = typeof entry.ref === 'string' ? entry.ref : undefined
693
+ const element = typeof entry.element === 'string' ? entry.element : undefined
694
+ const fieldType = String(entry.type || 'text').toLowerCase()
695
+ const value = entry.value
696
+ if (!ref && !element) continue
697
+ if (fieldType === 'select') {
698
+ const values = Array.isArray(value) ? value.map(String) : [String(value ?? '')]
699
+ await callMcpTool('browser_select_option', { ref, element, values })
700
+ } else if (fieldType === 'checkbox' || fieldType === 'radio') {
701
+ if (value === true || value === 'true' || value === 'on' || value === 'checked') {
702
+ await callMcpTool('browser_click', { ref, element })
703
+ }
704
+ } else {
705
+ await callMcpTool('browser_type', {
706
+ ref,
707
+ element,
708
+ text: String(value ?? ''),
709
+ slowly: fieldType === 'password' ? false : params.slowly === true,
710
+ })
711
+ }
712
+ filled.push({
713
+ ref: ref || null,
714
+ element: element || null,
715
+ type: fieldType,
716
+ value: value ?? null,
717
+ })
718
+ }
719
+ return { ok: true, filled }
720
+ }
721
+
722
+ const submitForm = async (params: Record<string, unknown>) => {
723
+ if (typeof params.submitRef === 'string' || typeof params.submitElement === 'string') {
724
+ await callMcpTool('browser_click', {
725
+ ref: typeof params.submitRef === 'string' ? params.submitRef : undefined,
726
+ element: typeof params.submitElement === 'string' ? params.submitElement : undefined,
727
+ })
728
+ } else {
729
+ await callBrowserEvaluate(`() => {
730
+ const form = document.forms[0];
731
+ if (!form) return { submitted: false, reason: 'no-form' };
732
+ const submitButton = form.querySelector('button[type="submit"], input[type="submit"], button');
733
+ if (submitButton && typeof submitButton.click === 'function') {
734
+ submitButton.click();
735
+ return { submitted: true, method: 'click' };
736
+ }
737
+ if (typeof form.requestSubmit === 'function') {
738
+ form.requestSubmit();
739
+ return { submitted: true, method: 'requestSubmit' };
740
+ }
741
+ if (typeof form.submit === 'function') {
742
+ form.submit();
743
+ return { submitted: true, method: 'submit' };
744
+ }
745
+ return { submitted: false, reason: 'no-submit-method' };
746
+ }`)
747
+ }
748
+
749
+ const waitMs = typeof params.waitMs === 'number' ? Math.max(250, params.waitMs) : 1000
750
+ try {
751
+ await callBrowserEvaluate(`async () => { await new Promise(resolve => setTimeout(resolve, ${Math.min(waitMs, 5000)})); }`)
752
+ } catch {
753
+ await new Promise((resolve) => setTimeout(resolve, waitMs))
754
+ }
755
+
756
+ return {
757
+ ok: true,
758
+ submitted: true,
759
+ page: await captureStructuredObservation(),
760
+ }
761
+ }
762
+
763
+ const scrollUntil = async (params: Record<string, unknown>) => {
764
+ const containsText = typeof params.containsText === 'string'
765
+ ? params.containsText
766
+ : typeof params.text === 'string'
767
+ ? params.text
768
+ : ''
769
+ const selector = typeof params.selector === 'string' ? params.selector : ''
770
+ if (!containsText && !selector) return { ok: false, error: 'containsText or selector is required for scroll_until.' }
771
+
772
+ const maxScrolls = typeof params.maxScrolls === 'number' ? Math.max(1, Math.min(20, params.maxScrolls)) : 8
773
+ let matchedAtStep = -1
774
+ for (let index = 0; index < maxScrolls; index += 1) {
775
+ const result = await callBrowserEvaluate(`() => {
776
+ const bodyText = String(document.body?.innerText || document.body?.textContent || '');
777
+ const selector = ${JSON.stringify(selector)};
778
+ const containsText = ${JSON.stringify(containsText)};
779
+ const match = (selector && !!document.querySelector(selector))
780
+ || (containsText && bodyText.includes(containsText));
781
+ if (match) return { found: true, scrollY: window.scrollY, step: ${index} };
782
+ window.scrollBy({ top: Math.max(window.innerHeight * 0.85, 600), behavior: 'instant' });
783
+ return { found: false, scrollY: window.scrollY, step: ${index} };
784
+ }`)
785
+ const payload = extractJsonPayload(result)
786
+ if (payload && typeof payload === 'object' && !Array.isArray(payload) && (payload as Record<string, unknown>).found === true) {
787
+ matchedAtStep = index
788
+ break
789
+ }
790
+ }
791
+
792
+ const page = await captureStructuredObservation()
793
+ return {
794
+ ok: matchedAtStep >= 0,
795
+ found: matchedAtStep >= 0,
796
+ matchedAtStep: matchedAtStep >= 0 ? matchedAtStep : null,
797
+ page,
798
+ }
799
+ }
800
+
801
+ const resolveDownloadUrl = async (params: Record<string, unknown>) => {
802
+ if (typeof params.url === 'string' && params.url.trim()) return params.url.trim()
803
+ const linkText = typeof params.linkText === 'string' ? params.linkText.trim() : ''
804
+ const hrefContains = typeof params.hrefContains === 'string' ? params.hrefContains.trim() : ''
805
+ if (!linkText && !hrefContains) return null
806
+ const result = await callBrowserEvaluate(`() => {
807
+ const linkText = ${JSON.stringify(linkText)};
808
+ const hrefContains = ${JSON.stringify(hrefContains)};
809
+ const links = Array.from(document.querySelectorAll('a[href]'));
810
+ const match = links.find((link) => {
811
+ const text = String(link.innerText || link.textContent || '').trim();
812
+ const href = String(link.href || link.getAttribute('href') || '').trim();
813
+ if (!href) return false;
814
+ if (linkText && text.toLowerCase().includes(linkText.toLowerCase())) return true;
815
+ if (hrefContains && href.toLowerCase().includes(hrefContains.toLowerCase())) return true;
816
+ return false;
817
+ });
818
+ return { href: match ? (match.href || match.getAttribute('href') || '') : null };
819
+ }`)
820
+ const payload = extractJsonPayload(result)
821
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
822
+ const href = (payload as Record<string, unknown>).href
823
+ return typeof href === 'string' && href.trim() ? href.trim() : null
824
+ }
825
+ return null
826
+ }
827
+
828
+ const downloadFile = async (params: Record<string, unknown>) => {
829
+ const downloadUrl = await resolveDownloadUrl(params)
830
+ if (!downloadUrl) return { ok: false, error: 'url, linkText, or hrefContains is required for download_file.' }
831
+
832
+ const current = await captureStructuredObservation()
833
+ let resolvedUrl = downloadUrl
834
+ if (!/^https?:\/\//i.test(resolvedUrl)) {
835
+ const base = typeof current.url === 'string' && current.url ? current.url : undefined
836
+ if (!base) return { ok: false, error: 'Relative download URL requires an active page URL.' }
837
+ resolvedUrl = new URL(resolvedUrl, base).toString()
838
+ }
839
+
840
+ const res = await fetch(resolvedUrl, {
841
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SwarmClaw/1.0)' },
842
+ signal: AbortSignal.timeout(30_000),
843
+ })
844
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status}: ${res.statusText}`, url: resolvedUrl }
845
+
846
+ const arrayBuffer = await res.arrayBuffer()
847
+ const data = Buffer.from(arrayBuffer)
848
+ const inferredName = (() => {
849
+ try {
850
+ const pathname = new URL(resolvedUrl).pathname
851
+ const base = path.basename(pathname)
852
+ return base && base !== '/' ? base : `download-${Date.now()}`
853
+ } catch {
854
+ return `download-${Date.now()}`
855
+ }
856
+ })()
857
+ const targetPath = typeof params.saveTo === 'string' && params.saveTo.trim()
858
+ ? safePath(cwd, params.saveTo.trim())
859
+ : path.join(UPLOAD_DIR, inferredName)
860
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true })
861
+ fs.writeFileSync(targetPath, data)
862
+
863
+ const artifactPath = targetPath.startsWith(UPLOAD_DIR)
864
+ ? targetPath
865
+ : path.join(UPLOAD_DIR, `${Date.now()}-${path.basename(targetPath)}`)
866
+ if (artifactPath !== targetPath) fs.copyFileSync(targetPath, artifactPath)
867
+ const filename = path.basename(artifactPath)
868
+ upsertBrowserSessionRecord({
869
+ sessionId: sessionKey,
870
+ profileId: profileInfo.profileId,
871
+ profileDir,
872
+ status: 'active',
873
+ lastAction: 'download_file',
874
+ artifacts: [{
875
+ kind: 'download',
876
+ path: artifactPath,
877
+ url: `/api/uploads/${filename}`,
878
+ filename,
879
+ createdAt: Date.now(),
880
+ }],
881
+ })
882
+
883
+ return {
884
+ ok: true,
885
+ url: resolvedUrl,
886
+ path: targetPath,
887
+ artifactUrl: `/api/uploads/${filename}`,
888
+ filename: path.basename(targetPath),
889
+ sizeBytes: data.byteLength,
890
+ contentType: res.headers.get('content-type') || null,
891
+ }
892
+ }
893
+
894
+ const verifyOutcome = async (params: Record<string, unknown>) => {
895
+ const verification: Record<string, unknown> = {}
896
+ if (typeof params.expectText === 'string' && params.expectText.trim()) {
897
+ verification.expectText = await callMcpTool('browser_verify_text_visible', { text: params.expectText.trim() })
898
+ }
899
+ if (typeof params.expectElement === 'string' && params.expectElement.trim()) {
900
+ verification.expectElement = await callMcpTool('browser_verify_element_visible', { element: params.expectElement.trim() })
901
+ }
902
+ if (typeof params.expectValue === 'string' && params.expectValue.trim()) {
903
+ verification.expectValue = await callMcpTool('browser_verify_value', {
904
+ element: typeof params.expectValueElement === 'string' ? params.expectValueElement : undefined,
905
+ value: params.expectValue.trim(),
906
+ })
907
+ }
908
+ return verification
909
+ }
910
+
911
+ const completeWebTask = async (params: Record<string, unknown>) => {
912
+ const steps: string[] = []
913
+ if (typeof params.url === 'string' && params.url.trim()) {
914
+ await callMcpTool('browser_navigate', { url: params.url.trim() })
915
+ steps.push(`navigate:${params.url.trim()}`)
916
+ try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
917
+ }
918
+
919
+ let initialPage = await captureStructuredObservation()
920
+ if (typeof params.scrollUntilText === 'string' || typeof params.scrollUntilSelector === 'string') {
921
+ const scroll = await scrollUntil({
922
+ containsText: typeof params.scrollUntilText === 'string' ? params.scrollUntilText : undefined,
923
+ selector: typeof params.scrollUntilSelector === 'string' ? params.scrollUntilSelector : undefined,
924
+ maxScrolls: typeof params.maxScrolls === 'number' ? params.maxScrolls : undefined,
925
+ })
926
+ steps.push('scroll_until')
927
+ if (scroll.ok) initialPage = scroll.page
928
+ }
929
+
930
+ if (Array.isArray(params.fields) && params.fields.length > 0) {
931
+ const filled = await performFillForm(params)
932
+ if (!filled.ok) return filled
933
+ steps.push('fill_form')
934
+ }
935
+
936
+ if (params.submit === true) {
937
+ await submitForm(params)
938
+ steps.push('submit_form')
939
+ }
940
+
941
+ let download: Record<string, unknown> | null = null
942
+ if (params.download === true || typeof params.downloadUrl === 'string' || typeof params.linkText === 'string' || typeof params.hrefContains === 'string') {
943
+ download = await downloadFile({
944
+ url: typeof params.downloadUrl === 'string' ? params.downloadUrl : params.url,
945
+ linkText: params.linkText,
946
+ hrefContains: params.hrefContains,
947
+ saveTo: params.saveTo,
948
+ })
949
+ steps.push('download_file')
950
+ }
951
+
952
+ const verification = await verifyOutcome(params)
953
+ const page = await captureStructuredObservation()
954
+ return {
955
+ ok: true,
956
+ goal: typeof params.goal === 'string' ? params.goal : null,
957
+ steps,
958
+ verification,
959
+ initialPage,
960
+ page,
961
+ download,
962
+ }
299
963
  }
300
964
 
301
965
  const MCP_TOOL_MAP: Record<string, string> = {
302
- navigate: 'browser_navigate', screenshot: 'browser_take_screenshot', snapshot: 'browser_snapshot', click: 'browser_click',
303
- type: 'browser_type', press_key: 'browser_press_key', select: 'browser_select_option', evaluate: 'browser_evaluate',
304
- pdf: 'browser_pdf_save', upload: 'browser_file_upload', wait: 'browser_wait_for',
966
+ navigate: 'browser_navigate',
967
+ back: 'browser_navigate_back',
968
+ close: 'browser_close',
969
+ screenshot: 'browser_take_screenshot',
970
+ snapshot: 'browser_snapshot',
971
+ click: 'browser_click',
972
+ hover: 'browser_hover',
973
+ type: 'browser_type',
974
+ press_key: 'browser_press_key',
975
+ select: 'browser_select_option',
976
+ fill_form: 'browser_fill_form',
977
+ dialog: 'browser_handle_dialog',
978
+ evaluate: 'browser_evaluate',
979
+ run_code: 'browser_run_code',
980
+ pdf: 'browser_pdf_save',
981
+ upload: 'browser_file_upload',
982
+ wait: 'browser_wait_for',
983
+ tabs: 'browser_tabs',
984
+ network: 'browser_network_requests',
985
+ verify_text: 'browser_verify_text_visible',
986
+ verify_element: 'browser_verify_element_visible',
987
+ verify_list: 'browser_verify_list_visible',
988
+ verify_value: 'browser_verify_value',
305
989
  }
306
990
 
307
991
  tools.push(
308
992
  tool(
309
- async (params) => {
993
+ async (rawParams) => {
994
+ const params = normalizeToolInputArgs((rawParams ?? {}) as Record<string, unknown>)
310
995
  try {
311
- const { action, ...rest } = params
996
+ const action = String(params.action || '').trim()
997
+
998
+ if (action === 'profile') {
999
+ const state = upsertBrowserSessionRecord({
1000
+ sessionId: sessionKey,
1001
+ profileId: profileInfo.profileId,
1002
+ profileDir,
1003
+ inheritedFromSessionId: profileInfo.inheritedFromSessionId,
1004
+ status: activeBrowsers.has(sessionKey) ? 'active' : 'idle',
1005
+ })
1006
+ return stringifyStructured({
1007
+ sessionId: sessionKey,
1008
+ active: activeBrowsers.has(sessionKey),
1009
+ profileId: state.profileId,
1010
+ profileDir: state.profileDir,
1011
+ inheritedFromSessionId: state.inheritedFromSessionId,
1012
+ currentUrl: state.currentUrl,
1013
+ pageTitle: state.pageTitle,
1014
+ lastObservation: state.lastObservation,
1015
+ })
1016
+ }
1017
+
1018
+ if (action === 'reset_profile') {
1019
+ cleanupSessionBrowser(sessionKey)
1020
+ fs.rmSync(profileDir, { recursive: true, force: true })
1021
+ removeBrowserSessionRecord(sessionKey)
1022
+ return stringifyStructured({
1023
+ ok: true,
1024
+ sessionId: sessionKey,
1025
+ profileId: profileInfo.profileId,
1026
+ profileDir,
1027
+ reset: true,
1028
+ })
1029
+ }
1030
+
1031
+ if (action === 'read_page') {
1032
+ const url = typeof params.url === 'string' ? params.url : ''
1033
+ if (url) {
1034
+ await callMcpTool('browser_navigate', { url })
1035
+ try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
1036
+ }
1037
+ return stringifyStructured(await captureStructuredObservation())
1038
+ }
1039
+
1040
+ if (action === 'extract_links') {
1041
+ const observation = await captureStructuredObservation() as Record<string, unknown>
1042
+ return stringifyStructured({
1043
+ url: observation.url || null,
1044
+ title: observation.title || null,
1045
+ links: Array.isArray(observation.links) ? observation.links : [],
1046
+ })
1047
+ }
1048
+
1049
+ if (action === 'extract_form_fields') {
1050
+ const observation = await captureStructuredObservation() as Record<string, unknown>
1051
+ return stringifyStructured({
1052
+ url: observation.url || null,
1053
+ title: observation.title || null,
1054
+ forms: Array.isArray(observation.forms) ? observation.forms : [],
1055
+ })
1056
+ }
1057
+
1058
+ if (action === 'extract_table') {
1059
+ const observation = await captureStructuredObservation() as Record<string, unknown>
1060
+ const tables = Array.isArray(observation.tables) ? observation.tables : []
1061
+ const tableIndex = typeof params.tableIndex === 'number' ? params.tableIndex : 0
1062
+ return stringifyStructured({
1063
+ url: observation.url || null,
1064
+ title: observation.title || null,
1065
+ table: tables[tableIndex] || null,
1066
+ tables,
1067
+ })
1068
+ }
1069
+
1070
+ if (action === 'fill_form') {
1071
+ const filled = await performFillForm(params)
1072
+ if (!filled.ok) return `Error: ${filled.error}`
1073
+ if (params.submit === true) {
1074
+ await submitForm(params)
1075
+ }
1076
+ return stringifyStructured({
1077
+ ok: true,
1078
+ filled: filled.filled,
1079
+ submitted: params.submit === true,
1080
+ page: await captureStructuredObservation(),
1081
+ })
1082
+ }
1083
+
1084
+ if (action === 'submit_form') {
1085
+ return stringifyStructured(await submitForm(params))
1086
+ }
1087
+
1088
+ if (action === 'scroll_until') {
1089
+ return stringifyStructured(await scrollUntil(params))
1090
+ }
1091
+
1092
+ if (action === 'download_file') {
1093
+ return stringifyStructured(await downloadFile(params))
1094
+ }
1095
+
1096
+ if (action === 'complete_web_task') {
1097
+ return stringifyStructured(await completeWebTask(params))
1098
+ }
1099
+
312
1100
  const mcpTool = MCP_TOOL_MAP[action]
313
1101
  if (!mcpTool) return `Unknown browser action: "${action}"`
314
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1102
+ const rest = { ...params }
1103
+ delete rest.action
315
1104
  const args: Record<string, any> = {}
316
- for (const [k, v] of Object.entries(rest)) { if (v !== undefined && v !== null && v !== '') args[k] = v }
1105
+ for (const [k, v] of Object.entries(rest)) {
1106
+ if (v !== undefined && v !== null && v !== '') args[k] = v
1107
+ }
317
1108
 
318
- // If screenshot includes a url, navigate first then capture
319
- if (action === 'screenshot' && args.url) {
1109
+ if (action === 'tabs') {
1110
+ args.action = typeof params.tabAction === 'string' ? params.tabAction : 'list'
1111
+ delete args.tabAction
1112
+ }
1113
+ if (action === 'network') {
1114
+ args.includeStatic = params.includeStatic === true
1115
+ if (typeof params.filename !== 'string') delete args.filename
1116
+ }
1117
+ if (action === 'select' && args.option !== undefined) {
1118
+ args.values = Array.isArray(args.option) ? args.option : [String(args.option)]
1119
+ delete args.option
1120
+ }
1121
+
1122
+ if ((action === 'screenshot' || action === 'snapshot') && args.url) {
320
1123
  const navUrl = args.url
321
1124
  delete args.url
322
1125
  await callMcpTool('browser_navigate', { url: navUrl })
323
1126
  try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
324
1127
  }
325
1128
 
326
- // Wait for the page to finish rendering before capturing
327
- if (action === 'screenshot') {
1129
+ if (action === 'screenshot' || action === 'snapshot') {
328
1130
  try {
329
- await callMcpTool('browser_evaluate', {
330
- expression: `await new Promise(resolve => {
1131
+ await callBrowserEvaluate(`async () => { await new Promise(resolve => {
331
1132
  if (document.readyState === 'complete') {
332
- setTimeout(resolve, 1500);
1133
+ setTimeout(resolve, 1200);
333
1134
  } else {
334
- window.addEventListener('load', () => setTimeout(resolve, 1500), { once: true });
1135
+ window.addEventListener('load', () => setTimeout(resolve, 1200), { once: true });
335
1136
  setTimeout(resolve, 5000);
336
1137
  }
337
- })`,
338
- })
339
- } catch { /* page may not support evaluate — fall back to a flat delay */
340
- await new Promise((r) => setTimeout(r, 2000))
1138
+ }); }`)
1139
+ } catch {
1140
+ await new Promise((r) => setTimeout(r, 1200))
341
1141
  }
342
1142
  }
343
1143
 
344
- let result = await callMcpTool(mcpTool, args, { saveTo: params.saveTo })
345
-
346
- // Playwright throws ERR_ABORTED on server-side redirects (e.g. Wikipedia Special:Random).
347
- // The browser follows the redirect fine — the original navigation just gets "aborted".
348
- // Recover by taking a snapshot of the page the browser actually landed on.
1144
+ let result = await callMcpTool(mcpTool, args, { saveTo: typeof params.saveTo === 'string' ? params.saveTo : undefined })
349
1145
  if (action === 'navigate' && result.includes('ERR_ABORTED')) {
350
1146
  await new Promise((r) => setTimeout(r, 1000))
351
1147
  result = await callMcpTool('browser_snapshot', {})
352
1148
  }
1149
+ if (action === 'navigate') {
1150
+ try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ }
1151
+ }
1152
+
1153
+ if (['navigate', 'back', 'click', 'type', 'select', 'fill_form', 'submit_form', 'press_key', 'scroll_until', 'complete_web_task'].includes(action)) {
1154
+ try { await captureStructuredObservation() } catch { /* ignore */ }
1155
+ }
1156
+
1157
+ if (action === 'close') {
1158
+ cleanupSessionBrowser(sessionKey)
1159
+ }
353
1160
 
354
- if (action === 'navigate') { try { await dismissCookieBanners(callMcpTool) } catch { /* ignore */ } }
355
1161
  return result
356
- } catch (err: unknown) { return `Error: ${err instanceof Error ? err.message : String(err)}` }
1162
+ } catch (err: unknown) {
1163
+ return `Error: ${err instanceof Error ? err.message : String(err)}`
1164
+ }
357
1165
  },
358
1166
  {
359
1167
  name: 'browser',
360
- description: 'Control the browser. Actions: navigate, screenshot, snapshot, click, type, press_key, select, evaluate, pdf, upload, wait.',
1168
+ description: 'Control a persistent browser profile. Supports low-level actions plus higher-level workflows like read_page, extract_links, extract_form_fields, extract_table, fill_form, submit_form, scroll_until, download_file, complete_web_task, profile, and reset_profile.',
361
1169
  schema: z.object({
362
- action: z.enum(['navigate', 'screenshot', 'snapshot', 'click', 'type', 'press_key', 'select', 'evaluate', 'pdf', 'upload', 'wait']),
363
- url: z.string().optional(), element: z.string().optional(), ref: z.string().optional(), text: z.string().optional(),
364
- key: z.string().optional(), option: z.string().optional(), expression: z.string().optional(),
365
- paths: z.array(z.string()).optional(), timeout: z.number().optional(), saveTo: z.string().optional(),
366
- }),
1170
+ action: z.enum([
1171
+ 'navigate',
1172
+ 'back',
1173
+ 'close',
1174
+ 'screenshot',
1175
+ 'snapshot',
1176
+ 'click',
1177
+ 'hover',
1178
+ 'type',
1179
+ 'fill_form',
1180
+ 'submit_form',
1181
+ 'scroll_until',
1182
+ 'press_key',
1183
+ 'select',
1184
+ 'dialog',
1185
+ 'evaluate',
1186
+ 'run_code',
1187
+ 'pdf',
1188
+ 'upload',
1189
+ 'wait',
1190
+ 'tabs',
1191
+ 'network',
1192
+ 'read_page',
1193
+ 'extract_links',
1194
+ 'extract_form_fields',
1195
+ 'extract_table',
1196
+ 'download_file',
1197
+ 'complete_web_task',
1198
+ 'verify_text',
1199
+ 'verify_element',
1200
+ 'verify_list',
1201
+ 'verify_value',
1202
+ 'profile',
1203
+ 'reset_profile',
1204
+ ]),
1205
+ }).passthrough(),
367
1206
  },
368
1207
  ),
369
1208
  )