devharness 0.8.0

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 (627) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/build/atomic-write.d.ts +28 -0
  4. package/build/atomic-write.d.ts.map +1 -0
  5. package/build/atomic-write.js +126 -0
  6. package/build/atomic-write.js.map +1 -0
  7. package/build/cdp-manager.d.ts +524 -0
  8. package/build/cdp-manager.d.ts.map +1 -0
  9. package/build/cdp-manager.js +1828 -0
  10. package/build/cdp-manager.js.map +1 -0
  11. package/build/cdp-manager.test.d.ts +6 -0
  12. package/build/cdp-manager.test.d.ts.map +1 -0
  13. package/build/cdp-manager.test.js +546 -0
  14. package/build/cdp-manager.test.js.map +1 -0
  15. package/build/chrome-launcher.d.ts +400 -0
  16. package/build/chrome-launcher.d.ts.map +1 -0
  17. package/build/chrome-launcher.js +1071 -0
  18. package/build/chrome-launcher.js.map +1 -0
  19. package/build/chrome-launcher.test.d.ts +11 -0
  20. package/build/chrome-launcher.test.d.ts.map +1 -0
  21. package/build/chrome-launcher.test.js +204 -0
  22. package/build/chrome-launcher.test.js.map +1 -0
  23. package/build/clickable-cache.d.ts +73 -0
  24. package/build/clickable-cache.d.ts.map +1 -0
  25. package/build/clickable-cache.js +90 -0
  26. package/build/clickable-cache.js.map +1 -0
  27. package/build/command-recorder.d.ts +401 -0
  28. package/build/command-recorder.d.ts.map +1 -0
  29. package/build/command-recorder.js +701 -0
  30. package/build/command-recorder.js.map +1 -0
  31. package/build/config.d.ts +390 -0
  32. package/build/config.d.ts.map +1 -0
  33. package/build/config.js +826 -0
  34. package/build/config.js.map +1 -0
  35. package/build/config.test.d.ts +2 -0
  36. package/build/config.test.d.ts.map +1 -0
  37. package/build/config.test.js +112 -0
  38. package/build/config.test.js.map +1 -0
  39. package/build/connection-manager.d.ts +159 -0
  40. package/build/connection-manager.d.ts.map +1 -0
  41. package/build/connection-manager.js +432 -0
  42. package/build/connection-manager.js.map +1 -0
  43. package/build/connection-manager.test.d.ts +11 -0
  44. package/build/connection-manager.test.d.ts.map +1 -0
  45. package/build/connection-manager.test.js +114 -0
  46. package/build/connection-manager.test.js.map +1 -0
  47. package/build/console-monitor.d.ts +150 -0
  48. package/build/console-monitor.d.ts.map +1 -0
  49. package/build/console-monitor.js +454 -0
  50. package/build/console-monitor.js.map +1 -0
  51. package/build/dashboard/bundle.js +1 -0
  52. package/build/dashboard/dashboard-client.d.ts +42 -0
  53. package/build/dashboard/dashboard-client.d.ts.map +1 -0
  54. package/build/dashboard/dashboard-client.js +184 -0
  55. package/build/dashboard/dashboard-client.js.map +1 -0
  56. package/build/dashboard/dashboard-hub.d.ts +50 -0
  57. package/build/dashboard/dashboard-hub.d.ts.map +1 -0
  58. package/build/dashboard/dashboard-hub.js +654 -0
  59. package/build/dashboard/dashboard-hub.js.map +1 -0
  60. package/build/dashboard/hub-lock.d.ts +16 -0
  61. package/build/dashboard/hub-lock.d.ts.map +1 -0
  62. package/build/dashboard/hub-lock.js +77 -0
  63. package/build/dashboard/hub-lock.js.map +1 -0
  64. package/build/dashboard/index.d.ts +25 -0
  65. package/build/dashboard/index.d.ts.map +1 -0
  66. package/build/dashboard/index.js +77 -0
  67. package/build/dashboard/index.js.map +1 -0
  68. package/build/dashboard/types.d.ts +83 -0
  69. package/build/dashboard/types.d.ts.map +1 -0
  70. package/build/dashboard/types.js +6 -0
  71. package/build/dashboard/types.js.map +1 -0
  72. package/build/debug-logger.d.ts +85 -0
  73. package/build/debug-logger.d.ts.map +1 -0
  74. package/build/debug-logger.js +195 -0
  75. package/build/debug-logger.js.map +1 -0
  76. package/build/debugger-aware-wrapper.d.ts +37 -0
  77. package/build/debugger-aware-wrapper.d.ts.map +1 -0
  78. package/build/debugger-aware-wrapper.js +128 -0
  79. package/build/debugger-aware-wrapper.js.map +1 -0
  80. package/build/dom-change-monitor.d.ts +92 -0
  81. package/build/dom-change-monitor.d.ts.map +1 -0
  82. package/build/dom-change-monitor.js +437 -0
  83. package/build/dom-change-monitor.js.map +1 -0
  84. package/build/element-collector.d.ts +16 -0
  85. package/build/element-collector.d.ts.map +1 -0
  86. package/build/element-collector.js +207 -0
  87. package/build/element-collector.js.map +1 -0
  88. package/build/error-helpers.d.ts +43 -0
  89. package/build/error-helpers.d.ts.map +1 -0
  90. package/build/error-helpers.js +143 -0
  91. package/build/error-helpers.js.map +1 -0
  92. package/build/formatters/console-formatter.d.ts +160 -0
  93. package/build/formatters/console-formatter.d.ts.map +1 -0
  94. package/build/formatters/console-formatter.js +338 -0
  95. package/build/formatters/console-formatter.js.map +1 -0
  96. package/build/helpers/parser-plugins.d.ts +44 -0
  97. package/build/helpers/parser-plugins.d.ts.map +1 -0
  98. package/build/helpers/parser-plugins.js +102 -0
  99. package/build/helpers/parser-plugins.js.map +1 -0
  100. package/build/helpers/paths.d.ts +79 -0
  101. package/build/helpers/paths.d.ts.map +1 -0
  102. package/build/helpers/paths.js +168 -0
  103. package/build/helpers/paths.js.map +1 -0
  104. package/build/helpers/process-liveness.d.ts +24 -0
  105. package/build/helpers/process-liveness.d.ts.map +1 -0
  106. package/build/helpers/process-liveness.js +36 -0
  107. package/build/helpers/process-liveness.js.map +1 -0
  108. package/build/helpers/process-liveness.test.d.ts +2 -0
  109. package/build/helpers/process-liveness.test.d.ts.map +1 -0
  110. package/build/helpers/process-liveness.test.js +40 -0
  111. package/build/helpers/process-liveness.test.js.map +1 -0
  112. package/build/helpers/sequence-tree.d.ts +27 -0
  113. package/build/helpers/sequence-tree.d.ts.map +1 -0
  114. package/build/helpers/sequence-tree.js +80 -0
  115. package/build/helpers/sequence-tree.js.map +1 -0
  116. package/build/helpers/sequence-tree.test.d.ts +2 -0
  117. package/build/helpers/sequence-tree.test.d.ts.map +1 -0
  118. package/build/helpers/sequence-tree.test.js +122 -0
  119. package/build/helpers/sequence-tree.test.js.map +1 -0
  120. package/build/index.d.ts +3 -0
  121. package/build/index.d.ts.map +1 -0
  122. package/build/index.js +2007 -0
  123. package/build/index.js.map +1 -0
  124. package/build/interaction-recorder.d.ts +214 -0
  125. package/build/interaction-recorder.d.ts.map +1 -0
  126. package/build/interaction-recorder.js +2007 -0
  127. package/build/interaction-recorder.js.map +1 -0
  128. package/build/issue-tracker.d.ts +107 -0
  129. package/build/issue-tracker.d.ts.map +1 -0
  130. package/build/issue-tracker.js +582 -0
  131. package/build/issue-tracker.js.map +1 -0
  132. package/build/issue-tracker.test.d.ts +2 -0
  133. package/build/issue-tracker.test.d.ts.map +1 -0
  134. package/build/issue-tracker.test.js +88 -0
  135. package/build/issue-tracker.test.js.map +1 -0
  136. package/build/launch-chrome-port.test.d.ts +14 -0
  137. package/build/launch-chrome-port.test.d.ts.map +1 -0
  138. package/build/launch-chrome-port.test.js +97 -0
  139. package/build/launch-chrome-port.test.js.map +1 -0
  140. package/build/log-processor/orchestrator.d.ts +32 -0
  141. package/build/log-processor/orchestrator.d.ts.map +1 -0
  142. package/build/log-processor/orchestrator.js +27 -0
  143. package/build/log-processor/orchestrator.js.map +1 -0
  144. package/build/logpoint-execution-tracker.d.ts +57 -0
  145. package/build/logpoint-execution-tracker.d.ts.map +1 -0
  146. package/build/logpoint-execution-tracker.js +112 -0
  147. package/build/logpoint-execution-tracker.js.map +1 -0
  148. package/build/mcp-supervisor.d.ts +3 -0
  149. package/build/mcp-supervisor.d.ts.map +1 -0
  150. package/build/mcp-supervisor.js +211 -0
  151. package/build/mcp-supervisor.js.map +1 -0
  152. package/build/messages.d.ts +165 -0
  153. package/build/messages.d.ts.map +1 -0
  154. package/build/messages.js +574 -0
  155. package/build/messages.js.map +1 -0
  156. package/build/network-monitor.d.ts +172 -0
  157. package/build/network-monitor.d.ts.map +1 -0
  158. package/build/network-monitor.js +350 -0
  159. package/build/network-monitor.js.map +1 -0
  160. package/build/overlays.d.ts +49 -0
  161. package/build/overlays.d.ts.map +1 -0
  162. package/build/overlays.js +409 -0
  163. package/build/overlays.js.map +1 -0
  164. package/build/persistent-profiles.test.d.ts +15 -0
  165. package/build/persistent-profiles.test.d.ts.map +1 -0
  166. package/build/persistent-profiles.test.js +470 -0
  167. package/build/persistent-profiles.test.js.map +1 -0
  168. package/build/port-reserver.d.ts +32 -0
  169. package/build/port-reserver.d.ts.map +1 -0
  170. package/build/port-reserver.js +118 -0
  171. package/build/port-reserver.js.map +1 -0
  172. package/build/puppeteer-manager.d.ts +47 -0
  173. package/build/puppeteer-manager.d.ts.map +1 -0
  174. package/build/puppeteer-manager.js +116 -0
  175. package/build/puppeteer-manager.js.map +1 -0
  176. package/build/recording-coverage.test.d.ts +12 -0
  177. package/build/recording-coverage.test.d.ts.map +1 -0
  178. package/build/recording-coverage.test.js +72 -0
  179. package/build/recording-coverage.test.js.map +1 -0
  180. package/build/reference-validator.d.ts +47 -0
  181. package/build/reference-validator.d.ts.map +1 -0
  182. package/build/reference-validator.js +86 -0
  183. package/build/reference-validator.js.map +1 -0
  184. package/build/reference-validator.test.d.ts +5 -0
  185. package/build/reference-validator.test.d.ts.map +1 -0
  186. package/build/reference-validator.test.js +243 -0
  187. package/build/reference-validator.test.js.map +1 -0
  188. package/build/replay-cursor.d.ts +27 -0
  189. package/build/replay-cursor.d.ts.map +1 -0
  190. package/build/replay-cursor.js +240 -0
  191. package/build/replay-cursor.js.map +1 -0
  192. package/build/runners/docker-compose-runner.d.ts +69 -0
  193. package/build/runners/docker-compose-runner.d.ts.map +1 -0
  194. package/build/runners/docker-compose-runner.js +438 -0
  195. package/build/runners/docker-compose-runner.js.map +1 -0
  196. package/build/runners/docker-runner.d.ts +65 -0
  197. package/build/runners/docker-runner.d.ts.map +1 -0
  198. package/build/runners/docker-runner.js +414 -0
  199. package/build/runners/docker-runner.js.map +1 -0
  200. package/build/runners/index.d.ts +19 -0
  201. package/build/runners/index.d.ts.map +1 -0
  202. package/build/runners/index.js +35 -0
  203. package/build/runners/index.js.map +1 -0
  204. package/build/runners/native-runner.d.ts +95 -0
  205. package/build/runners/native-runner.d.ts.map +1 -0
  206. package/build/runners/native-runner.js +370 -0
  207. package/build/runners/native-runner.js.map +1 -0
  208. package/build/runners/types.d.ts +147 -0
  209. package/build/runners/types.d.ts.map +1 -0
  210. package/build/runners/types.js +25 -0
  211. package/build/runners/types.js.map +1 -0
  212. package/build/self-restart.d.ts +12 -0
  213. package/build/self-restart.d.ts.map +1 -0
  214. package/build/self-restart.js +41 -0
  215. package/build/self-restart.js.map +1 -0
  216. package/build/self-restart.test.d.ts +2 -0
  217. package/build/self-restart.test.d.ts.map +1 -0
  218. package/build/self-restart.test.js +34 -0
  219. package/build/self-restart.test.js.map +1 -0
  220. package/build/server-claims.d.ts +161 -0
  221. package/build/server-claims.d.ts.map +1 -0
  222. package/build/server-claims.js +382 -0
  223. package/build/server-claims.js.map +1 -0
  224. package/build/server-claims.test.d.ts +2 -0
  225. package/build/server-claims.test.d.ts.map +1 -0
  226. package/build/server-claims.test.js +197 -0
  227. package/build/server-claims.test.js.map +1 -0
  228. package/build/server-manager.d.ts +438 -0
  229. package/build/server-manager.d.ts.map +1 -0
  230. package/build/server-manager.js +1674 -0
  231. package/build/server-manager.js.map +1 -0
  232. package/build/server-manager.test.d.ts +17 -0
  233. package/build/server-manager.test.d.ts.map +1 -0
  234. package/build/server-manager.test.js +122 -0
  235. package/build/server-manager.test.js.map +1 -0
  236. package/build/server-ownership.test.d.ts +2 -0
  237. package/build/server-ownership.test.d.ts.map +1 -0
  238. package/build/server-ownership.test.js +238 -0
  239. package/build/server-ownership.test.js.map +1 -0
  240. package/build/server-watcher.d.ts +21 -0
  241. package/build/server-watcher.d.ts.map +1 -0
  242. package/build/server-watcher.js +105 -0
  243. package/build/server-watcher.js.map +1 -0
  244. package/build/session-detector.d.ts +47 -0
  245. package/build/session-detector.d.ts.map +1 -0
  246. package/build/session-detector.js +229 -0
  247. package/build/session-detector.js.map +1 -0
  248. package/build/session-detector.test.d.ts +9 -0
  249. package/build/session-detector.test.d.ts.map +1 -0
  250. package/build/session-detector.test.js +45 -0
  251. package/build/session-detector.test.js.map +1 -0
  252. package/build/sourcemap-handler.d.ts +114 -0
  253. package/build/sourcemap-handler.d.ts.map +1 -0
  254. package/build/sourcemap-handler.js +479 -0
  255. package/build/sourcemap-handler.js.map +1 -0
  256. package/build/supervisor/child-manager.d.ts +43 -0
  257. package/build/supervisor/child-manager.d.ts.map +1 -0
  258. package/build/supervisor/child-manager.js +148 -0
  259. package/build/supervisor/child-manager.js.map +1 -0
  260. package/build/supervisor/child-manager.test.d.ts +2 -0
  261. package/build/supervisor/child-manager.test.d.ts.map +1 -0
  262. package/build/supervisor/child-manager.test.js +107 -0
  263. package/build/supervisor/child-manager.test.js.map +1 -0
  264. package/build/supervisor/client-watcher.d.ts +48 -0
  265. package/build/supervisor/client-watcher.d.ts.map +1 -0
  266. package/build/supervisor/client-watcher.js +186 -0
  267. package/build/supervisor/client-watcher.js.map +1 -0
  268. package/build/supervisor/client-watcher.test.d.ts +2 -0
  269. package/build/supervisor/client-watcher.test.d.ts.map +1 -0
  270. package/build/supervisor/client-watcher.test.js +121 -0
  271. package/build/supervisor/client-watcher.test.js.map +1 -0
  272. package/build/supervisor/idle-config.d.ts +30 -0
  273. package/build/supervisor/idle-config.d.ts.map +1 -0
  274. package/build/supervisor/idle-config.js +76 -0
  275. package/build/supervisor/idle-config.js.map +1 -0
  276. package/build/supervisor/idle-config.test.d.ts +2 -0
  277. package/build/supervisor/idle-config.test.d.ts.map +1 -0
  278. package/build/supervisor/idle-config.test.js +119 -0
  279. package/build/supervisor/idle-config.test.js.map +1 -0
  280. package/build/supervisor/ndjson-reader.d.ts +47 -0
  281. package/build/supervisor/ndjson-reader.d.ts.map +1 -0
  282. package/build/supervisor/ndjson-reader.js +75 -0
  283. package/build/supervisor/ndjson-reader.js.map +1 -0
  284. package/build/supervisor/ndjson-reader.test.d.ts +2 -0
  285. package/build/supervisor/ndjson-reader.test.d.ts.map +1 -0
  286. package/build/supervisor/ndjson-reader.test.js +92 -0
  287. package/build/supervisor/ndjson-reader.test.js.map +1 -0
  288. package/build/supervisor/pidfile.d.ts +11 -0
  289. package/build/supervisor/pidfile.d.ts.map +1 -0
  290. package/build/supervisor/pidfile.js +29 -0
  291. package/build/supervisor/pidfile.js.map +1 -0
  292. package/build/supervisor/pidfile.test.d.ts +8 -0
  293. package/build/supervisor/pidfile.test.d.ts.map +1 -0
  294. package/build/supervisor/pidfile.test.js +46 -0
  295. package/build/supervisor/pidfile.test.js.map +1 -0
  296. package/build/supervisor/restart-coordinator.d.ts +73 -0
  297. package/build/supervisor/restart-coordinator.d.ts.map +1 -0
  298. package/build/supervisor/restart-coordinator.js +300 -0
  299. package/build/supervisor/restart-coordinator.js.map +1 -0
  300. package/build/supervisor/restart-coordinator.test.d.ts +2 -0
  301. package/build/supervisor/restart-coordinator.test.d.ts.map +1 -0
  302. package/build/supervisor/restart-coordinator.test.js +434 -0
  303. package/build/supervisor/restart-coordinator.test.js.map +1 -0
  304. package/build/test-support/fake-execute-tool-call.d.ts +11 -0
  305. package/build/test-support/fake-execute-tool-call.d.ts.map +1 -0
  306. package/build/test-support/fake-execute-tool-call.js +19 -0
  307. package/build/test-support/fake-execute-tool-call.js.map +1 -0
  308. package/build/tool-error.d.ts +14 -0
  309. package/build/tool-error.d.ts.map +1 -0
  310. package/build/tool-error.js +18 -0
  311. package/build/tool-error.js.map +1 -0
  312. package/build/tool-response.d.ts +293 -0
  313. package/build/tool-response.d.ts.map +1 -0
  314. package/build/tool-response.js +365 -0
  315. package/build/tool-response.js.map +1 -0
  316. package/build/tools/assert-tools.d.ts +62 -0
  317. package/build/tools/assert-tools.d.ts.map +1 -0
  318. package/build/tools/assert-tools.js +330 -0
  319. package/build/tools/assert-tools.js.map +1 -0
  320. package/build/tools/breakpoint-await-abort.test.d.ts +2 -0
  321. package/build/tools/breakpoint-await-abort.test.d.ts.map +1 -0
  322. package/build/tools/breakpoint-await-abort.test.js +124 -0
  323. package/build/tools/breakpoint-await-abort.test.js.map +1 -0
  324. package/build/tools/breakpoint-tools.d.ts +99 -0
  325. package/build/tools/breakpoint-tools.d.ts.map +1 -0
  326. package/build/tools/breakpoint-tools.js +1197 -0
  327. package/build/tools/breakpoint-tools.js.map +1 -0
  328. package/build/tools/config-tools.d.ts +58 -0
  329. package/build/tools/config-tools.d.ts.map +1 -0
  330. package/build/tools/config-tools.js +180 -0
  331. package/build/tools/config-tools.js.map +1 -0
  332. package/build/tools/connection-routing.test.d.ts +12 -0
  333. package/build/tools/connection-routing.test.d.ts.map +1 -0
  334. package/build/tools/connection-routing.test.js +206 -0
  335. package/build/tools/connection-routing.test.js.map +1 -0
  336. package/build/tools/console-tools.d.ts +84 -0
  337. package/build/tools/console-tools.d.ts.map +1 -0
  338. package/build/tools/console-tools.js +241 -0
  339. package/build/tools/console-tools.js.map +1 -0
  340. package/build/tools/content-tools.d.ts +74 -0
  341. package/build/tools/content-tools.d.ts.map +1 -0
  342. package/build/tools/content-tools.js +582 -0
  343. package/build/tools/content-tools.js.map +1 -0
  344. package/build/tools/dashboard-tools.d.ts +38 -0
  345. package/build/tools/dashboard-tools.d.ts.map +1 -0
  346. package/build/tools/dashboard-tools.js +123 -0
  347. package/build/tools/dashboard-tools.js.map +1 -0
  348. package/build/tools/dom-tools.d.ts +41 -0
  349. package/build/tools/dom-tools.d.ts.map +1 -0
  350. package/build/tools/dom-tools.js +277 -0
  351. package/build/tools/dom-tools.js.map +1 -0
  352. package/build/tools/download-tools.d.ts +55 -0
  353. package/build/tools/download-tools.d.ts.map +1 -0
  354. package/build/tools/download-tools.js +280 -0
  355. package/build/tools/download-tools.js.map +1 -0
  356. package/build/tools/execution-tools.d.ts +40 -0
  357. package/build/tools/execution-tools.d.ts.map +1 -0
  358. package/build/tools/execution-tools.js +121 -0
  359. package/build/tools/execution-tools.js.map +1 -0
  360. package/build/tools/input-abort.test.d.ts +2 -0
  361. package/build/tools/input-abort.test.d.ts.map +1 -0
  362. package/build/tools/input-abort.test.js +227 -0
  363. package/build/tools/input-abort.test.js.map +1 -0
  364. package/build/tools/input-ambiguous-selector.test.d.ts +2 -0
  365. package/build/tools/input-ambiguous-selector.test.d.ts.map +1 -0
  366. package/build/tools/input-ambiguous-selector.test.js +34 -0
  367. package/build/tools/input-ambiguous-selector.test.js.map +1 -0
  368. package/build/tools/input-tools.d.ts +168 -0
  369. package/build/tools/input-tools.d.ts.map +1 -0
  370. package/build/tools/input-tools.js +1397 -0
  371. package/build/tools/input-tools.js.map +1 -0
  372. package/build/tools/inspection-tools.d.ts +112 -0
  373. package/build/tools/inspection-tools.d.ts.map +1 -0
  374. package/build/tools/inspection-tools.js +637 -0
  375. package/build/tools/inspection-tools.js.map +1 -0
  376. package/build/tools/interpolation.d.ts +30 -0
  377. package/build/tools/interpolation.d.ts.map +1 -0
  378. package/build/tools/interpolation.js +143 -0
  379. package/build/tools/interpolation.js.map +1 -0
  380. package/build/tools/issues-abort-hygiene.test.d.ts +2 -0
  381. package/build/tools/issues-abort-hygiene.test.d.ts.map +1 -0
  382. package/build/tools/issues-abort-hygiene.test.js +79 -0
  383. package/build/tools/issues-abort-hygiene.test.js.map +1 -0
  384. package/build/tools/issues-tools.d.ts +98 -0
  385. package/build/tools/issues-tools.d.ts.map +1 -0
  386. package/build/tools/issues-tools.js +821 -0
  387. package/build/tools/issues-tools.js.map +1 -0
  388. package/build/tools/issues-tools.test.d.ts +8 -0
  389. package/build/tools/issues-tools.test.d.ts.map +1 -0
  390. package/build/tools/issues-tools.test.js +184 -0
  391. package/build/tools/issues-tools.test.js.map +1 -0
  392. package/build/tools/modal-tools.d.ts +73 -0
  393. package/build/tools/modal-tools.d.ts.map +1 -0
  394. package/build/tools/modal-tools.js +161 -0
  395. package/build/tools/modal-tools.js.map +1 -0
  396. package/build/tools/network-tools.d.ts +72 -0
  397. package/build/tools/network-tools.d.ts.map +1 -0
  398. package/build/tools/network-tools.js +432 -0
  399. package/build/tools/network-tools.js.map +1 -0
  400. package/build/tools/page-tools.d.ts +78 -0
  401. package/build/tools/page-tools.d.ts.map +1 -0
  402. package/build/tools/page-tools.js +255 -0
  403. package/build/tools/page-tools.js.map +1 -0
  404. package/build/tools/plugin-tools.d.ts +16 -0
  405. package/build/tools/plugin-tools.d.ts.map +1 -0
  406. package/build/tools/plugin-tools.js +17 -0
  407. package/build/tools/plugin-tools.js.map +1 -0
  408. package/build/tools/replay-abort.test.d.ts +2 -0
  409. package/build/tools/replay-abort.test.d.ts.map +1 -0
  410. package/build/tools/replay-abort.test.js +259 -0
  411. package/build/tools/replay-abort.test.js.map +1 -0
  412. package/build/tools/replay-add-conditional.test.d.ts +2 -0
  413. package/build/tools/replay-add-conditional.test.d.ts.map +1 -0
  414. package/build/tools/replay-add-conditional.test.js +205 -0
  415. package/build/tools/replay-add-conditional.test.js.map +1 -0
  416. package/build/tools/replay-background-run.test.d.ts +2 -0
  417. package/build/tools/replay-background-run.test.d.ts.map +1 -0
  418. package/build/tools/replay-background-run.test.js +176 -0
  419. package/build/tools/replay-background-run.test.js.map +1 -0
  420. package/build/tools/replay-capture-variables.test.d.ts +2 -0
  421. package/build/tools/replay-capture-variables.test.d.ts.map +1 -0
  422. package/build/tools/replay-capture-variables.test.js +331 -0
  423. package/build/tools/replay-capture-variables.test.js.map +1 -0
  424. package/build/tools/replay-conditional-idb-and-launch.test.d.ts +11 -0
  425. package/build/tools/replay-conditional-idb-and-launch.test.d.ts.map +1 -0
  426. package/build/tools/replay-conditional-idb-and-launch.test.js +429 -0
  427. package/build/tools/replay-conditional-idb-and-launch.test.js.map +1 -0
  428. package/build/tools/replay-conditional-timeout.test.d.ts +14 -0
  429. package/build/tools/replay-conditional-timeout.test.d.ts.map +1 -0
  430. package/build/tools/replay-conditional-timeout.test.js +126 -0
  431. package/build/tools/replay-conditional-timeout.test.js.map +1 -0
  432. package/build/tools/replay-declare.test.d.ts +2 -0
  433. package/build/tools/replay-declare.test.d.ts.map +1 -0
  434. package/build/tools/replay-declare.test.js +106 -0
  435. package/build/tools/replay-declare.test.js.map +1 -0
  436. package/build/tools/replay-declared-browser-cleanup.test.d.ts +2 -0
  437. package/build/tools/replay-declared-browser-cleanup.test.d.ts.map +1 -0
  438. package/build/tools/replay-declared-browser-cleanup.test.js +103 -0
  439. package/build/tools/replay-declared-browser-cleanup.test.js.map +1 -0
  440. package/build/tools/replay-declared-profile.test.d.ts +2 -0
  441. package/build/tools/replay-declared-profile.test.d.ts.map +1 -0
  442. package/build/tools/replay-declared-profile.test.js +116 -0
  443. package/build/tools/replay-declared-profile.test.js.map +1 -0
  444. package/build/tools/replay-executor.d.ts +556 -0
  445. package/build/tools/replay-executor.d.ts.map +1 -0
  446. package/build/tools/replay-executor.js +2347 -0
  447. package/build/tools/replay-executor.js.map +1 -0
  448. package/build/tools/replay-folder-save.test.d.ts +2 -0
  449. package/build/tools/replay-folder-save.test.d.ts.map +1 -0
  450. package/build/tools/replay-folder-save.test.js +63 -0
  451. package/build/tools/replay-folder-save.test.js.map +1 -0
  452. package/build/tools/replay-formatters.d.ts +148 -0
  453. package/build/tools/replay-formatters.d.ts.map +1 -0
  454. package/build/tools/replay-formatters.js +856 -0
  455. package/build/tools/replay-formatters.js.map +1 -0
  456. package/build/tools/replay-kill-chrome-on-finish.test.d.ts +2 -0
  457. package/build/tools/replay-kill-chrome-on-finish.test.d.ts.map +1 -0
  458. package/build/tools/replay-kill-chrome-on-finish.test.js +175 -0
  459. package/build/tools/replay-kill-chrome-on-finish.test.js.map +1 -0
  460. package/build/tools/replay-multi-connection.test.d.ts +2 -0
  461. package/build/tools/replay-multi-connection.test.d.ts.map +1 -0
  462. package/build/tools/replay-multi-connection.test.js +547 -0
  463. package/build/tools/replay-multi-connection.test.js.map +1 -0
  464. package/build/tools/replay-output-and-record-options.test.d.ts +2 -0
  465. package/build/tools/replay-output-and-record-options.test.d.ts.map +1 -0
  466. package/build/tools/replay-output-and-record-options.test.js +240 -0
  467. package/build/tools/replay-output-and-record-options.test.js.map +1 -0
  468. package/build/tools/replay-production-error-shape.test.d.ts +7 -0
  469. package/build/tools/replay-production-error-shape.test.d.ts.map +1 -0
  470. package/build/tools/replay-production-error-shape.test.js +93 -0
  471. package/build/tools/replay-production-error-shape.test.js.map +1 -0
  472. package/build/tools/replay-rebase.test.d.ts +2 -0
  473. package/build/tools/replay-rebase.test.d.ts.map +1 -0
  474. package/build/tools/replay-rebase.test.js +58 -0
  475. package/build/tools/replay-rebase.test.js.map +1 -0
  476. package/build/tools/replay-retry-classification.test.d.ts +9 -0
  477. package/build/tools/replay-retry-classification.test.d.ts.map +1 -0
  478. package/build/tools/replay-retry-classification.test.js +57 -0
  479. package/build/tools/replay-retry-classification.test.js.map +1 -0
  480. package/build/tools/replay-run-registry.d.ts +55 -0
  481. package/build/tools/replay-run-registry.d.ts.map +1 -0
  482. package/build/tools/replay-run-registry.js +65 -0
  483. package/build/tools/replay-run-registry.js.map +1 -0
  484. package/build/tools/replay-sequence-reload.test.d.ts +2 -0
  485. package/build/tools/replay-sequence-reload.test.d.ts.map +1 -0
  486. package/build/tools/replay-sequence-reload.test.js +82 -0
  487. package/build/tools/replay-sequence-reload.test.js.map +1 -0
  488. package/build/tools/replay-socket-scope.test.d.ts +2 -0
  489. package/build/tools/replay-socket-scope.test.d.ts.map +1 -0
  490. package/build/tools/replay-socket-scope.test.js +75 -0
  491. package/build/tools/replay-socket-scope.test.js.map +1 -0
  492. package/build/tools/replay-step-connection.test.d.ts +2 -0
  493. package/build/tools/replay-step-connection.test.d.ts.map +1 -0
  494. package/build/tools/replay-step-connection.test.js +292 -0
  495. package/build/tools/replay-step-connection.test.js.map +1 -0
  496. package/build/tools/replay-step-timeout.test.d.ts +2 -0
  497. package/build/tools/replay-step-timeout.test.d.ts.map +1 -0
  498. package/build/tools/replay-step-timeout.test.js +204 -0
  499. package/build/tools/replay-step-timeout.test.js.map +1 -0
  500. package/build/tools/replay-tags.test.d.ts +2 -0
  501. package/build/tools/replay-tags.test.d.ts.map +1 -0
  502. package/build/tools/replay-tags.test.js +99 -0
  503. package/build/tools/replay-tags.test.js.map +1 -0
  504. package/build/tools/replay-teardown-and-foreach.test.d.ts +13 -0
  505. package/build/tools/replay-teardown-and-foreach.test.d.ts.map +1 -0
  506. package/build/tools/replay-teardown-and-foreach.test.js +318 -0
  507. package/build/tools/replay-teardown-and-foreach.test.js.map +1 -0
  508. package/build/tools/replay-tool-name-validation.test.d.ts +2 -0
  509. package/build/tools/replay-tool-name-validation.test.d.ts.map +1 -0
  510. package/build/tools/replay-tool-name-validation.test.js +280 -0
  511. package/build/tools/replay-tool-name-validation.test.js.map +1 -0
  512. package/build/tools/replay-tools.d.ts +288 -0
  513. package/build/tools/replay-tools.d.ts.map +1 -0
  514. package/build/tools/replay-tools.js +2994 -0
  515. package/build/tools/replay-tools.js.map +1 -0
  516. package/build/tools/request-abort.test.d.ts +2 -0
  517. package/build/tools/request-abort.test.d.ts.map +1 -0
  518. package/build/tools/request-abort.test.js +130 -0
  519. package/build/tools/request-abort.test.js.map +1 -0
  520. package/build/tools/request-tools.d.ts +56 -0
  521. package/build/tools/request-tools.d.ts.map +1 -0
  522. package/build/tools/request-tools.js +174 -0
  523. package/build/tools/request-tools.js.map +1 -0
  524. package/build/tools/screenshot-tools.d.ts +135 -0
  525. package/build/tools/screenshot-tools.d.ts.map +1 -0
  526. package/build/tools/screenshot-tools.js +636 -0
  527. package/build/tools/screenshot-tools.js.map +1 -0
  528. package/build/tools/server-tools.d.ts +88 -0
  529. package/build/tools/server-tools.d.ts.map +1 -0
  530. package/build/tools/server-tools.js +485 -0
  531. package/build/tools/server-tools.js.map +1 -0
  532. package/build/tools/source-tools.d.ts +46 -0
  533. package/build/tools/source-tools.d.ts.map +1 -0
  534. package/build/tools/source-tools.js +47 -0
  535. package/build/tools/source-tools.js.map +1 -0
  536. package/build/tools/storage-tools.d.ts +141 -0
  537. package/build/tools/storage-tools.d.ts.map +1 -0
  538. package/build/tools/storage-tools.js +894 -0
  539. package/build/tools/storage-tools.js.map +1 -0
  540. package/build/tools/storage-tools.test.d.ts +13 -0
  541. package/build/tools/storage-tools.test.d.ts.map +1 -0
  542. package/build/tools/storage-tools.test.js +707 -0
  543. package/build/tools/storage-tools.test.js.map +1 -0
  544. package/build/tools/tab-tools.d.ts +42 -0
  545. package/build/tools/tab-tools.d.ts.map +1 -0
  546. package/build/tools/tab-tools.js +296 -0
  547. package/build/tools/tab-tools.js.map +1 -0
  548. package/build/tools/wait-tools.d.ts +85 -0
  549. package/build/tools/wait-tools.d.ts.map +1 -0
  550. package/build/tools/wait-tools.js +236 -0
  551. package/build/tools/wait-tools.js.map +1 -0
  552. package/build/tools/wait-tools.test.d.ts +9 -0
  553. package/build/tools/wait-tools.test.d.ts.map +1 -0
  554. package/build/tools/wait-tools.test.js +193 -0
  555. package/build/tools/wait-tools.test.js.map +1 -0
  556. package/build/types.d.ts +133 -0
  557. package/build/types.d.ts.map +1 -0
  558. package/build/types.js +5 -0
  559. package/build/types.js.map +1 -0
  560. package/build/ui-verifier.d.ts +88 -0
  561. package/build/ui-verifier.d.ts.map +1 -0
  562. package/build/ui-verifier.js +540 -0
  563. package/build/ui-verifier.js.map +1 -0
  564. package/build/utils/abort.d.ts +68 -0
  565. package/build/utils/abort.d.ts.map +1 -0
  566. package/build/utils/abort.js +159 -0
  567. package/build/utils/abort.js.map +1 -0
  568. package/build/utils/abort.test.d.ts +2 -0
  569. package/build/utils/abort.test.d.ts.map +1 -0
  570. package/build/utils/abort.test.js +174 -0
  571. package/build/utils/abort.test.js.map +1 -0
  572. package/build/utils/modal-detection-core.d.ts +150 -0
  573. package/build/utils/modal-detection-core.d.ts.map +1 -0
  574. package/build/utils/modal-detection-core.js +332 -0
  575. package/build/utils/modal-detection-core.js.map +1 -0
  576. package/build/utils/modal-detection-core.test.d.ts +5 -0
  577. package/build/utils/modal-detection-core.test.d.ts.map +1 -0
  578. package/build/utils/modal-detection-core.test.js +492 -0
  579. package/build/utils/modal-detection-core.test.js.map +1 -0
  580. package/build/utils/modal-detector.d.ts +29 -0
  581. package/build/utils/modal-detector.d.ts.map +1 -0
  582. package/build/utils/modal-detector.js +281 -0
  583. package/build/utils/modal-detector.js.map +1 -0
  584. package/build/utils/modal-dismissal.d.ts +32 -0
  585. package/build/utils/modal-dismissal.d.ts.map +1 -0
  586. package/build/utils/modal-dismissal.js +190 -0
  587. package/build/utils/modal-dismissal.js.map +1 -0
  588. package/build/utils/port-check.d.ts +31 -0
  589. package/build/utils/port-check.d.ts.map +1 -0
  590. package/build/utils/port-check.js +87 -0
  591. package/build/utils/port-check.js.map +1 -0
  592. package/build/utils/port-check.test.d.ts +2 -0
  593. package/build/utils/port-check.test.d.ts.map +1 -0
  594. package/build/utils/port-check.test.js +66 -0
  595. package/build/utils/port-check.test.js.map +1 -0
  596. package/build/utils/selector-resolver.d.ts +66 -0
  597. package/build/utils/selector-resolver.d.ts.map +1 -0
  598. package/build/utils/selector-resolver.js +200 -0
  599. package/build/utils/selector-resolver.js.map +1 -0
  600. package/build/validation-helpers.d.ts +41 -0
  601. package/build/validation-helpers.d.ts.map +1 -0
  602. package/build/validation-helpers.js +236 -0
  603. package/build/validation-helpers.js.map +1 -0
  604. package/build/window-sizing.d.ts +26 -0
  605. package/build/window-sizing.d.ts.map +1 -0
  606. package/build/window-sizing.js +87 -0
  607. package/build/window-sizing.js.map +1 -0
  608. package/build/window-sizing.test.d.ts +8 -0
  609. package/build/window-sizing.test.d.ts.map +1 -0
  610. package/build/window-sizing.test.js +113 -0
  611. package/build/window-sizing.test.js.map +1 -0
  612. package/docs/README.md +74 -0
  613. package/docs/automation.md +562 -0
  614. package/docs/debugging.md +229 -0
  615. package/docs/installation.md +133 -0
  616. package/docs/instructions.md +227 -0
  617. package/docs/mcp-instructions.md +15 -0
  618. package/docs/message-system.md +327 -0
  619. package/docs/messages.md +3480 -0
  620. package/docs/parser-plugins.md +132 -0
  621. package/docs/replay.md +1411 -0
  622. package/docs/troubleshooting.md +272 -0
  623. package/docs/working_with_llm_notes.md +5 -0
  624. package/package.json +87 -0
  625. package/skills/devharness/SKILL.md +195 -0
  626. package/skills/devharness/references/sequences.md +613 -0
  627. package/skills/devharness/references/tool-categories.md +99 -0
@@ -0,0 +1,2994 @@
1
+ /**
2
+ * Command Replay Tools - Action router for sequence recording and playback
3
+ */
4
+ import { z } from 'zod';
5
+ import { selectSuiteFiles, sequenceFolders } from '../helpers/sequence-tree.js';
6
+ import { createTool } from '../validation-helpers.js';
7
+ import { createSuccessResponse, createErrorResponse } from '../messages.js';
8
+ import { showReplayOverlay } from '../interaction-recorder.js';
9
+ import { getIssue } from '../issue-tracker.js';
10
+ import { deriveConnectionReference, sanitizeReference } from '../reference-validator.js';
11
+ import { normalizeProfileName } from '../chrome-launcher.js';
12
+ import { runRegistry } from './replay-run-registry.js';
13
+ import { loadSequence, rebaseSequence, analyzeSequenceConnections, extractConnectionFromSequence, sequenceNeedsConnection, ensureConnection, navigateToStartUrl, executeSteps, executeSequenceWithPause, getDebugState, setReplayCursorCallbacks, injectReplayCursor, showClickEffect, showKeyPress, removeReplayCursor, autoLaunchChrome, commandNeedsBrowserConnection, analyzeRecordedStepConnections, commandTakesInjectedConnection, normalizeStepConnections, sanitizeConnectionMap, parseConnectionList, validateConditionSyntax, } from './replay-executor.js';
14
+ // =============================================================================
15
+ // Step tool-name validation (bug-010)
16
+ // =============================================================================
17
+ /**
18
+ * Step "tools" that the replay executor handles itself instead of dispatching
19
+ * through the MCP tool map (see replay-executor.ts). These are always valid
20
+ * step names even though they are not registered tools.
21
+ */
22
+ const VIRTUAL_STEP_TOOLS = new Set(['conditional', 'forEach']);
23
+ /** Levenshtein distance, used only to suggest a likely intended tool name. */
24
+ function editDistance(a, b) {
25
+ const prev = new Array(b.length + 1);
26
+ for (let j = 0; j <= b.length; j++)
27
+ prev[j] = j;
28
+ for (let i = 1; i <= a.length; i++) {
29
+ let diag = prev[0];
30
+ prev[0] = i;
31
+ for (let j = 1; j <= b.length; j++) {
32
+ const tmp = prev[j];
33
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, diag + (a[i - 1] === b[j - 1] ? 0 : 1));
34
+ diag = tmp;
35
+ }
36
+ }
37
+ return prev[b.length];
38
+ }
39
+ function suggestToolName(name, knownToolNames) {
40
+ const lower = name.toLowerCase();
41
+ let best;
42
+ for (const known of knownToolNames) {
43
+ const distance = editDistance(lower, known.toLowerCase());
44
+ if (distance <= 3 && (!best || distance < best.distance)) {
45
+ best = { name: known, distance };
46
+ }
47
+ }
48
+ return best?.name;
49
+ }
50
+ /**
51
+ * Find sequence steps whose `tool` is not a registered tool name.
52
+ *
53
+ * Only NAMES are validated - step params are deliberately not checked against
54
+ * the tools' zod schemas, because params legitimately contain interpolation
55
+ * tokens ({{var:...}}, {{timestamp}}) that are only substituted at run time,
56
+ * so a number-typed field can validly hold a string token at rest.
57
+ */
58
+ export function findUnknownStepTools(commands, knownToolNames) {
59
+ const known = new Set(knownToolNames);
60
+ const unknown = [];
61
+ commands.forEach((cmd, i) => {
62
+ const name = typeof cmd?.tool === 'string' ? cmd.tool : String(cmd?.tool);
63
+ if (known.has(name) || VIRTUAL_STEP_TOOLS.has(name))
64
+ return;
65
+ unknown.push({ step: i + 1, tool: name, suggestion: suggestToolName(name, knownToolNames) });
66
+ });
67
+ return unknown;
68
+ }
69
+ /**
70
+ * Build the error response for a sequence containing unknown tool names.
71
+ * Uses a plain response rather than a message template because there is no
72
+ * template for this case yet (see report for the suggested SEQUENCE_UNKNOWN_TOOL entry).
73
+ */
74
+ function unknownStepToolsError(action, sequenceName, unknown, knownToolNames) {
75
+ const plural = unknown.length === 1 ? '' : 's';
76
+ const lines = [
77
+ `Error: Sequence "${sequenceName}" references ${unknown.length} unknown tool name${plural}`,
78
+ `The "${action}" action was rejected before any step ran, so no browser state was changed.`,
79
+ '',
80
+ ];
81
+ for (const u of unknown) {
82
+ lines.push(`- Step ${u.step}: \`${u.tool}\` is not a known tool${u.suggestion ? ` - did you mean \`${u.suggestion}\`?` : ''}`);
83
+ }
84
+ lines.push('');
85
+ lines.push('**Fix:** correct the `tool` field on the listed step(s).');
86
+ lines.push(`**Known tools:** ${knownToolNames.slice().sort().join(', ')}`);
87
+ return { content: [{ type: 'text', text: lines.join('\n') }], isError: true };
88
+ }
89
+ /**
90
+ * Validate every step's tool name in a sequence. Returns an error response when
91
+ * any name is unknown, or null when the sequence is fine (including when no
92
+ * tool-name provider was supplied, which keeps validation opt-in).
93
+ */
94
+ function validateSequenceToolNames(sequence, action, getKnownToolNames) {
95
+ if (!getKnownToolNames)
96
+ return null;
97
+ const knownToolNames = getKnownToolNames();
98
+ if (!knownToolNames || knownToolNames.length === 0)
99
+ return null;
100
+ const unknown = findUnknownStepTools(sequence.commands ?? [], knownToolNames);
101
+ if (unknown.length === 0)
102
+ return null;
103
+ return unknownStepToolsError(action, sequence.name, unknown, knownToolNames);
104
+ }
105
+ /**
106
+ * Handle loadSequence error result - creates proper error response with template variables
107
+ */
108
+ function handleLoadSequenceError(result, action) {
109
+ return createErrorResponse(result.errorCode, {
110
+ action,
111
+ message: result.error,
112
+ ...result.templateVars
113
+ });
114
+ }
115
+ import { formatExecutionResults, formatPausedResponse, formatDebugState, formatBreakpointHit, formatClickValidationFailure, extractTextVariables, formatVariablePrompt, formatHistory, formatSequenceCreated, formatSequenceList, formatSequenceDetails, formatSavedSequencesList, formatActiveStatus, formatStepResults, formatInsertPrompt, formatInsertResult, formatConditionalAdded, formatDeclarations, formatEventsForReview, } from './replay-formatters.js';
116
+ import { readHistoryLines, getHistoryFilePath } from '../debug-logger.js';
117
+ import { startRecording, eventsToCommands, generateCondensedTimeline, isCommentEvent, } from '../interaction-recorder.js';
118
+ import { addIssue, initializeTracker, saveIssueSequence, } from '../issue-tracker.js';
119
+ import { configManager } from '../config.js';
120
+ // =============================================================================
121
+ // Schema Definition
122
+ // =============================================================================
123
+ const replaySchema = z.object({
124
+ action: z.enum([
125
+ 'history', 'create', 'list', 'get', 'delete',
126
+ 'export', 'load', 'listSaved', 'deleteSaved',
127
+ 'run', 'runAll', 'step', 'finish', 'insert', 'addConditional', 'declare', 'status', 'cancel',
128
+ 'repeat', 'runFromLog',
129
+ 'recordInteraction'
130
+ ]),
131
+ limit: z.number().optional().describe('Max items (default:50)'),
132
+ name: z.string().optional(),
133
+ description: z.string().optional(),
134
+ expectedOutcome: z.string().optional(),
135
+ startUrl: z.string().optional().describe('create: sequence start URL. run: replace the stored startUrl for this run only (e.g. a freshly minted link)'),
136
+ baseUrl: z.string().optional().describe('run: retarget the sequence at another deployment — every absolute URL (startUrl + command params) keeps its path/query but takes this origin. Not preserved across a mid-run pause/step resume'),
137
+ indices: z.array(z.number()).optional().describe('Command indices'),
138
+ lines: z.array(z.number()).optional().describe('Log line numbers'),
139
+ sequenceId: z.string().optional(),
140
+ runId: z.string().optional().describe('status/cancel: address a specific background run by the id that run returned'),
141
+ wait: z.boolean().optional().describe('run: block until the run completes and return the full result (pre-0.7 behaviour). Default false: return a runId immediately and execute in the background'),
142
+ global: z.boolean().optional().describe('Use ~/.cdp-tools/'),
143
+ format: z.enum(['sequence', 'playwright', 'puppeteer']).optional(),
144
+ filename: z.string().optional(),
145
+ intoHistory: z.boolean().optional(),
146
+ connectionReason: z.string().optional(),
147
+ requiredConnections: z.array(z.object({
148
+ reference: z.string().describe('Reference the steps use, e.g. "duo-member-two"'),
149
+ profile: z.string().optional().describe('Named persistent Chrome profile to come up on (launchChrome({ profile })). The durable identity: its storage survives between runs, so a device enrolled once stays enrolled'),
150
+ url: z.string().optional().describe("Opened on launch (defaults to the sequence's startUrl)"),
151
+ role: z.string().optional().describe('Why this browser exists, shown in the run summary'),
152
+ forceNewInstance: z.boolean().optional().describe('A distinct process rather than a tab. Default true, but false when profile is set - only one live Chrome may hold a profile'),
153
+ }).strict()).optional().describe('declare: the browsers this sequence needs. Replaces the whole list; [] clears it'),
154
+ tags: z.array(z.string()).optional().describe("declare: what kind of sequence this is, e.g. ['ui'] or ['contract','slow'] - replaces the whole list, [] clears it. runAll: run only sequences carrying at least one of these tags; the summary reports the split either way"),
155
+ requiredSockets: z.array(z.string()).optional().describe("declare: URL substrings of the WebSockets this sequence's assertions ride on, e.g. ['/api/sync/socket']. Match the app's own path, not the origin, so it survives baseUrl. Replaces the whole list; [] clears it"),
156
+ connections: z.record(z.string()).optional().describe("run: rebind a multi-connection sequence's recorded references onto this session - { \"<recorded reference>\": \"<reference here>\" }. Only needed when steps carry their own connectionReason (replay({action:'get', outputFormat:'commands'}) shows which)"),
157
+ record: z.boolean().optional(),
158
+ variables: z.record(z.string()).optional(),
159
+ stepTimeout: z.number().optional().describe('Per-step ms (default 30000). A step exceeding min(stepTimeout, remaining totalTimeout) fails the run at that step. wait steps are exempt (own timeoutMs) but still capped by totalTimeout'),
160
+ totalTimeout: z.number().optional().describe('Total ms'),
161
+ startFrom: z.number().optional().describe('Start step (1-indexed)'),
162
+ stepTo: z.number().optional().describe('Pause after step'),
163
+ stepCount: z.number().optional().describe('Steps to run'),
164
+ insertIndices: z.array(z.number()).optional(),
165
+ insertAfterStep: z.number().optional(),
166
+ condition: z.string().optional().describe("addConditional: the guard, e.g. '{{selector:.cookie-banner}}' or '{{!localStorage:token}}'"),
167
+ thenSequence: z.string().optional().describe('addConditional: name of the sequence to run when the condition holds'),
168
+ comment: z.string().optional().describe('addConditional: note stored on the step'),
169
+ overwrite: z.boolean().optional(),
170
+ newName: z.string().optional(),
171
+ showOverlay: z.boolean().optional(),
172
+ simplifyEvents: z.boolean().optional().describe('recordInteraction: collapse noisy raw events (default:true)'),
173
+ includeHovers: z.boolean().optional().describe('recordInteraction: keep mousemove steps (default:false)'),
174
+ outputFormat: z.enum(['events', 'commands', 'review', 'puppeteer', 'playwright']).optional()
175
+ .describe('get: commands|playwright|puppeteer. recordInteraction: events|commands|review (JSON dump, or a human-readable event walkthrough, appended to the summary)'),
176
+ preferCoordinates: z.boolean().optional().describe('recordInteraction: emit x,y clicks instead of selectors (default:false)'),
177
+ preferSelectors: z.boolean().optional().describe('recordInteraction: emit selector clicks even for canvas; wins over preferCoordinates (default:false)'),
178
+ issueId: z.number().optional(),
179
+ issueType: z.enum(['bug', 'feature']).optional(),
180
+ issueTitle: z.string().optional(),
181
+ showReplayOverlay: z.boolean().optional(),
182
+ showAll: z.boolean().optional().describe('Show all sequences including completed/fixed issues'),
183
+ requireSockets: z.boolean().optional().describe("run/runAll: fail the run if any WebSocket CLOSED or hit frame errors while it executed. Diffed against the start, so a socket already down is not blamed on this sequence, and it catches a drop that recovered before the last step - which a final assertion cannot see. Usually unnecessary: a sequence that sets `requiredSockets` (URL substrings of the sockets its assertions ride on) is checked without asking, and that check also fails when a declared socket is missing or never opened, which no closure count can detect"),
184
+ strict: z.enum(['errors', 'warnings']).optional().describe("run/runAll: fail the run when it PRODUCES console output - 'errors' fails on new console errors, 'warnings' also fails on new warnings. Counted per connection and diffed against the start of the run, so pre-existing noise is not blamed on this sequence. A sequence can be functionally correct and still be logging; strict is how you separate those questions"),
185
+ folder: z.string().optional().describe("runAll: sequences subfolder to run, relative to the sequences dir (e.g. 'spine'). Omit to run every sequence outside folders whose name starts with '_'. The whole tree is always LOADED first so name references (a conditional's then, a forEach's do) resolve wherever the helper lives"),
186
+ continueOnFailure: z.boolean().optional().describe('runAll: keep going after a sequence fails and report every result (default true). false stops at the first failure'),
187
+ killChromeOnFinish: z.boolean().optional().describe("run: after finishing (skipped on pause/abort), kill the browsers this run owns - its own connection plus any a launchChrome step actually created. A step that reached an already-bound reference only borrowed that browser and it is left running, so an instance you launched yourself survives. Also skipped for any browser whose port another live connection shares (a launchChrome step usually opens a tab in the same instance), and the run reports which connection kept it alive."),
188
+ }).strict();
189
+ async function handleHistory(args, recorder) {
190
+ const limit = args.limit || 50;
191
+ const history = recorder.getHistory(limit);
192
+ const stats = recorder.getStats();
193
+ // Mark history as viewed if we're in a paused sequence (enables insert)
194
+ if (recorder.getActiveSequence()) {
195
+ recorder.markHistoryViewed();
196
+ }
197
+ return { content: [{ type: 'text', text: formatHistory(history, stats.historyCount) }] };
198
+ }
199
+ /**
200
+ * Decide whether an explicit batch-level `connectionReason` may replace the
201
+ * connections the commands were recorded against (`repeat`, `runFromLog`).
202
+ *
203
+ * Yes for a single-connection batch - that is what the parameter has always
204
+ * meant, and silently ignoring it (which is what "never overwrite a recorded
205
+ * connection" amounted to once history started retaining them) breaks a
206
+ * documented knob with no signal. No for a batch spanning several browsers:
207
+ * there is no honest single answer, and picking one reproduces bug-018.
208
+ */
209
+ function resolveBatchOverride(commands, requested, action) {
210
+ if (!requested)
211
+ return { replaceRecorded: false };
212
+ const refs = new Set(commands
213
+ .filter(c => typeof c.params.connectionReason === 'string' && c.params.connectionReason.trim())
214
+ .map(c => sanitizeReference(c.params.connectionReason)));
215
+ if (refs.size > 1) {
216
+ return {
217
+ error: createErrorResponse('INVALID_PARAMETER', {
218
+ parameter: 'connectionReason',
219
+ value: requested,
220
+ message: `These commands were recorded against ${refs.size} different connections (${[...refs].join(', ')}), ` +
221
+ `so a single connectionReason cannot apply to all of them - running them in one browser would report success without ever using the second. ` +
222
+ `Omit connectionReason to replay each command against the connection it was recorded with, or ${action} the commands for one connection at a time.`
223
+ })
224
+ };
225
+ }
226
+ return { replaceRecorded: true };
227
+ }
228
+ async function handleRepeat(args, recorder, executeToolCall) {
229
+ if (!args.indices || args.indices.length === 0) {
230
+ return createErrorResponse('MISSING_PARAMETER', {
231
+ action: 'repeat',
232
+ missing: 'indices',
233
+ message: 'The "repeat" action requires an "indices" array with command indices to execute'
234
+ });
235
+ }
236
+ // Get commands from history
237
+ const commands = [];
238
+ for (const idx of args.indices) {
239
+ const cmd = recorder.getCommand(idx);
240
+ if (!cmd) {
241
+ return createErrorResponse('INVALID_INDICES', {
242
+ message: `Command index ${idx} not found in history. Use replay({ action: "history" }) to see available commands.`
243
+ });
244
+ }
245
+ commands.push({ tool: cmd.tool, params: cmd.params, index: idx });
246
+ }
247
+ // A command replays against the connection it was RECORDED with when it has one
248
+ // (bug-018) - repeating a batch that spans two browsers used to resolve one
249
+ // connection for the whole batch and stamp it onto every command, silently
250
+ // running both browsers' steps in one. Only commands with no recorded
251
+ // connection need a batch-level one. No `connections` mapping here: repeat
252
+ // replays from this session's own history, so the recorded references are the
253
+ // live ones by construction.
254
+ const needsConnection = commands.some(cmd => commandNeedsBrowserConnection(cmd) && !cmd.params.connectionReason);
255
+ let connectionReason = args.connectionReason;
256
+ // An explicitly passed connectionReason must still mean "run these against
257
+ // that connection" - history now retains the recorded one for every browser
258
+ // command, so honouring only bare commands turned this documented parameter
259
+ // into a silent no-op. It can only be honoured when the batch is
260
+ // single-connection; overriding a two-browser batch is the collapse bug-018
261
+ // is about, so that combination is refused rather than silently picking one.
262
+ const override = resolveBatchOverride(commands, args.connectionReason, 'repeat');
263
+ if ('error' in override)
264
+ return override.error;
265
+ // Try to extract connection from commands if not provided
266
+ if (!connectionReason && needsConnection) {
267
+ // Check if any command creates a connection (launchChrome, connectDebugger)
268
+ const launchCmd = commands.find(c => c.tool === 'launchChrome' || c.tool === 'connectDebugger');
269
+ if (launchCmd && launchCmd.params.reference) {
270
+ connectionReason = launchCmd.params.reference;
271
+ }
272
+ }
273
+ if (!connectionReason && needsConnection) {
274
+ return createErrorResponse('MISSING_PARAMETER', {
275
+ action: 'repeat',
276
+ missing: 'connectionReason',
277
+ message: 'These commands require a browser connection. Provide connectionReason parameter.'
278
+ });
279
+ }
280
+ // Execute commands
281
+ const results = [];
282
+ const startTime = Date.now();
283
+ for (const cmd of commands) {
284
+ try {
285
+ // Fill in a batch-level connection where the command has none, and replace
286
+ // the recorded one only when the caller explicitly asked to retarget a
287
+ // single-connection batch (see resolveBatchOverride).
288
+ const params = { ...cmd.params };
289
+ if (connectionReason && commandNeedsBrowserConnection(cmd) &&
290
+ (override.replaceRecorded || !params.connectionReason)) {
291
+ params.connectionReason = connectionReason;
292
+ }
293
+ await executeToolCall(cmd.tool, params);
294
+ results.push({ index: cmd.index, tool: cmd.tool, success: true });
295
+ }
296
+ catch (error) {
297
+ results.push({ index: cmd.index, tool: cmd.tool, success: false, error: error.message || String(error) });
298
+ // Stop on first error
299
+ break;
300
+ }
301
+ }
302
+ const durationMs = Date.now() - startTime;
303
+ const successful = results.filter(r => r.success).length;
304
+ const failed = results.filter(r => !r.success).length;
305
+ // Format response
306
+ let response = failed > 0
307
+ ? `**Repeat failed** at command #${results.find(r => !r.success)?.index}`
308
+ : `**Repeated ${successful} command${successful !== 1 ? 's' : ''}** in ${(durationMs / 1000).toFixed(1)}s`;
309
+ response += '\n';
310
+ results.forEach(r => {
311
+ const icon = r.success ? '✓' : '✗';
312
+ response += `\n#${r.index}. **${r.tool}** ${icon}`;
313
+ if (r.error) {
314
+ response += ` - ${r.error}`;
315
+ }
316
+ });
317
+ return { content: [{ type: 'text', text: response }] };
318
+ }
319
+ async function handleRunFromLog(args, executeToolCall) {
320
+ if (!args.lines || args.lines.length === 0) {
321
+ return createErrorResponse('MISSING_PARAMETER', {
322
+ action: 'runFromLog',
323
+ missing: 'lines',
324
+ message: `The "runFromLog" action requires a "lines" array with line numbers to execute from history.log (1-indexed, line 1 is most recent). File: ${getHistoryFilePath()}`
325
+ });
326
+ }
327
+ // Read commands from history.log file
328
+ const lineResults = await readHistoryLines(args.lines);
329
+ // Check for errors
330
+ const errors = lineResults.filter((r) => 'error' in r);
331
+ if (errors.length > 0) {
332
+ return createErrorResponse('INVALID_LINES', {
333
+ message: `Some lines could not be read from history.log:\n${errors.map(e => ` Line ${e.line}: ${e.error}`).join('\n')}`,
334
+ file: getHistoryFilePath()
335
+ });
336
+ }
337
+ const commands = lineResults;
338
+ // As in repeat: a logged command keeps the connection it was recorded with, so
339
+ // only the bare ones need a batch-level connection (bug-018).
340
+ const needsConnection = commands.some(cmd => commandNeedsBrowserConnection(cmd) && !cmd.params.connectionReason);
341
+ let connectionReason = args.connectionReason;
342
+ // Same rule as repeat: an explicit connectionReason retargets a
343
+ // single-connection batch, and is refused for a multi-connection one.
344
+ const override = resolveBatchOverride(commands, args.connectionReason, 'runFromLog');
345
+ if ('error' in override)
346
+ return override.error;
347
+ // Try to extract connection from commands if not provided
348
+ if (!connectionReason && needsConnection) {
349
+ const launchCmd = commands.find(c => c.tool === 'launchChrome' || c.tool === 'connectDebugger');
350
+ if (launchCmd && launchCmd.params.reference) {
351
+ connectionReason = launchCmd.params.reference;
352
+ }
353
+ }
354
+ if (!connectionReason && needsConnection) {
355
+ return createErrorResponse('MISSING_PARAMETER', {
356
+ action: 'runFromLog',
357
+ missing: 'connectionReason',
358
+ message: 'These commands require a browser connection. Provide connectionReason parameter.'
359
+ });
360
+ }
361
+ // Execute commands
362
+ const results = [];
363
+ const startTime = Date.now();
364
+ for (const cmd of commands) {
365
+ try {
366
+ const params = { ...cmd.params };
367
+ if (connectionReason && commandNeedsBrowserConnection(cmd) &&
368
+ (override.replaceRecorded || !params.connectionReason)) {
369
+ params.connectionReason = connectionReason;
370
+ }
371
+ await executeToolCall(cmd.tool, params);
372
+ results.push({ line: cmd.line, tool: cmd.tool, success: true });
373
+ }
374
+ catch (error) {
375
+ results.push({ line: cmd.line, tool: cmd.tool, success: false, error: error.message || String(error) });
376
+ break;
377
+ }
378
+ }
379
+ const durationMs = Date.now() - startTime;
380
+ const successful = results.filter(r => r.success).length;
381
+ const failed = results.filter(r => !r.success).length;
382
+ let response = failed > 0
383
+ ? `**runFromLog failed** at line ${results.find(r => !r.success)?.line}`
384
+ : `**Executed ${successful} command${successful !== 1 ? 's' : ''} from history.log** in ${(durationMs / 1000).toFixed(1)}s`;
385
+ response += '\n';
386
+ results.forEach(r => {
387
+ const icon = r.success ? '✓' : '✗';
388
+ response += `\nL${r.line}. **${r.tool}** ${icon}`;
389
+ if (r.error) {
390
+ response += ` - ${r.error}`;
391
+ }
392
+ });
393
+ return { content: [{ type: 'text', text: response }] };
394
+ }
395
+ async function handleCreate(args, recorder, getKnownToolNames) {
396
+ if (!args.name) {
397
+ return createErrorResponse('MISSING_PARAMETER', {
398
+ action: 'create',
399
+ missing: 'name',
400
+ message: 'The "create" action requires a "name" parameter'
401
+ });
402
+ }
403
+ if (!args.indices || args.indices.length === 0) {
404
+ return createErrorResponse('MISSING_PARAMETER', {
405
+ action: 'create',
406
+ missing: 'indices',
407
+ message: 'The "create" action requires an "indices" array with at least one command index'
408
+ });
409
+ }
410
+ // Reject unknown tool names up front rather than failing mid-run (bug-010).
411
+ // The check runs inside createSequence, on the candidate, BEFORE it replaces any
412
+ // same-named sequence in memory - otherwise a bad create would delete the user's
413
+ // good sequence and then reject the new one, leaving them with neither.
414
+ let invalid = null;
415
+ const sequence = await recorder.createSequence(args.name, args.indices, {
416
+ description: args.description,
417
+ expectedOutcome: args.expectedOutcome,
418
+ startUrl: args.startUrl,
419
+ validate: (candidate) => {
420
+ invalid = validateSequenceToolNames(candidate, 'create', getKnownToolNames);
421
+ return invalid === null;
422
+ },
423
+ });
424
+ if (invalid)
425
+ return invalid;
426
+ if (!sequence) {
427
+ return createErrorResponse('INVALID_INDICES', {
428
+ message: 'One or more command indices are invalid. Use replay({ action: "history" }) to see available commands.'
429
+ });
430
+ }
431
+ // Recorded steps keep the connection they were driven against (bug-018). Hoist
432
+ // it back off when the whole sequence shares one, so the sequence stays
433
+ // portable and a run-level connectionReason still retargets it; keep it
434
+ // per-step only where the sequence genuinely spans connections.
435
+ const normalized = normalizeStepConnections(sequence.commands);
436
+ sequence.commands = normalized.commands;
437
+ // Remember what was hoisted - `insert` needs it to tell a same-browser insert
438
+ // from a cross-browser one (see handleInsert).
439
+ if (normalized.hoisted)
440
+ sequence.recordedConnection = normalized.hoisted;
441
+ return { content: [{ type: 'text', text: formatSequenceCreated(sequence) + formatConnectionNote(normalized) }] };
442
+ }
443
+ /**
444
+ * Re-stamp the connection that `create` hoisted off the steps, so a merged
445
+ * command array is fully explicit about which browser each step belongs to.
446
+ * Without this a sequence's own bare steps read as "ambiguous" the moment
447
+ * anything connection-bearing is spliced in.
448
+ */
449
+ function rehydrateStepConnections(sequence) {
450
+ const recorded = sequence.recordedConnection;
451
+ if (!recorded)
452
+ return sequence.commands;
453
+ return sequence.commands.map(cmd => commandTakesInjectedConnection(cmd) && !cmd.params.connectionReason
454
+ ? { ...cmd, params: { ...cmd.params, connectionReason: recorded } }
455
+ : cmd);
456
+ }
457
+ /**
458
+ * What `create`/`insert` did with the recorded per-step connections, and what the
459
+ * user has to do about it on `run`. A multi-connection sequence is only portable
460
+ * if its references are rebound, and an ambiguous ("mixed") recording is worth
461
+ * saying out loud rather than guessing at.
462
+ */
463
+ function formatConnectionNote(normalized) {
464
+ const { analysis, hoisted } = normalized;
465
+ const notes = [];
466
+ if (hoisted) {
467
+ return `\n\n**Connection:** every step ran against \`${hoisted}\`, so it was hoisted off the steps` +
468
+ ` - the sequence is portable and \`replay({ action: 'run', connectionReason: '<other>' })\` retargets it.`;
469
+ }
470
+ if (analysis.multiConnection) {
471
+ notes.push(`\n\n**Multi-connection sequence:** steps keep their own connections (${analysis.references.map(r => `\`${r}\``).join(', ')}),` +
472
+ ` so the recorded interleaving is reproduced instead of collapsing into one browser.` +
473
+ ` A run-level \`connectionReason\` does NOT override them; in another session rebind them with` +
474
+ ` \`replay({ action: 'run', name: '...', connections: { ${analysis.references.map(r => `"${r}": "<reference here>"`).join(', ')} } })\`.` +
475
+ ` A reference that doesn't exist at run time fails that step rather than falling back.`);
476
+ }
477
+ // NOT an else-if. A sequence can be both, and that combination is the most
478
+ // dangerous one: bare steps in a two-browser sequence take whatever the
479
+ // run-level connection happens to be, so the same sequence sends them to a
480
+ // different browser depending on how it is run - silently, and green either
481
+ // way. Returning early on multiConnection used to make this warning
482
+ // unreachable in exactly the case that needs it.
483
+ if (analysis.mixed) {
484
+ notes.push(`\n\n**${analysis.multiConnection ? 'Some steps name no connection' : 'Mixed connections'}:** ` +
485
+ `steps naming ${analysis.references.map(r => `\`${r}\``).join(', ')} are pinned, but other browser steps name none` +
486
+ ` (they ran against whichever connection was active at record time, which is not recorded).` +
487
+ ` Those bare steps take the run-level connection, so ${analysis.multiConnection
488
+ ? `they land in a DIFFERENT browser depending on the run-level \`connectionReason\` - and the run still reports success either way.`
489
+ : `a run-level \`connectionReason\` retargets them while the named steps stay put.`}` +
490
+ ` Re-record passing \`connectionReason\` on every step to make this deterministic.`);
491
+ }
492
+ return notes.join('');
493
+ }
494
+ async function handleList(recorder) {
495
+ const sequences = recorder.listSequences();
496
+ return { content: [{ type: 'text', text: formatSequenceList(sequences) }] };
497
+ }
498
+ async function handleGet(args, recorder) {
499
+ // Use loadSequence to support both name (disk) and sequenceId (memory)
500
+ const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
501
+ if (!loadResult.success) {
502
+ return handleLoadSequenceError(loadResult, 'get');
503
+ }
504
+ const sequence = loadResult.sequence;
505
+ // Raw input events are only ever held in memory during recordInteraction -
506
+ // a stored sequence keeps the converted commands, not the events. Say so
507
+ // instead of silently returning the detail view.
508
+ if (args.outputFormat === 'events') {
509
+ return createErrorResponse('INVALID_PARAMETER', {
510
+ parameter: 'outputFormat',
511
+ value: 'events',
512
+ message: 'A stored sequence holds commands, not raw input events. Use outputFormat: "commands" here, or outputFormat: "events" on action "recordInteraction" to dump the raw events of a live recording.'
513
+ });
514
+ }
515
+ // 'review' renders raw input events too, so it has the same problem as
516
+ // 'events' - say so instead of silently returning the detail view.
517
+ if (args.outputFormat === 'review') {
518
+ return createErrorResponse('INVALID_PARAMETER', {
519
+ parameter: 'outputFormat',
520
+ value: 'review',
521
+ message: 'The review walkthrough renders raw input events, and a stored sequence holds commands, not events. Use outputFormat: "commands" here, or outputFormat: "review" on action "recordInteraction" to review the events of a live recording.'
522
+ });
523
+ }
524
+ if (args.outputFormat === 'commands') {
525
+ let output = `**${sequence.name} - Commands (JSON)**\n\n`;
526
+ output += '```json\n';
527
+ output += JSON.stringify(sequence.commands, null, 2);
528
+ output += '\n```';
529
+ return { content: [{ type: 'text', text: output }] };
530
+ }
531
+ // Check if output format is specified for code export
532
+ if (args.outputFormat === 'playwright') {
533
+ const code = generatePlaywrightCode(sequence.commands, sequence.startUrl);
534
+ let output = `**${sequence.name} - Playwright Code**\n\n`;
535
+ output += '```typescript\n';
536
+ output += code;
537
+ output += '\n```';
538
+ return { content: [{ type: 'text', text: output }] };
539
+ }
540
+ if (args.outputFormat === 'puppeteer') {
541
+ const code = generatePuppeteerCode(sequence.commands, sequence.startUrl);
542
+ let output = `**${sequence.name} - Puppeteer Code**\n\n`;
543
+ output += '```javascript\n';
544
+ output += code;
545
+ output += '\n```';
546
+ return { content: [{ type: 'text', text: output }] };
547
+ }
548
+ return { content: [{ type: 'text', text: formatSequenceDetails(sequence) }] };
549
+ }
550
+ async function handleDelete(args, recorder) {
551
+ // Use loadSequence to support both name and sequenceId
552
+ const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
553
+ if (!loadResult.success) {
554
+ return handleLoadSequenceError(loadResult, 'delete');
555
+ }
556
+ const sequence = loadResult.sequence;
557
+ const deleted = recorder.deleteSequence(sequence.id);
558
+ if (!deleted) {
559
+ return createErrorResponse('SEQUENCE_NOT_FOUND', {
560
+ sequenceId: sequence.id,
561
+ message: `Sequence "${sequence.name}" not found.`
562
+ });
563
+ }
564
+ return createSuccessResponse('SEQUENCE_DELETED', {
565
+ sequenceId: sequence.id,
566
+ name: sequence.name,
567
+ message: `Sequence "${sequence.name}" deleted successfully.`
568
+ });
569
+ }
570
+ async function handleExport(args, recorder) {
571
+ const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
572
+ if (!loadResult.success) {
573
+ return handleLoadSequenceError(loadResult, 'export');
574
+ }
575
+ const sequence = loadResult.sequence;
576
+ const format = args.format || 'sequence';
577
+ const overwrite = args.overwrite ?? false;
578
+ // Always save sequence file first (for all formats)
579
+ const sequenceResult = await recorder.saveSequenceToDisk(sequence.id, args.global ?? false, overwrite);
580
+ if (!sequenceResult) {
581
+ return createErrorResponse('EXPORT_FAILED', { message: 'Sequence not found.' });
582
+ }
583
+ if (!sequenceResult.success) {
584
+ if (sequenceResult.conflict) {
585
+ return createSuccessResponse('EXPORT_CONFLICT', {
586
+ filepath: sequenceResult.filepath,
587
+ sequenceName: sequence.name,
588
+ format
589
+ });
590
+ }
591
+ return createErrorResponse('EXPORT_FAILED', { message: sequenceResult.error });
592
+ }
593
+ // If only exporting sequence JSON, we're done
594
+ if (format === 'sequence') {
595
+ const location = args.global ? 'global (~/.cdp-tools/sequences/)' : 'working directory';
596
+ return createSuccessResponse('EXPORT_SEQUENCE_SUCCESS', {
597
+ filename: sequenceResult.filepath,
598
+ location
599
+ });
600
+ }
601
+ // Export as Playwright or Puppeteer test
602
+ const replayConfig = configManager.getReplayConfig();
603
+ const isPlaywright = format === 'playwright';
604
+ const code = isPlaywright
605
+ ? generatePlaywrightCode(sequence.commands, sequence.startUrl)
606
+ : generatePuppeteerCode(sequence.commands, sequence.startUrl);
607
+ const exportPath = isPlaywright ? replayConfig.playwrightExportPath : replayConfig.puppeteerExportPath;
608
+ const extension = isPlaywright ? '.spec.ts' : '.test.js';
609
+ const fs = await import('fs');
610
+ const path = await import('path');
611
+ const sanitizedName = sequence.name.replace(/[^a-zA-Z0-9-_]/g, '-');
612
+ const fullPath = path.resolve(exportPath, `${sanitizedName}${extension}`);
613
+ // Check for test file conflict
614
+ if (fs.existsSync(fullPath) && !overwrite) {
615
+ return createSuccessResponse('EXPORT_CONFLICT', {
616
+ filepath: fullPath,
617
+ sequenceName: sequence.name,
618
+ format
619
+ });
620
+ }
621
+ // Write the test file
622
+ const dir = path.dirname(fullPath);
623
+ if (!fs.existsSync(dir)) {
624
+ fs.mkdirSync(dir, { recursive: true });
625
+ }
626
+ fs.writeFileSync(fullPath, code, 'utf-8');
627
+ return createSuccessResponse('EXPORT_SUCCESS', {
628
+ testFile: fullPath,
629
+ sequenceFile: sequenceResult.filepath,
630
+ format
631
+ });
632
+ }
633
+ async function handleLoad(args, recorder, getKnownToolNames) {
634
+ if (!args.filename) {
635
+ return createErrorResponse('MISSING_PARAMETER', {
636
+ action: 'load',
637
+ missing: 'filename',
638
+ message: 'The "load" action requires a "filename" parameter. Use listSaved to see available files.'
639
+ });
640
+ }
641
+ // Reject unknown tool names up front rather than failing mid-run (bug-010).
642
+ // Validation runs on the parsed candidate BEFORE it replaces any same-named
643
+ // sequence in memory, so a bad file can't evict a good in-memory sequence.
644
+ let invalid = null;
645
+ const sequence = await recorder.loadSequenceFromDisk(args.filename, {
646
+ validate: (candidate) => {
647
+ invalid = validateSequenceToolNames(candidate, 'load', getKnownToolNames);
648
+ return invalid === null;
649
+ },
650
+ });
651
+ if (invalid)
652
+ return invalid;
653
+ if (!sequence) {
654
+ return createErrorResponse('LOAD_FAILED', {
655
+ filename: args.filename,
656
+ error: 'File may not exist or be invalid.'
657
+ });
658
+ }
659
+ // If intoHistory is true, load commands into history without executing
660
+ if (args.intoHistory) {
661
+ let loadedCount = 0;
662
+ for (const cmd of sequence.commands) {
663
+ recorder.recordCommand(cmd.tool, cmd.params);
664
+ loadedCount++;
665
+ }
666
+ return createSuccessResponse('SEQUENCE_LOADED_INTO_HISTORY', {
667
+ sequenceId: sequence.id,
668
+ name: sequence.name,
669
+ commandCount: loadedCount,
670
+ message: `Loaded ${loadedCount} commands from "${sequence.name}" into history. Use replay({ action: 'history' }) to view.`
671
+ });
672
+ }
673
+ return createSuccessResponse('SEQUENCE_LOADED_FROM_DISK', {
674
+ sequenceId: sequence.id,
675
+ name: sequence.name,
676
+ commandCount: sequence.commands.length,
677
+ message: `Sequence "${sequence.name}" loaded successfully. Use replay({ action: 'run', sequenceId: '${sequence.id}' }) to execute.`
678
+ });
679
+ }
680
+ async function handleListSaved(args, recorder) {
681
+ const savedSequences = await recorder.listSavedSequencesOnDisk();
682
+ const issueSequences = await recorder.listIssueSequencesOnDisk();
683
+ const showAll = args.showAll ?? false;
684
+ return { content: [{ type: 'text', text: formatSavedSequencesList(savedSequences, issueSequences, showAll) }] };
685
+ }
686
+ async function handleDeleteSaved(args, recorder) {
687
+ if (!args.filename) {
688
+ return createErrorResponse('MISSING_PARAMETER', {
689
+ action: 'deleteSaved',
690
+ missing: 'filename',
691
+ message: 'The "deleteSaved" action requires a "filename" parameter'
692
+ });
693
+ }
694
+ const deleted = await recorder.deleteSequenceFromDisk(args.filename);
695
+ if (!deleted) {
696
+ return createErrorResponse('DELETE_FAILED', {
697
+ filename: args.filename,
698
+ message: `Failed to delete file "${args.filename}". File may not exist.`
699
+ });
700
+ }
701
+ return createSuccessResponse('SAVED_SEQUENCE_DELETED', {
702
+ filename: args.filename,
703
+ message: `Sequence file "${args.filename}" deleted successfully.`
704
+ });
705
+ }
706
+ /**
707
+ * Live connection references sharing `port`, excluding `self`. Empty when the
708
+ * session cannot be read - an unreadable list must not stop a requested kill,
709
+ * only a KNOWN co-tenant does.
710
+ */
711
+ async function connectionsSharingPort(executeToolCall, port, self) {
712
+ try {
713
+ const result = await executeToolCall('listConnections', {});
714
+ const parsed = parseConnectionList(result?.content?.[0]?.text || '');
715
+ if (!parsed)
716
+ return [];
717
+ return parsed
718
+ .filter(c => c.port === port
719
+ && c.connected !== false
720
+ && sanitizeReference(c.reference) !== sanitizeReference(self))
721
+ .map(c => c.reference);
722
+ }
723
+ catch {
724
+ return [];
725
+ }
726
+ }
727
+ /**
728
+ * References that sequences reached through `conditional` steps name, for
729
+ * validating `connections`. Resolution is memory-only and best-effort: a
730
+ * sequence that lives on disk isn't loaded here (that would register it as a
731
+ * side effect of validation), so `complete: false` says "this list may be
732
+ * short" and the caller must not treat a missing key as a typo.
733
+ */
734
+ function collectNestedRebindableReferences(commands, recorder, depth = 0, seen = new Set()) {
735
+ // Must track the executor's own cap, not a hardcoded copy: with a raised
736
+ // maxConditionalDepth, references at runtime-reachable depths would be
737
+ // omitted while `complete` still claimed the list was exhaustive, and a valid
738
+ // rebinding key would be rejected as a typo.
739
+ if (depth >= configManager.getReplayConfig().maxConditionalDepth) {
740
+ return { references: [], complete: false };
741
+ }
742
+ const references = [];
743
+ let complete = true;
744
+ for (const cmd of commands) {
745
+ if (cmd.tool !== 'conditional')
746
+ continue;
747
+ const then = typeof cmd.params?.then === 'string' ? cmd.params.then : undefined;
748
+ if (!then || seen.has(then))
749
+ continue;
750
+ seen.add(then);
751
+ const nested = recorder.listSequences().find(s => s.name === then);
752
+ if (!nested) {
753
+ complete = false;
754
+ continue;
755
+ }
756
+ references.push(...analyzeRecordedStepConnections(nested.commands).references);
757
+ for (const c of nested.commands) {
758
+ if ((c.tool === 'launchChrome' || c.tool === 'connectDebugger') && typeof c.params.reference === 'string') {
759
+ references.push(sanitizeReference(c.params.reference));
760
+ }
761
+ }
762
+ const deeper = collectNestedRebindableReferences(nested.commands, recorder, depth + 1, seen);
763
+ references.push(...deeper.references);
764
+ complete = complete && deeper.complete;
765
+ }
766
+ return { references, complete };
767
+ }
768
+ /**
769
+ * Run every sequence in a folder, in filename order, and report one line each.
770
+ *
771
+ * Two behaviours make this usable as a suite runner rather than a loop:
772
+ * - the ENTIRE tree is loaded before anything runs, so a sequence in spine/
773
+ * can still reference a helper in _helpers/ by name (conditional `then`,
774
+ * forEach `do`) — those resolve by sequence NAME, not by path;
775
+ * - a failure is recorded and the run continues (continueOnFailure, default
776
+ * true). A suite that stops at the first red tells you far less than one
777
+ * that finishes and shows you all of them.
778
+ *
779
+ * Folders whose name starts with '_' are loaded but never run on their own —
780
+ * that is where preamble/helper sequences live, which are meaningless in
781
+ * isolation and would fail if executed standalone.
782
+ */
783
+ async function handleRunAll(args, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort) {
784
+ // Stay inside ONE root. listSavedSequencesOnDisk merges the project dir with
785
+ // ~/.cdp-tools/sequences, and a bare runAll that swept in the user's global
786
+ // sequences would execute unrelated suites from other projects — and a name
787
+ // colliding across the two roots would select twice.
788
+ const wantLocation = args.global ? 'global' : 'working-dir';
789
+ const onDisk = (await recorder.listSavedSequencesOnDisk())
790
+ .filter(e => e.location === wantLocation);
791
+ if (onDisk.length === 0) {
792
+ return createErrorResponse('INVALID_PARAMETER', {
793
+ parameter: 'folder',
794
+ value: String(args.folder ?? ''),
795
+ message: `No sequences found in the ${args.global ? 'global (~/.cdp-tools/sequences)' : 'project'} sequences directory. Export or record one first${args.global ? '' : ', or pass global:true to run the global ones'}.`
796
+ });
797
+ }
798
+ // Load everything first so cross-folder name references resolve.
799
+ for (const entry of onDisk) {
800
+ await recorder.loadSequenceFromDisk(entry.fullPath);
801
+ }
802
+ const folder = (args.folder || '').replace(/^\/+|\/+$/g, '');
803
+ const chosen = new Set(selectSuiteFiles(onDisk.map(e => e.filename), folder));
804
+ const tagsOf = (name) => recorder.listSequences().find(s => s.name === name)?.tags ?? [];
805
+ // Tag selection runs after the folder pick, so `folder` and `tags` compose:
806
+ // "the ui sequences in spine/" is one call, not a choice between two axes.
807
+ let wantTags = [];
808
+ if (args.tags !== undefined) {
809
+ const cleaned = normalizeTags(args.tags);
810
+ if ('error' in cleaned) {
811
+ return createErrorResponse('INVALID_PARAMETER', { parameter: 'tags', value: args.tags.join(', '), message: cleaned.error });
812
+ }
813
+ wantTags = cleaned.tags;
814
+ }
815
+ const inFolder = onDisk
816
+ .filter(e => chosen.has(e.filename))
817
+ .sort((a, b) => a.filename.localeCompare(b.filename));
818
+ const selected = wantTags.length === 0
819
+ ? inFolder
820
+ : inFolder.filter(e => tagsOf(e.name).some(t => wantTags.includes(t)));
821
+ if (wantTags.length > 0 && selected.length === 0) {
822
+ const available = [...new Set(inFolder.flatMap(e => tagsOf(e.name)))].sort();
823
+ return createErrorResponse('INVALID_PARAMETER', {
824
+ parameter: 'tags',
825
+ value: wantTags.join(', '),
826
+ message: `No sequence ${folder ? `under "${folder}" ` : ''}carries ${wantTags.length > 1 ? 'any of those tags' : `the tag "${wantTags[0]}"`}. ` +
827
+ (available.length
828
+ ? `Tags in use here: ${available.join(', ')}.`
829
+ : `No sequence here is tagged yet - set one with replay({ action: 'declare', name: '...', tags: ['ui'] }).`),
830
+ });
831
+ }
832
+ if (selected.length === 0) {
833
+ const folders = sequenceFolders(onDisk.map(e => e.filename));
834
+ return createErrorResponse('INVALID_PARAMETER', {
835
+ parameter: 'folder',
836
+ value: folder,
837
+ message: `No sequences under "${folder}". ` +
838
+ (folders.length ? `Available folders: ${folders.join(', ')}.` : 'No subfolders exist yet — sequences are all at the top level.')
839
+ });
840
+ }
841
+ const keepGoing = args.continueOnFailure !== false;
842
+ const results = [];
843
+ for (const entry of selected) {
844
+ if (abortSignal?.aborted) {
845
+ results.push({ filename: entry.filename, name: entry.name, ok: false, detail: 'cancelled before it ran' });
846
+ continue;
847
+ }
848
+ let ok = false;
849
+ let detail = '';
850
+ try {
851
+ // Reuse handleRun so a suite run and a single run cannot drift apart.
852
+ const res = await handleRun({
853
+ ...args,
854
+ action: 'run',
855
+ folder: undefined,
856
+ continueOnFailure: undefined,
857
+ name: undefined,
858
+ sequenceId: entry.id,
859
+ wait: true,
860
+ // A suite has nobody to answer a prompt. Keeping the recorded values
861
+ // is the only unattended behaviour that still runs the sequence;
862
+ // leaving it undefined turns every parameterised sequence into a
863
+ // no-op that a caller then has to notice.
864
+ variables: args.variables ?? {},
865
+ // Per-run args that are actively wrong when fanned across a suite:
866
+ // killChromeOnFinish would tear down the browser between sequences and
867
+ // destroy the state a _helpers preamble just established, and
868
+ // startFrom/stepTo/stepCount/startUrl mean something only for one
869
+ // specific sequence.
870
+ killChromeOnFinish: undefined,
871
+ startFrom: undefined,
872
+ stepTo: undefined,
873
+ stepCount: undefined,
874
+ startUrl: undefined,
875
+ }, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort);
876
+ const text = (res?.content || []).map((c) => c?.text || '').join('\n');
877
+ // performRun stamps _meta.replay on every terminal response, so trust that
878
+ // over the prose. Regexing for a line starting with "Error:" both misses
879
+ // non-run outcomes (a variables prompt, a pause) and misfires on any step
880
+ // output that happens to echo one.
881
+ const meta = res?._meta?.replay;
882
+ if (meta && typeof meta.success === 'boolean') {
883
+ ok = meta.success === true && meta.paused !== true && meta.prompted !== true;
884
+ detail = meta.prompted
885
+ ? 'did not run: it has recorded variables and none were supplied — pass variables:{} to keep the recorded values'
886
+ : meta.paused
887
+ ? 'did not finish: the run PAUSED (stepTo, a breakpoint, or click validation) and is still open'
888
+ : (text.match(/\*\*Socket health failed\*\*[\s\S]*?(?=\n\n\*\*|$)/)?.[0]?.replace(/\s+/g, ' ').slice(0, 200)
889
+ || text.match(/\*\*Strict run failed\*\*[\s\S]*/)?.[0]?.replace(/\s+/g, ' ').slice(0, 200)
890
+ || text.match(/^\s*Error:.*$/m)?.[0]
891
+ || `failed at ${meta.failedSteps ?? '?'} step(s)`).trim();
892
+ }
893
+ else {
894
+ // No _meta means this was not a terminal run response at all.
895
+ ok = false;
896
+ detail = (text.match(/^\s*Error:.*$/m)?.[0] || text.split('\n')[0] || 'no run result').trim();
897
+ }
898
+ }
899
+ catch (err) {
900
+ ok = false;
901
+ detail = `threw: ${err?.message || String(err)}`;
902
+ }
903
+ results.push({ filename: entry.filename, name: entry.name, ok, detail });
904
+ if (!ok && !keepGoing)
905
+ break;
906
+ }
907
+ const passed = results.filter(r => r.ok).length;
908
+ const failed = results.length - passed;
909
+ const scope = [
910
+ folder ? `folder "${folder}"` : 'all sequences',
911
+ wantTags.length ? `tagged ${wantTags.join(' or ')}` : '',
912
+ ].filter(Boolean).join(', ');
913
+ // What the suite actually covered, reported every run rather than needing an
914
+ // audit to discover: "36 passed" reads as interface coverage whether or not
915
+ // any of it drove the interface.
916
+ const tagCounts = new Map();
917
+ let untagged = 0;
918
+ for (const r of results) {
919
+ const tags = tagsOf(r.name);
920
+ if (tags.length === 0)
921
+ untagged++;
922
+ for (const tag of tags)
923
+ tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
924
+ }
925
+ const split = [
926
+ ...[...tagCounts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([tag, n]) => `${n} ${tag}`),
927
+ ...(untagged > 0 ? [`${untagged} untagged`] : []),
928
+ ].join(', ');
929
+ const lines = results.map(r => `${r.ok ? 'PASS' : 'FAIL'} ${r.filename}${r.ok ? '' : ` — ${r.detail}`}`);
930
+ const skipped = selected.length - results.length;
931
+ return {
932
+ content: [{
933
+ type: 'text',
934
+ text: [
935
+ `runAll ${scope}: ${passed} passed, ${failed} failed${skipped > 0 ? `, ${skipped} not run (stopped at first failure)` : ''}` +
936
+ (split ? ` (${split})` : ''),
937
+ '',
938
+ ...lines,
939
+ '',
940
+ `Loaded ${onDisk.length} sequence(s) from disk; ran ${results.length}.`,
941
+ ].join('\n')
942
+ }],
943
+ ...(failed > 0 ? { isError: true } : {})
944
+ };
945
+ }
946
+ /**
947
+ * Launch the browsers a sequence declares it needs, if they are not live yet.
948
+ *
949
+ * Naming a connection on a step does not create it. Without this, a
950
+ * multi-browser sequence runs only when someone has already opened those
951
+ * browsers by hand — so an unattended suite run skips precisely the coverage
952
+ * that a single browser cannot provide.
953
+ *
954
+ * A caller's `connections` rebinding wins: the declaration supplies a default
955
+ * browser, it does not override where the caller wants the steps pointed.
956
+ */
957
+ /**
958
+ * Connections a sequence actually loads the app in - the ones a declared
959
+ * WebSocket could plausibly belong to.
960
+ *
961
+ * A navigate step names its connection or takes the run's; either way the app
962
+ * comes up there and its sockets open. Everything else (asserting a captured
963
+ * value, waiting, inspecting) can take a connection without ever giving the
964
+ * transport a page to live on, so counting those made an idle browser look
965
+ * driven and failed the run for a socket nothing had asked it to open.
966
+ *
967
+ * Empty when the sequence never navigates - it is then driving a page someone
968
+ * else loaded, and the caller falls back to the wider rule rather than
969
+ * silently checking nothing.
970
+ */
971
+ function navigatedConnections(commands, runConnection) {
972
+ const refs = [];
973
+ for (const cmd of commands) {
974
+ if (cmd.tool !== 'navigate')
975
+ continue;
976
+ const raw = cmd.params?.connectionReason;
977
+ const ref = typeof raw === 'string' && raw.trim() ? sanitizeReference(raw) : runConnection;
978
+ if (ref && !refs.includes(ref))
979
+ refs.push(ref);
980
+ }
981
+ return refs;
982
+ }
983
+ /**
984
+ * Reject a declaration set whose profiles cannot mean what it says, before
985
+ * anything is launched.
986
+ *
987
+ * Two failures, both of which would otherwise surface later as something else:
988
+ *
989
+ * - **Two references on one profile.** Only one live Chrome may hold a profile,
990
+ * so the second launch fails - but the message would be about ports, not
991
+ * about a sequence asking two identities to be the same browser. Same shape
992
+ * as the `connections` rule that refuses collapsing two references into one.
993
+ *
994
+ * - **Rebinding a profile-bearing reference.** A rebind normally wins, because
995
+ * a declaration is only a default. A profile is not a default, it is an
996
+ * identity claim: pointing "device-a" at some other browser runs device-a's
997
+ * steps somewhere that is not device-a, and the run reports success. That is
998
+ * the class of lie the per-step connection rules exist to prevent.
999
+ */
1000
+ function declaredProfileConflict(declared, connectionMap) {
1001
+ const byProfile = new Map();
1002
+ for (const decl of declared) {
1003
+ if (!decl.profile)
1004
+ continue;
1005
+ const reference = sanitizeReference(decl.reference);
1006
+ if (!reference)
1007
+ continue;
1008
+ const rebound = connectionMap?.[reference];
1009
+ if (rebound) {
1010
+ return `"${reference}" is declared on the persistent profile "${decl.profile}", so it names a specific browser identity, ` +
1011
+ `not a default - rebinding it onto "${rebound}" would run its steps in a browser that is not "${decl.profile}" and pass. ` +
1012
+ `Drop it from \`connections\`, or drop the profile from the declaration.`;
1013
+ }
1014
+ byProfile.set(decl.profile, [...(byProfile.get(decl.profile) || []), reference]);
1015
+ }
1016
+ for (const [profile, references] of byProfile) {
1017
+ if (references.length > 1) {
1018
+ return `${references.length} declared connections (${references.join(', ')}) name the same persistent profile "${profile}". ` +
1019
+ `Only one live Chrome may hold a profile, so they would be one browser - give each identity its own profile.`;
1020
+ }
1021
+ }
1022
+ return null;
1023
+ }
1024
+ async function ensureDeclaredConnections(sequence, executeToolCall, getPageForConnection, connectionMap) {
1025
+ const declared = sequence.requiredConnections;
1026
+ if (!Array.isArray(declared) || declared.length === 0)
1027
+ return { launched: [] };
1028
+ const conflict = declaredProfileConflict(declared, connectionMap);
1029
+ if (conflict)
1030
+ return { launched: [], error: `"${sequence.name}": ${conflict}`, invalid: true };
1031
+ const launched = [];
1032
+ for (const decl of declared) {
1033
+ const wanted = sanitizeReference(decl.reference);
1034
+ if (!wanted)
1035
+ continue;
1036
+ // Rebound onto an existing session connection: nothing to launch.
1037
+ const target = connectionMap?.[wanted] ?? wanted;
1038
+ if (connectionMap?.[wanted])
1039
+ continue;
1040
+ // Probing first is not enough: a reference can still resolve to a page after
1041
+ // the browser was killed out of band, and skipping the launch then fails the
1042
+ // first step that uses it. Attempt the launch and treat "already bound" as a
1043
+ // live browser to reuse.
1044
+ try {
1045
+ await executeToolCall('launchChrome', {
1046
+ reference: target,
1047
+ url: decl.url ?? sequence.startUrl,
1048
+ // A profile IS the browser this declaration wants, so a live Chrome
1049
+ // already running it is the target rather than something to spawn
1050
+ // beside - and only one Chrome may hold a profile, so forcing a second
1051
+ // process fails against the browser it was asking for.
1052
+ forceNewInstance: decl.profile
1053
+ ? decl.forceNewInstance === true
1054
+ : decl.forceNewInstance !== false,
1055
+ ...(decl.profile && { profile: decl.profile }),
1056
+ });
1057
+ launched.push(target);
1058
+ }
1059
+ catch (err) {
1060
+ const message = String(err?.message || err);
1061
+ if (/already bound/i.test(message)) {
1062
+ try {
1063
+ if (await getPageForConnection(target))
1064
+ continue;
1065
+ }
1066
+ catch { /* fall through to the error below */ }
1067
+ }
1068
+ return {
1069
+ launched,
1070
+ error: `"${sequence.name}" needs the browser "${target}"${decl.role ? ` (${decl.role})` : ''} and launching it failed: ${err?.message || String(err)}`,
1071
+ };
1072
+ }
1073
+ }
1074
+ return { launched };
1075
+ }
1076
+ /**
1077
+ * Close browsers this run launched from a sequence's requiredConnections.
1078
+ *
1079
+ * The run created them, so the run owns them. Anything the caller supplied is
1080
+ * left alone. Without this a suite leaves a browser behind per multi-browser
1081
+ * sequence, and the next run silently reuses one holding state from before —
1082
+ * which is worse than the clutter, because it looks like a fresh browser.
1083
+ */
1084
+ async function closeLaunchedConnections(launched, executeToolCall, getConnectionPort, sequenceName,
1085
+ /** How the run came to own these, for the kill reason and the closing note. */
1086
+ origin = 'declared and launched') {
1087
+ if (launched.length === 0 || !getConnectionPort)
1088
+ return '';
1089
+ const closed = [];
1090
+ for (const ref of launched) {
1091
+ try {
1092
+ const port = await getConnectionPort(ref);
1093
+ if (port === null)
1094
+ continue;
1095
+ const sharers = await connectionsSharingPort(executeToolCall, port, ref);
1096
+ if (sharers.length > 0)
1097
+ continue; // someone else is on this browser
1098
+ await executeToolCall('killChrome', { reason: `sequence "${sequenceName}" ${origin} ${ref}`, port });
1099
+ // Release the reference as well. Killing the process leaves the name
1100
+ // bound, and the next sequence in a suite declaring the same reference
1101
+ // then fails to launch against a browser that no longer exists.
1102
+ await executeToolCall('disconnectDebugger', { reference: ref }).catch(() => { });
1103
+ closed.push(ref);
1104
+ }
1105
+ catch {
1106
+ // Best-effort: a browser that will not close is not a run failure.
1107
+ }
1108
+ }
1109
+ return closed.length ? `\n\n**Browsers closed** (${origin}): ${closed.join(', ')}` : '';
1110
+ }
1111
+ /**
1112
+ * Declared-browser cleanups owed by a run that PAUSED, keyed by run id (or by
1113
+ * sequence id for a `wait: true` pause, which registers no run record).
1114
+ *
1115
+ * A pause is the one outcome that deliberately keeps its browsers - they are
1116
+ * the state someone stopped to inspect. Every way out of a pause is terminal
1117
+ * though (cancel, step to the end, finish), and each used to drop the launched
1118
+ * references on the floor: the browsers stayed up and the next run reused one
1119
+ * carrying the previous run's state (issue #127).
1120
+ */
1121
+ const pendingDeclaredCleanups = new Map();
1122
+ const cleanupKey = (runId, sequenceId) => runId ?? `seq:${sequenceId}`;
1123
+ /** Run and forget the cleanup a paused run left owing, if any. */
1124
+ async function drainDeclaredCleanup(runId, sequenceId) {
1125
+ const key = cleanupKey(runId, sequenceId);
1126
+ const cleanup = pendingDeclaredCleanups.get(key);
1127
+ if (!cleanup)
1128
+ return '';
1129
+ pendingDeclaredCleanups.delete(key);
1130
+ return cleanup().catch(() => '');
1131
+ }
1132
+ /** Console error/warning counts per connection, for a strict run's before/after. */
1133
+ async function snapshotConsole(refs, executeToolCall) {
1134
+ const out = {};
1135
+ for (const ref of refs) {
1136
+ try {
1137
+ const res = await executeToolCall('console', { action: 'list', limit: 1, connectionReason: ref });
1138
+ out[ref] = {
1139
+ errors: res?._meta?.console?.errorCount || 0,
1140
+ warnings: res?._meta?.console?.warnCount || 0,
1141
+ };
1142
+ }
1143
+ catch {
1144
+ // A connection that cannot be read yet contributes nothing to the diff.
1145
+ }
1146
+ }
1147
+ return out;
1148
+ }
1149
+ /**
1150
+ * What a strict run should fail on: console output the sequence PRODUCED.
1151
+ *
1152
+ * Counted per connection and diffed against the start of the run, so noise that
1153
+ * was already on the page is not blamed on this sequence. Warnings count only
1154
+ * when strict is 'warnings' — a sequence can be functionally correct and still
1155
+ * be logging, and those are different questions.
1156
+ */
1157
+ function strictConsoleFailures(before, after, includeWarnings) {
1158
+ const out = [];
1159
+ for (const ref of Object.keys(after)) {
1160
+ const b = before[ref] || { errors: 0, warnings: 0 };
1161
+ const errs = after[ref].errors - b.errors;
1162
+ const warns = after[ref].warnings - b.warnings;
1163
+ if (errs > 0)
1164
+ out.push(`${ref}: ${errs} new console error(s)`);
1165
+ if (includeWarnings && warns > 0)
1166
+ out.push(`${ref}: ${warns} new console warning(s)`);
1167
+ }
1168
+ return out;
1169
+ }
1170
+ /**
1171
+ * Every WebSocket per connection, for a run's before/after comparison.
1172
+ *
1173
+ * A connection that cannot be read is recorded as unreadable rather than
1174
+ * omitted. Omitting it silently disables the health check for that connection -
1175
+ * a run then passes because nothing was measured, which is the exact failure
1176
+ * the check exists to prevent.
1177
+ */
1178
+ async function snapshotSockets(refs, executeToolCall) {
1179
+ const out = {};
1180
+ for (const ref of refs) {
1181
+ try {
1182
+ const res = await executeToolCall('network', { action: 'sockets', connectionReason: ref });
1183
+ const list = res?._meta?.socketList;
1184
+ out[ref] = Array.isArray(list)
1185
+ ? list
1186
+ : { unreadable: res?.isError ? firstLine(res) : 'socket health was not reported' };
1187
+ }
1188
+ catch (error) {
1189
+ out[ref] = { unreadable: error?.message || String(error) };
1190
+ }
1191
+ }
1192
+ return out;
1193
+ }
1194
+ /** First line of a tool response's text, for embedding in a failure message. */
1195
+ function firstLine(res) {
1196
+ const text = res?.content?.[0]?.text;
1197
+ return typeof text === 'string' ? text.split('\n')[0].slice(0, 120) : 'unreadable';
1198
+ }
1199
+ /** Shorten a socket URL for a failure message - the path is the identifying part. */
1200
+ function socketLabel(url) {
1201
+ try {
1202
+ const u = new URL(url);
1203
+ return `${u.pathname}${u.search}`.slice(0, 80) || url;
1204
+ }
1205
+ catch {
1206
+ return url.slice(0, 80);
1207
+ }
1208
+ }
1209
+ /**
1210
+ * Socket problems a run CAUSED, per socket.
1211
+ *
1212
+ * Diffed against the start so a socket that was already dead is not blamed on
1213
+ * this sequence. Two failures, and the sequence's declaration decides which
1214
+ * sockets are in scope:
1215
+ *
1216
+ * - one it depends on closed or hit frame errors mid-run. No assertion written
1217
+ * as a final step can see this: the app keeps rendering its last synced
1218
+ * snapshot after the socket dies, and a drop that recovered before the last
1219
+ * step leaves no trace at all.
1220
+ * - a declared socket is not open at the end. Absence and health are otherwise
1221
+ * indistinguishable - a transport that never came up closes nothing, so
1222
+ * counting closures alone passes an app that never connected.
1223
+ *
1224
+ * With no declaration (`requireSockets: true` on the run) every socket is in
1225
+ * scope for closures, but nothing can be required to exist - which socket ought
1226
+ * to be there is exactly what the declaration carries.
1227
+ *
1228
+ * Absence is returned separately because it is the one verdict worth waiting
1229
+ * on: sampled the instant the last step ends, it catches an app mid-reconnect
1230
+ * and calls a recovering transport a dead one. Closures and frame errors are
1231
+ * already-happened facts and never resolve by waiting.
1232
+ */
1233
+ function socketFailures(before, after, required, requiredOn) {
1234
+ const out = [];
1235
+ const absent = [];
1236
+ const inScope = (url) => required.length === 0 || required.some(m => url.includes(m));
1237
+ const list = (v) => Array.isArray(v) ? v : [];
1238
+ for (const ref of Object.keys(after)) {
1239
+ const afterEntry = after[ref];
1240
+ if (!Array.isArray(afterEntry)) {
1241
+ out.push(`${ref}: could not read socket health - ${afterEntry.unreadable}`);
1242
+ continue;
1243
+ }
1244
+ const was = new Map(list(before[ref]).map(s => [s.id, s]));
1245
+ const now = afterEntry;
1246
+ for (const sock of now) {
1247
+ if (!inScope(sock.url))
1248
+ continue;
1249
+ const prev = was.get(sock.id);
1250
+ // Three closes this run did not cause, all of them normal:
1251
+ // - already closed before the run started;
1252
+ // - torn down with its target, since a `navigate` replaces the page's
1253
+ // workers and takes their sockets with it;
1254
+ // - hung up by the page itself, which an app does on sign-out or an
1255
+ // identity change.
1256
+ // Whether a socket came back afterwards is the end-state check's
1257
+ // question, not this one's.
1258
+ const deliberate = sock.closedWithTarget || sock.clientClosed;
1259
+ if (sock.closed && !deliberate && !prev?.closed) {
1260
+ out.push(`${ref}: ${socketLabel(sock.url)} [${sock.target}] closed during the run`);
1261
+ }
1262
+ const newErrors = sock.errors - (prev?.errors || 0);
1263
+ if (newErrors > 0) {
1264
+ out.push(`${ref}: ${socketLabel(sock.url)} [${sock.target}] hit ${newErrors} frame error(s)`);
1265
+ }
1266
+ }
1267
+ for (const match of requiredOn.includes(ref) ? required : []) {
1268
+ const matching = now.filter(s => s.url.includes(match));
1269
+ if (!matching.some(s => !s.closed)) {
1270
+ absent.push(matching.length === 0
1271
+ ? `${ref}: no WebSocket matching "${match}" was ever seen - the transport this sequence asserts on never opened`
1272
+ : `${ref}: no open WebSocket matching "${match}" at the end of the run (${matching.length} seen, all closed)`);
1273
+ }
1274
+ }
1275
+ }
1276
+ return { settled: out, absent };
1277
+ }
1278
+ async function handleRun(args, recorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort) {
1279
+ // Load sequence
1280
+ const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
1281
+ if (!loadResult.success) {
1282
+ return handleLoadSequenceError(loadResult, 'run');
1283
+ }
1284
+ // Run-time retarget: baseUrl swaps the origin of every absolute URL in the
1285
+ // sequence (startUrl + command params); startUrl replaces the entry URL
1286
+ // wholesale. Lets one recorded sequence run against any deployment.
1287
+ const sequence = (args.baseUrl || args.startUrl)
1288
+ ? rebaseSequence(loadResult.sequence, { baseUrl: args.baseUrl, startUrl: args.startUrl })
1289
+ : loadResult.sequence;
1290
+ const commands = sequence.commands;
1291
+ const analysis = analyzeSequenceConnections(commands);
1292
+ // Determine connection reason
1293
+ let connectionReason = args.connectionReason || extractConnectionFromSequence(commands, analysis);
1294
+ // Validate connection requirement - fall back to a reason derived from the
1295
+ // sequence name so we can auto-launch Chrome instead of erroring out
1296
+ const needsConnection = sequenceNeedsConnection(commands);
1297
+ if (!connectionReason && !analysis.hasLaunchBeforeConnection && needsConnection) {
1298
+ connectionReason = deriveConnectionReference(sequence.name);
1299
+ }
1300
+ // Handle variable extraction and prompting
1301
+ const extractedVariables = extractTextVariables(commands);
1302
+ if (Object.keys(extractedVariables).length > 0 && args.variables === undefined) {
1303
+ const idParam = args.sequenceId || args.name;
1304
+ // Tag it: this response is a PROMPT, not a run. runAll has to be able to
1305
+ // tell "asked you a question" from "executed and passed", or a suite goes
1306
+ // green for a sequence that ran zero steps.
1307
+ return {
1308
+ content: [{ type: 'text', text: formatVariablePrompt(sequence.name, idParam, extractedVariables, connectionReason) }],
1309
+ _meta: { tool: 'replay', action: 'run', timestamp: Date.now(), replay: { success: false, prompted: true } }
1310
+ };
1311
+ }
1312
+ // Validate the connection rebinding before any side effects. A key that names
1313
+ // no recorded reference is a typo the user needs to hear about now: silently
1314
+ // ignoring it would leave the step on its recorded reference and, in the worst
1315
+ // case, replay a cross-browser sequence in one browser (bug-018).
1316
+ const connectionMap = sanitizeConnectionMap(args.connections);
1317
+ if (connectionMap) {
1318
+ const recorded = analyzeRecordedStepConnections(commands);
1319
+ // A `conditional` step's sequence inherits this map, and a setup sequence
1320
+ // normally lives BEHIND the conditional - so its references have to count as
1321
+ // rebindable too, or the only rebindable ones are those needing no rebind.
1322
+ const nested = collectNestedRebindableReferences(commands, recorder);
1323
+ const launchRefs = commands
1324
+ .filter(c => (c.tool === 'launchChrome' || c.tool === 'connectDebugger') && typeof c.params.reference === 'string')
1325
+ .map(c => sanitizeReference(c.params.reference));
1326
+ const known = new Set([...recorded.references, ...launchRefs, ...nested.references]);
1327
+ // An unresolvable nested sequence (on disk, or created later) means we
1328
+ // cannot prove a key is a typo - and refusing a run over an unprovable
1329
+ // typo is worse than letting an unused mapping through.
1330
+ const unknown = nested.complete
1331
+ ? Object.keys(connectionMap).filter(k => !known.has(k))
1332
+ : [];
1333
+ if (unknown.length > 0) {
1334
+ return createErrorResponse('INVALID_PARAMETER', {
1335
+ parameter: 'connections',
1336
+ value: unknown.join(', '),
1337
+ message: `No step in "${sequence.name}" is recorded against ${unknown.map(u => `"${u}"`).join(', ')}. ` +
1338
+ (known.size > 0
1339
+ ? `Recorded references: ${[...known].join(', ')}. `
1340
+ : `No step in this sequence names a connection at all, so there is nothing to rebind - use connectionReason to set the run connection. `) +
1341
+ `Check replay({ action: 'get', name: '${sequence.name}', outputFormat: 'commands' }).`
1342
+ });
1343
+ }
1344
+ // Two recorded connections rebound onto ONE live reference replays the whole
1345
+ // multi-browser sequence in a single browser and reports success - bug-018
1346
+ // exactly, re-entered through the API that exists to prevent it. Refuse.
1347
+ const byTarget = new Map();
1348
+ for (const [from, to] of Object.entries(connectionMap)) {
1349
+ if (!recorded.references.includes(from))
1350
+ continue; // launch-only rename, harmless
1351
+ byTarget.set(to, [...(byTarget.get(to) ?? []), from]);
1352
+ }
1353
+ const collapsed = [...byTarget.entries()].filter(([, froms]) => froms.length > 1);
1354
+ if (collapsed.length > 0) {
1355
+ return createErrorResponse('INVALID_PARAMETER', {
1356
+ parameter: 'connections',
1357
+ value: collapsed.map(([to, froms]) => `${froms.join(' + ')} -> ${to}`).join('; '),
1358
+ message: `That mapping would run ${collapsed.map(([to, froms]) => `${froms.length} recorded connections (${froms.join(', ')}) in the single browser "${to}"`).join('; ')}. ` +
1359
+ `"${sequence.name}" spans more than one browser precisely to test what crosses between them; collapsing it would make the run pass without ever involving a second browser. ` +
1360
+ `Give each recorded reference its own live reference, or launch another browser first.`
1361
+ });
1362
+ }
1363
+ }
1364
+ // The run-level connection may itself have been DERIVED from the sequence (a
1365
+ // launchChrome reference), in which case it is a recorded name and needs the
1366
+ // same rebinding as the steps - otherwise it points at a reference that does
1367
+ // not exist here, and the startUrl navigation and cursor injection silently
1368
+ // no-op against it. An explicitly passed connectionReason is already a live
1369
+ // reference and is left alone.
1370
+ if (connectionMap && !args.connectionReason && connectionReason) {
1371
+ connectionReason = connectionMap[sanitizeReference(connectionReason)] ?? connectionReason;
1372
+ }
1373
+ // Bring up any browser the sequence declares before the first step.
1374
+ const declaredConns = await ensureDeclaredConnections(sequence, executeToolCall, getPageForConnection, connectionMap);
1375
+ if (declaredConns.error) {
1376
+ // A declaration that contradicts itself is a bad sequence, not a missing
1377
+ // browser - saying "connection not found" would send you looking for one.
1378
+ return declaredConns.invalid
1379
+ ? createErrorResponse('INVALID_PARAMETER', {
1380
+ parameter: 'requiredConnections',
1381
+ value: sequence.name,
1382
+ message: declaredConns.error,
1383
+ })
1384
+ // Not CONNECTION_NOT_FOUND: that template renders a generic "no active
1385
+ // browser connection" and drops the message, so every failed declaration
1386
+ // reported the same thing and never said which browser, which role, or
1387
+ // why the launch failed.
1388
+ : createErrorResponse('DECLARED_CONNECTION_FAILED', { message: declaredConns.error });
1389
+ }
1390
+ // Validate startFrom before any side effects, so both modes reject immediately
1391
+ if (args.startFrom && args.startFrom > sequence.commands.length) {
1392
+ return createErrorResponse('INVALID_START_FROM', {
1393
+ message: `startFrom (${args.startFrom}) exceeds sequence length (${sequence.commands.length})`
1394
+ });
1395
+ }
1396
+ const deps = {
1397
+ args, recorder, executeToolCall, getPageForConnection, getConnectionPort,
1398
+ sequence, analysis, connectionReason, needsConnection,
1399
+ launchedConnections: new Set(),
1400
+ ...(connectionMap && { connectionMap }),
1401
+ };
1402
+ // wait: true - pre-0.7 blocking behaviour, driven by the MCP request signal.
1403
+ // Also what nested `replay run` STEPS use (the executor injects wait: true),
1404
+ // so a run started from inside a sequence never registers as its own
1405
+ // top-level run and its caller keeps the result.
1406
+ // Connections a strict run watches: the run's own, plus every browser the
1407
+ // sequence declared.
1408
+ const watchedRefs = [...new Set([
1409
+ ...(connectionReason ? [connectionReason] : []),
1410
+ ...(sequence.requiredConnections || []).map(d => connectionMap?.[sanitizeReference(d.reference)] ?? sanitizeReference(d.reference)),
1411
+ ])].filter(Boolean);
1412
+ // A sequence that declares the sockets its assertions ride on is checked
1413
+ // whether or not the caller asked - that is the point of declaring it.
1414
+ const requiredSockets = sequence.requiredSockets || [];
1415
+ const checkSockets = args.requireSockets === true || requiredSockets.length > 0;
1416
+ // Requiring a declared socket to EXIST only makes sense on the browsers this
1417
+ // sequence drives. A multi-browser sequence names its connections per step and
1418
+ // leaves the run's own connection idle - demanding a transport there fails a
1419
+ // healthy run for a browser that was never asked to do anything.
1420
+ //
1421
+ // "Drives" is read from the NAVIGATE steps, because a socket rides on a
1422
+ // loaded app: a connection that never navigated has no page for the
1423
+ // transport to belong to. Inferring it from connection injection instead
1424
+ // failed a healthy three-browser run - its 3 bare steps were `assert`s over
1425
+ // values already captured, which take a connection but load nothing, and
1426
+ // that was enough to demand a sync socket on the run's own idle browser.
1427
+ const stepRefs = analyzeRecordedStepConnections(commands);
1428
+ const navigatedRefs = navigatedConnections(commands, connectionReason);
1429
+ const namedRefs = navigatedRefs.length > 0
1430
+ ? navigatedRefs.map(r => connectionMap?.[sanitizeReference(r)] ?? sanitizeReference(r))
1431
+ : stepRefs.references.length > 0 && !stepRefs.mixed
1432
+ ? stepRefs.references.map(r => connectionMap?.[sanitizeReference(r)] ?? sanitizeReference(r))
1433
+ : watchedRefs;
1434
+ // Absence is only checked on connections that are BOTH driven and watched, so
1435
+ // a driven ref nobody snapshots would quietly drop out of the check - the
1436
+ // declaration silently stops being enforced, which is the failure this whole
1437
+ // check exists to prevent. Widen the watch list instead of narrowing the
1438
+ // verdict.
1439
+ const unwatchedDriven = namedRefs.filter(r => !watchedRefs.includes(r));
1440
+ if (unwatchedDriven.length > 0)
1441
+ watchedRefs.push(...unwatchedDriven);
1442
+ const drivenRefs = namedRefs;
1443
+ const consoleBefore = args.strict ? await snapshotConsole(watchedRefs, executeToolCall) : {};
1444
+ const socketsBefore = checkSockets ? await snapshotSockets(watchedRefs, executeToolCall) : {};
1445
+ /**
1446
+ * Post-run health verdicts, applied to whichever response the caller will
1447
+ * read. Same rule as a sequence's own teardown: a paused run is not over, and
1448
+ * its browsers are the state someone stopped to look at.
1449
+ */
1450
+ const applyHealthChecks = async (response, outcome) => {
1451
+ if (outcome === 'paused')
1452
+ return true;
1453
+ let healthy = true;
1454
+ const fail = (heading, failures) => {
1455
+ if (failures.length === 0)
1456
+ return;
1457
+ healthy = false;
1458
+ if (response?.content?.[0]?.text === undefined)
1459
+ return;
1460
+ response.content[0].text += `\n\n${heading}\n${failures.map(f => `- ${f}`).join('\n')}`;
1461
+ response.isError = true;
1462
+ if (response._meta?.replay)
1463
+ response._meta.replay.success = false;
1464
+ };
1465
+ if (checkSockets) {
1466
+ // A declared socket missing at the last step may just be reconnecting, so
1467
+ // give it the same order of grace an in-page liveness assertion gets
1468
+ // rather than calling a recovering transport dead. Closures and frame
1469
+ // errors are settled facts - only absence is worth re-reading.
1470
+ let verdict = socketFailures(socketsBefore, await snapshotSockets(watchedRefs, executeToolCall), requiredSockets, drivenRefs);
1471
+ for (let attempt = 0; verdict.absent.length > 0 && attempt < 5; attempt++) {
1472
+ await new Promise(r => setTimeout(r, 1000));
1473
+ verdict = socketFailures(socketsBefore, await snapshotSockets(watchedRefs, executeToolCall), requiredSockets, drivenRefs);
1474
+ }
1475
+ fail('**Socket health failed** - the transport did not stay up:', [...verdict.settled, ...verdict.absent]);
1476
+ }
1477
+ if (args.strict) {
1478
+ fail('**Strict run failed** - the sequence produced console output:', strictConsoleFailures(consoleBefore, await snapshotConsole(watchedRefs, executeToolCall), args.strict === 'warnings'));
1479
+ }
1480
+ return healthy;
1481
+ };
1482
+ // Closing the browsers the sequence declared, deferred so that every terminal
1483
+ // outcome uses one path: a pause hands the debt to `pendingDeclaredCleanups`
1484
+ // and whatever ends the pause pays it (issues #127, #137).
1485
+ const closeDeclared = () => closeLaunchedConnections(declaredConns.launched, executeToolCall, getConnectionPort, sequence.name);
1486
+ /**
1487
+ * Tear down the browsers this run owns: its own connection, plus any a step
1488
+ * CREATED. Runs AFTER the health verdicts, because both of them interrogate
1489
+ * the browser - a run with `killChromeOnFinish` and declared sockets used to
1490
+ * kill Chrome first and then report "could not read socket health -
1491
+ * Connection not found" as a socket FAILURE. Every such run failed, for a
1492
+ * reason that was an artefact of its own cleanup.
1493
+ *
1494
+ * Ownership is not guessed from the sequence text: a `launchChrome` step
1495
+ * hands back an existing browser when the reference is already bound, which
1496
+ * is the multi-device case where killing would destroy state the user cannot
1497
+ * get back. The launch response says which it was, and only the ones this run
1498
+ * created are killed (issue #103).
1499
+ *
1500
+ * The kill is by PORT, and other connections can share one - a `launchChrome`
1501
+ * step usually opens a TAB in the same instance - so the port is checked for
1502
+ * other tenants first.
1503
+ */
1504
+ const killOwnedChrome = async () => {
1505
+ if (!args.killChromeOnFinish)
1506
+ return '';
1507
+ let note = '';
1508
+ if (connectionReason && getConnectionPort) {
1509
+ const port = await getConnectionPort(connectionReason);
1510
+ const sharers = port === null ? [] : await connectionsSharingPort(executeToolCall, port, connectionReason);
1511
+ if (sharers.length > 0) {
1512
+ note += `\n\n**Chrome left running** (port ${port} also serves ${sharers.join(', ')}, killChromeOnFinish)` +
1513
+ ` - killing it would take those connections with it.`;
1514
+ }
1515
+ else if (port !== null) {
1516
+ const killResult = await executeToolCall('killChrome', {
1517
+ reason: `killChromeOnFinish: sequence "${sequence.name}" completed`,
1518
+ port,
1519
+ }).catch((error) => ({ isError: true, error }));
1520
+ note += killResult?.isError
1521
+ ? `\n\n**Chrome kill failed** (${connectionReason}, port ${port}, killChromeOnFinish)`
1522
+ : `\n\n**Chrome killed** (${connectionReason}, port ${port}, killChromeOnFinish)`;
1523
+ }
1524
+ }
1525
+ // The run-level connection is handled above; everything else here is a
1526
+ // browser a step of this run opened and nobody else asked for.
1527
+ const stepOwned = [...deps.launchedConnections].filter(ref => ref !== connectionReason);
1528
+ note += await closeLaunchedConnections(stepOwned, executeToolCall, getConnectionPort, sequence.name, 'launched in a step');
1529
+ return note;
1530
+ };
1531
+ /** Everything a terminal run owes: verdicts first, then teardown. */
1532
+ const settle = async (response, outcome) => {
1533
+ const healthy = await applyHealthChecks(response, outcome);
1534
+ if (outcome === 'paused')
1535
+ return healthy;
1536
+ const notes = (await killOwnedChrome()) + (await closeDeclared());
1537
+ if (notes && response?.content?.[0]?.text !== undefined) {
1538
+ response.content[0].text += notes;
1539
+ }
1540
+ return healthy;
1541
+ };
1542
+ if (args.wait === true) {
1543
+ const { response, outcome } = await performRun(deps, abortSignal);
1544
+ await settle(response, outcome);
1545
+ if (outcome === 'paused') {
1546
+ pendingDeclaredCleanups.set(cleanupKey(undefined, sequence.id), closeDeclared);
1547
+ }
1548
+ return response;
1549
+ }
1550
+ // Background (default): register a run and return a handle immediately.
1551
+ const runId = runRegistry.newRunId();
1552
+ const controller = new AbortController();
1553
+ const record = {
1554
+ runId,
1555
+ sequenceId: sequence.id,
1556
+ sequenceName: sequence.name,
1557
+ connectionReason,
1558
+ status: 'running',
1559
+ startedAt: Date.now(),
1560
+ totalSteps: commands.length,
1561
+ currentStep: 0,
1562
+ results: [],
1563
+ controller,
1564
+ };
1565
+ runRegistry.register(record);
1566
+ performRun(deps, controller.signal, runId, (ev) => {
1567
+ record.currentStep = ev.step;
1568
+ record.currentTool = ev.tool;
1569
+ }).then(async ({ response, outcome, results }) => {
1570
+ // A background run is read through its record, so the verdicts have to land
1571
+ // there too - otherwise the same sequence passes or fails on `wait` alone.
1572
+ const healthy = await settle(response, outcome).catch(() => true);
1573
+ if (outcome === 'paused') {
1574
+ pendingDeclaredCleanups.set(cleanupKey(runId, sequence.id), closeDeclared);
1575
+ }
1576
+ record.finalResponse = response;
1577
+ if (results)
1578
+ record.results = results;
1579
+ record.endedAt = Date.now();
1580
+ // Still not derived by parsing the response: the check reports its own
1581
+ // verdict, and a run whose transport died did not complete successfully.
1582
+ record.status = !healthy && outcome === 'completed' ? 'failed' : outcome;
1583
+ }).catch(async (error) => {
1584
+ // A run that blew up still launched what it launched.
1585
+ await closeDeclared().catch(() => '');
1586
+ record.error = error?.message || String(error);
1587
+ record.endedAt = Date.now();
1588
+ record.status = 'failed';
1589
+ });
1590
+ const started = createSuccessResponse('REPLAY_RUN_STARTED', {
1591
+ runId,
1592
+ name: sequence.name,
1593
+ totalSteps: commands.length,
1594
+ connectionReason: connectionReason || 'none',
1595
+ });
1596
+ started._meta = {
1597
+ tool: 'replay', action: 'run', timestamp: Date.now(),
1598
+ replay: { runId, background: true, totalSteps: commands.length },
1599
+ };
1600
+ return started;
1601
+ }
1602
+ /**
1603
+ * Execute a run to completion: connection setup, cursor/overlay, step
1604
+ * execution, post-run cleanup (cursor/overlay/tab, debug state,
1605
+ * killChromeOnFinish). Everything after the fast validation in handleRun.
1606
+ *
1607
+ * Used by both modes: awaited directly for wait: true, spawned in the
1608
+ * background otherwise. The returned outcome is authoritative for the run
1609
+ * record's terminal status - never derived by parsing the response.
1610
+ */
1611
+ async function performRun(deps, abortSignal, runId, onProgress) {
1612
+ const { args, recorder, executeToolCall, getPageForConnection, getConnectionPort, sequence, analysis, connectionReason, needsConnection, connectionMap, launchedConnections } = deps;
1613
+ // Build execution context
1614
+ const ctx = {
1615
+ executeToolCall,
1616
+ commandRecorder: recorder,
1617
+ connectionReason: connectionReason,
1618
+ logPrefix: 'run',
1619
+ variableStore: {},
1620
+ launchedConnections,
1621
+ ...(connectionMap && { connectionMap })
1622
+ };
1623
+ // Ensure connection is ready
1624
+ let didAutoLaunch = false;
1625
+ if (needsConnection && !analysis.hasLaunchBeforeConnection) {
1626
+ const connResult = await ensureConnection(ctx, needsConnection, analysis.hasLaunchBeforeConnection);
1627
+ if (!connResult.success) {
1628
+ return {
1629
+ outcome: 'failed',
1630
+ response: createErrorResponse('LAUNCH_FAILED', {
1631
+ message: connResult.error,
1632
+ suggestion: 'Launch Chrome manually first'
1633
+ })
1634
+ };
1635
+ }
1636
+ didAutoLaunch = connResult.didAutoLaunch;
1637
+ }
1638
+ // Navigate to startUrl if needed
1639
+ const navResult = await navigateToStartUrl(ctx, sequence, analysis);
1640
+ if (!navResult.success) {
1641
+ // Close the tab if we auto-launched it
1642
+ if (didAutoLaunch && connectionReason) {
1643
+ await executeToolCall('tab', { action: 'close', reference: connectionReason }).catch(() => { });
1644
+ }
1645
+ return {
1646
+ outcome: 'failed',
1647
+ response: createErrorResponse('NAVIGATION_FAILED', {
1648
+ message: navResult.error,
1649
+ startUrl: sequence.startUrl
1650
+ })
1651
+ };
1652
+ }
1653
+ // Inject cursor if enabled in config
1654
+ let cursorPage = null;
1655
+ if (configManager.getReplayConfig().showCursor && connectionReason) {
1656
+ cursorPage = await getPageForConnection(connectionReason);
1657
+ if (cursorPage) {
1658
+ await injectReplayCursor(cursorPage);
1659
+ setReplayCursorCallbacks({
1660
+ onClickBefore: async (x, y, isRightClick) => {
1661
+ await showClickEffect(cursorPage, x, y, isRightClick);
1662
+ },
1663
+ onKeyPress: async (key) => {
1664
+ await showKeyPress(cursorPage, key);
1665
+ }
1666
+ });
1667
+ }
1668
+ }
1669
+ // Show replay overlay if requested (for issue verification)
1670
+ let cleanupReplayOverlay;
1671
+ if (args.showReplayOverlay && args.issueId && args.issueType && connectionReason) {
1672
+ const overlayPage = cursorPage || await getPageForConnection(connectionReason);
1673
+ if (overlayPage) {
1674
+ cleanupReplayOverlay = await showReplayOverlay(overlayPage, args.issueType, args.issueTitle || 'Verifying issue...', args.issueId);
1675
+ }
1676
+ }
1677
+ // Helper to clean up cursor, overlay, and optionally close tab
1678
+ const cleanup = async (closeTab = false) => {
1679
+ if (cursorPage) {
1680
+ await removeReplayCursor(cursorPage).catch(() => { });
1681
+ setReplayCursorCallbacks({});
1682
+ }
1683
+ if (cleanupReplayOverlay) {
1684
+ await cleanupReplayOverlay().catch(() => { });
1685
+ }
1686
+ if (closeTab && didAutoLaunch && connectionReason) {
1687
+ await executeToolCall('tab', { action: 'close', reference: connectionReason }).catch(() => { });
1688
+ }
1689
+ };
1690
+ // Calculate start step (convert 1-indexed to 0-indexed).
1691
+ // startFrom itself was validated in handleRun, before any side effects.
1692
+ const startStep = args.startFrom ? Math.max(0, args.startFrom - 1) : 0;
1693
+ // Register cleanup handler on abort signal BEFORE execution starts
1694
+ // This ensures cleanup runs even if the tool call is interrupted mid-execution
1695
+ if (abortSignal) {
1696
+ abortSignal.addEventListener('abort', () => { cleanup(true); }, { once: true });
1697
+ }
1698
+ // Execute the sequence
1699
+ const execResult = await executeSequenceWithPause({
1700
+ sequence,
1701
+ startStep,
1702
+ ctx,
1703
+ variables: args.variables,
1704
+ record: args.record,
1705
+ stepTimeout: args.stepTimeout,
1706
+ totalTimeout: args.totalTimeout,
1707
+ stepTo: args.stepTo,
1708
+ overrideConnectionReason: args.connectionReason,
1709
+ abortSignal,
1710
+ onProgress
1711
+ });
1712
+ // Handle abort - return early (cleanup already handled by abort signal listener)
1713
+ if (abortSignal?.aborted) {
1714
+ // results holds every step ATTEMPTED - failures and the abort marker
1715
+ // included - so its length is not a count of completed work. A run that
1716
+ // aborted while a step was failing reported that step as completed.
1717
+ const succeeded = execResult.results.filter(r => r.success).length;
1718
+ const failed = execResult.results.filter(r => !r.success).length;
1719
+ const abortedResponse = createSuccessResponse('REPLAY_ABORTED', {
1720
+ name: sequence.name,
1721
+ completedSteps: succeeded,
1722
+ totalSteps: sequence.commands.length,
1723
+ failedSteps: failed > 0 ? failed : null,
1724
+ message: 'Replay aborted by user'
1725
+ });
1726
+ abortedResponse._meta = {
1727
+ tool: 'replay', action: 'run', timestamp: Date.now(),
1728
+ replay: { success: false, totalSteps: sequence.commands.length, failedSteps: execResult.results.filter(r => !r.success).length, paused: true }
1729
+ };
1730
+ return { response: abortedResponse, outcome: 'cancelled', results: execResult.results };
1731
+ }
1732
+ // Handle breakpoint hit
1733
+ if (execResult.breakpointHit && connectionReason) {
1734
+ return { outcome: 'paused', results: execResult.results, response: { content: [{ type: 'text', text: formatBreakpointHit(sequence.name, execResult.results, execResult.totalCommands, execResult.durationMs, execResult.breakpointHit, connectionReason) }],
1735
+ _meta: {
1736
+ tool: 'replay', action: 'run', timestamp: Date.now(),
1737
+ replay: { success: false, totalSteps: sequence.commands.length, failedSteps: execResult.results.filter(r => !r.success).length, paused: true }
1738
+ }
1739
+ } };
1740
+ }
1741
+ // Handle click validation failure (pause for inspection/retry)
1742
+ if (execResult.clickValidationFailure && connectionReason) {
1743
+ // Set active sequence state so user can retry/continue
1744
+ const activeState = {
1745
+ sequenceId: sequence.id,
1746
+ sequenceName: sequence.name,
1747
+ currentStep: execResult.pausedAtStep - 1, // Back to failed step for retry
1748
+ totalSteps: sequence.commands.length,
1749
+ pausedAt: Date.now(),
1750
+ historyIndexAtPause: recorder.getHistory().length,
1751
+ connectionReason,
1752
+ runId,
1753
+ // step/finish must resolve per-step connections the way this run did
1754
+ ...(connectionMap && { connectionMap }),
1755
+ };
1756
+ recorder.setActiveSequence(activeState);
1757
+ return { outcome: 'paused', results: execResult.results, response: { content: [{ type: 'text', text: formatClickValidationFailure(sequence, execResult.results, execResult.pausedAtStep, execResult.durationMs, execResult.clickValidationFailure, connectionReason) }],
1758
+ _meta: {
1759
+ tool: 'replay', action: 'run', timestamp: Date.now(),
1760
+ replay: { success: false, totalSteps: sequence.commands.length, failedSteps: execResult.results.filter(r => !r.success).length, paused: true }
1761
+ } } };
1762
+ }
1763
+ // Handle paused state (stepTo)
1764
+ if (execResult.pausedAtStep && execResult.activeSequenceState) {
1765
+ recorder.setActiveSequence({ ...execResult.activeSequenceState, runId });
1766
+ return { outcome: 'paused', results: execResult.results, response: { content: [{ type: 'text', text: formatPausedResponse(sequence, execResult.results, execResult.pausedAtStep, execResult.durationMs) }],
1767
+ _meta: {
1768
+ tool: 'replay', action: 'run', timestamp: Date.now(),
1769
+ replay: { success: false, totalSteps: sequence.commands.length, failedSteps: execResult.results.filter(r => !r.success).length, paused: true }
1770
+ }
1771
+ } };
1772
+ }
1773
+ // Clean up cursor and overlay
1774
+ await cleanup();
1775
+ // Format results
1776
+ let response = formatExecutionResults(sequence.name, execResult.results, execResult.totalCommands, execResult.durationMs, execResult.teardownResults
1777
+ ? { results: execResult.teardownResults, ...(execResult.teardownFailed !== undefined ? { failed: execResult.teardownFailed } : {}) }
1778
+ : undefined);
1779
+ // Add debug state if successful
1780
+ const failed = execResult.results.filter(r => !r.success).length;
1781
+ if (connectionReason && failed === 0) {
1782
+ const debugState = await getDebugState(ctx);
1783
+ if (debugState) {
1784
+ response += formatDebugState(debugState, connectionReason);
1785
+ }
1786
+ }
1787
+ return {
1788
+ outcome: failed === 0 ? 'completed' : 'failed',
1789
+ results: execResult.results,
1790
+ response: { content: [{ type: 'text', text: response }],
1791
+ _meta: {
1792
+ tool: 'replay', action: 'run', timestamp: Date.now(),
1793
+ replay: { success: failed === 0, totalSteps: execResult.totalCommands, failedSteps: failed, paused: false }
1794
+ }
1795
+ }
1796
+ };
1797
+ }
1798
+ /** One line per known run, newest first, for the no-runId status overview. */
1799
+ function formatRunsOverview(records) {
1800
+ const lines = records.map(r => {
1801
+ const progress = r.status === 'running' || r.status === 'cancelling'
1802
+ ? ` - step ${r.currentStep}/${r.totalSteps}${r.currentTool ? ` (${r.currentTool})` : ''}`
1803
+ : ` - ${r.results.filter(s => s.success).length}/${r.totalSteps} steps ok`;
1804
+ return `- \`${r.runId}\` ${r.sequenceName}: **${r.status}**${progress}`;
1805
+ });
1806
+ return `**Runs** (details: \`replay({ action: 'status', runId: '...' })\`)\n${lines.join('\n')}`;
1807
+ }
1808
+ /** Full status for one run. For a settled run this includes the final result. */
1809
+ function formatRunRecord(record) {
1810
+ const elapsed = ((record.endedAt ?? Date.now()) - record.startedAt) / 1000;
1811
+ let text = `**Run \`${record.runId}\`** - ${record.sequenceName}: **${record.status}** (${elapsed.toFixed(1)}s)`;
1812
+ if (record.status === 'running' || record.status === 'cancelling') {
1813
+ text += record.currentStep > 0
1814
+ ? `\n\nExecuting step ${record.currentStep}/${record.totalSteps}${record.currentTool ? ` (${record.currentTool})` : ''}.`
1815
+ : `\n\nSetting up (connection/navigation), no step started yet.`;
1816
+ text += `\n\nPoll again with \`replay({ action: 'status', runId: '${record.runId}' })\``;
1817
+ if (record.status === 'running') {
1818
+ text += ` or stop it with \`replay({ action: 'cancel', runId: '${record.runId}' })\`.`;
1819
+ }
1820
+ else {
1821
+ text += `. Cancel was requested; steps that support cancellation stop promptly, others at the next step boundary.`;
1822
+ }
1823
+ }
1824
+ else if (record.finalResponse?.content?.[0]?.text) {
1825
+ text += `\n\n${record.finalResponse.content[0].text}`;
1826
+ if (record.status === 'paused') {
1827
+ text += `\n\nDrive the paused session with \`replay({ action: 'step' })\` / \`finish\`, or drop it with \`replay({ action: 'cancel', runId: '${record.runId}' })\`.`;
1828
+ }
1829
+ }
1830
+ else if (record.error) {
1831
+ text += `\n\nRun failed before producing a result: ${record.error}`;
1832
+ }
1833
+ return {
1834
+ content: [{ type: 'text', text }],
1835
+ _meta: {
1836
+ tool: 'replay', action: 'status', timestamp: Date.now(),
1837
+ replay: {
1838
+ runId: record.runId,
1839
+ runStatus: record.status,
1840
+ currentStep: record.currentStep,
1841
+ totalSteps: record.totalSteps,
1842
+ ...(record.finalResponse?._meta?.replay ?? {}),
1843
+ },
1844
+ },
1845
+ };
1846
+ }
1847
+ async function handleStatus(args, recorder) {
1848
+ if (args.runId) {
1849
+ const record = runRegistry.get(args.runId);
1850
+ if (!record) {
1851
+ return createErrorResponse('REPLAY_RUN_NOT_FOUND', { runId: args.runId });
1852
+ }
1853
+ return formatRunRecord(record);
1854
+ }
1855
+ const activeSeq = recorder.getActiveSequence();
1856
+ const runs = runRegistry.list();
1857
+ let text;
1858
+ if (activeSeq) {
1859
+ text = formatActiveStatus(activeSeq, recorder.getCommandsSincePause());
1860
+ }
1861
+ else {
1862
+ text = '**No active sequence.** Use `replay({ action: \'run\', name: \'...\', stepTo: N })` to start a step-through session.';
1863
+ }
1864
+ if (runs.length > 0) {
1865
+ text += `\n\n${formatRunsOverview(runs)}`;
1866
+ }
1867
+ return { content: [{ type: 'text', text }] };
1868
+ }
1869
+ /** Cancel one specific registered run, whatever state it is in. */
1870
+ async function cancelRunRecord(record, recorder) {
1871
+ if (record.status === 'running' || record.status === 'cancelling') {
1872
+ record.status = 'cancelling';
1873
+ record.controller.abort();
1874
+ return createSuccessResponse('REPLAY_RUN_CANCELLING', {
1875
+ runId: record.runId,
1876
+ name: record.sequenceName,
1877
+ });
1878
+ }
1879
+ if (record.status === 'paused') {
1880
+ const activeSeq = recorder.getActiveSequence();
1881
+ if (activeSeq?.runId === record.runId) {
1882
+ recorder.setActiveSequence(null);
1883
+ }
1884
+ record.status = 'cancelled';
1885
+ record.endedAt = record.endedAt ?? Date.now();
1886
+ // Cancelling ends the run, so it cleans up like any other terminal outcome.
1887
+ const closedNote = await drainDeclaredCleanup(record.runId, record.sequenceId);
1888
+ const response = createSuccessResponse('REPLAY_RUN_CANCELLED', {
1889
+ runId: record.runId,
1890
+ name: record.sequenceName,
1891
+ });
1892
+ if (closedNote)
1893
+ response.content[0].text += closedNote;
1894
+ return response;
1895
+ }
1896
+ return createSuccessResponse('REPLAY_RUN_ALREADY_FINISHED', {
1897
+ runId: record.runId,
1898
+ name: record.sequenceName,
1899
+ status: record.status,
1900
+ });
1901
+ }
1902
+ async function handleCancel(args, recorder) {
1903
+ // Explicit runId wins: cancel exactly that run.
1904
+ if (args.runId) {
1905
+ const record = runRegistry.get(args.runId);
1906
+ if (!record) {
1907
+ return createErrorResponse('REPLAY_RUN_NOT_FOUND', { runId: args.runId });
1908
+ }
1909
+ return cancelRunRecord(record, recorder);
1910
+ }
1911
+ // No runId: a paused step-through session takes precedence (pre-0.7
1912
+ // behaviour - `cancel` always meant "drop the paused session").
1913
+ const activeSeq = recorder.getActiveSequence();
1914
+ if (activeSeq) {
1915
+ if (activeSeq.runId) {
1916
+ const record = runRegistry.get(activeSeq.runId);
1917
+ if (record && record.status === 'paused') {
1918
+ record.status = 'cancelled';
1919
+ record.endedAt = record.endedAt ?? Date.now();
1920
+ }
1921
+ }
1922
+ const name = activeSeq.sequenceName;
1923
+ recorder.setActiveSequence(null);
1924
+ // Terminal: close what the paused run launched, whichever way it paused
1925
+ // (a `wait: true` pause registers no run record, hence the sequence key).
1926
+ const closedNote = await drainDeclaredCleanup(activeSeq.runId, activeSeq.sequenceId);
1927
+ return { content: [{ type: 'text', text: `**Cancelled:** ${name}${closedNote}` }] };
1928
+ }
1929
+ // No paused session: fall through to background runs. Unambiguous only if
1930
+ // exactly one is still executing.
1931
+ const active = runRegistry.active();
1932
+ if (active.length === 1) {
1933
+ return cancelRunRecord(active[0], recorder);
1934
+ }
1935
+ if (active.length > 1) {
1936
+ return createErrorResponse('REPLAY_RUN_AMBIGUOUS', {
1937
+ count: active.length,
1938
+ runList: active.map(r => `\`${r.runId}\` (${r.sequenceName}, step ${r.currentStep}/${r.totalSteps})`).join(', '),
1939
+ });
1940
+ }
1941
+ return { content: [{ type: 'text', text: '**No active sequence to cancel.**' }] };
1942
+ }
1943
+ async function handleStep(args, recorder, executeToolCall) {
1944
+ const activeSeq = recorder.getActiveSequence();
1945
+ if (!activeSeq) {
1946
+ return createErrorResponse('NO_ACTIVE_SEQUENCE', {
1947
+ message: 'No active sequence to step through. Use run with stepTo first.'
1948
+ });
1949
+ }
1950
+ const sequence = recorder.getSequence(activeSeq.sequenceId);
1951
+ if (!sequence) {
1952
+ recorder.setActiveSequence(null);
1953
+ return createErrorResponse('SEQUENCE_NOT_FOUND', {
1954
+ message: `Sequence ${activeSeq.sequenceId} no longer exists`
1955
+ });
1956
+ }
1957
+ const commands = sequence.commands;
1958
+ const stepCount = args.stepCount || 1;
1959
+ const startStep = activeSeq.currentStep;
1960
+ const endStep = Math.min(startStep + stepCount, commands.length);
1961
+ if (startStep >= commands.length) {
1962
+ recorder.setActiveSequence(null);
1963
+ const closedNote = await drainDeclaredCleanup(activeSeq.runId, activeSeq.sequenceId);
1964
+ return { content: [{ type: 'text', text: `**Sequence complete.** All ${commands.length} steps executed.${closedNote}` }] };
1965
+ }
1966
+ const ctx = {
1967
+ executeToolCall,
1968
+ commandRecorder: recorder,
1969
+ connectionReason: activeSeq.connectionReason,
1970
+ logPrefix: 'step',
1971
+ variableStore: activeSeq.capturedVariables ?? (activeSeq.capturedVariables = {}),
1972
+ runTimestamp: activeSeq.runTimestamp ?? (activeSeq.runTimestamp = Date.now()),
1973
+ // per-step connections resolve exactly as they did in the run that paused
1974
+ ...(activeSeq.connectionMap && { connectionMap: activeSeq.connectionMap })
1975
+ };
1976
+ const execResult = await executeSteps({
1977
+ sequence,
1978
+ startStep,
1979
+ endStep,
1980
+ ctx
1981
+ });
1982
+ const lastExecuted = execResult.results.length > 0 ? execResult.results[execResult.results.length - 1].step : startStep;
1983
+ const failed = execResult.results.some(r => !r.success);
1984
+ // Update active sequence state
1985
+ let closedNote = '';
1986
+ if (failed || lastExecuted >= commands.length) {
1987
+ recorder.setActiveSequence(null);
1988
+ // Stepping off the end (or onto a failure) ends the run: same cleanup a
1989
+ // straight-through run gets.
1990
+ closedNote = await drainDeclaredCleanup(activeSeq.runId, activeSeq.sequenceId);
1991
+ }
1992
+ else {
1993
+ recorder.updateActiveSequenceStep(lastExecuted);
1994
+ }
1995
+ return { content: [{ type: 'text', text: formatStepResults(sequence.name, execResult.results, startStep, commands.length, failed) + closedNote }] };
1996
+ }
1997
+ async function handleFinish(recorder, executeToolCall) {
1998
+ const activeSeq = recorder.getActiveSequence();
1999
+ if (!activeSeq) {
2000
+ return createErrorResponse('NO_ACTIVE_SEQUENCE', {
2001
+ message: 'No active sequence to finish. Use run with stepTo first.'
2002
+ });
2003
+ }
2004
+ const sequence = recorder.getSequence(activeSeq.sequenceId);
2005
+ if (!sequence) {
2006
+ recorder.setActiveSequence(null);
2007
+ return createErrorResponse('SEQUENCE_NOT_FOUND', {
2008
+ message: `Sequence ${activeSeq.sequenceId} no longer exists`
2009
+ });
2010
+ }
2011
+ const commands = sequence.commands;
2012
+ const startStep = activeSeq.currentStep;
2013
+ if (startStep >= commands.length) {
2014
+ recorder.setActiveSequence(null);
2015
+ const alreadyDone = await drainDeclaredCleanup(activeSeq.runId, activeSeq.sequenceId);
2016
+ return { content: [{ type: 'text', text: `**Sequence already complete.** All ${commands.length} steps executed.${alreadyDone}` }] };
2017
+ }
2018
+ const ctx = {
2019
+ executeToolCall,
2020
+ commandRecorder: recorder,
2021
+ connectionReason: activeSeq.connectionReason,
2022
+ logPrefix: 'finish',
2023
+ variableStore: activeSeq.capturedVariables ?? (activeSeq.capturedVariables = {}),
2024
+ runTimestamp: activeSeq.runTimestamp ?? (activeSeq.runTimestamp = Date.now()),
2025
+ ...(activeSeq.connectionMap && { connectionMap: activeSeq.connectionMap })
2026
+ };
2027
+ const execResult = await executeSteps({
2028
+ sequence,
2029
+ startStep,
2030
+ ctx
2031
+ });
2032
+ // Clear active sequence
2033
+ recorder.setActiveSequence(null);
2034
+ const closedNote = await drainDeclaredCleanup(activeSeq.runId, activeSeq.sequenceId);
2035
+ return { content: [{ type: 'text', text: formatExecutionResults(sequence.name, execResult.results, commands.length, execResult.durationMs) + closedNote }] };
2036
+ }
2037
+ async function handleInsert(args, recorder) {
2038
+ const activeSeq = recorder.getActiveSequence();
2039
+ if (!activeSeq) {
2040
+ return createErrorResponse('NO_ACTIVE_SEQUENCE', {
2041
+ message: 'No active sequence. Use run with stepTo first to pause a sequence.'
2042
+ });
2043
+ }
2044
+ const sequence = recorder.getSequence(activeSeq.sequenceId);
2045
+ if (!sequence) {
2046
+ return createErrorResponse('SEQUENCE_NOT_FOUND', {
2047
+ message: `Sequence ${activeSeq.sequenceId} no longer exists`
2048
+ });
2049
+ }
2050
+ const commandsSincePause = recorder.getCommandsSincePause();
2051
+ // If no insertIndices provided, show available commands
2052
+ if (!args.insertIndices || args.insertIndices.length === 0) {
2053
+ return { content: [{ type: 'text', text: formatInsertPrompt(sequence.name, commandsSincePause, activeSeq.currentStep, activeSeq.totalSteps) }] };
2054
+ }
2055
+ // Check if history was viewed first (required before insert with indices)
2056
+ if (!recorder.wasHistoryViewed()) {
2057
+ return {
2058
+ content: [{
2059
+ type: 'text',
2060
+ text: '**Run `replay({ action: \'history\' })` first** to see available commands and their indices before inserting.'
2061
+ }],
2062
+ isError: true
2063
+ };
2064
+ }
2065
+ // Validate indices
2066
+ const validIndices = args.insertIndices.filter(idx => commandsSincePause.some(cmd => cmd.index === idx));
2067
+ if (validIndices.length === 0) {
2068
+ let errorMsg = 'None of the provided indices are valid commands recorded since pause.\n\n';
2069
+ errorMsg += '**Run `replay({ action: \'history\' })` again** to see available commands and their indices.\n\n';
2070
+ if (commandsSincePause.length > 0) {
2071
+ errorMsg += `Valid indices since pause: ${commandsSincePause.map(c => c.index).join(', ')}`;
2072
+ }
2073
+ else {
2074
+ errorMsg += 'No commands have been recorded since the sequence was paused.';
2075
+ }
2076
+ return { content: [{ type: 'text', text: errorMsg }], isError: true };
2077
+ }
2078
+ // Get commands to insert
2079
+ const commandsToInsert = validIndices
2080
+ .map(idx => commandsSincePause.find(cmd => cmd.index === idx))
2081
+ .filter((cmd) => cmd !== undefined)
2082
+ .map(cmd => ({ tool: cmd.tool, params: cmd.params }));
2083
+ // Determine insert position
2084
+ const insertAfter = args.insertAfterStep !== undefined ? args.insertAfterStep : activeSeq.currentStep;
2085
+ // Build new commands array. Inserted history commands carry the connection they
2086
+ // were driven against (bug-018), so re-run the create-time normalization: an
2087
+ // insert into a single-connection sequence must not quietly pin those steps to
2088
+ // this session's reference and make the sequence unportable.
2089
+ // The sequence's own steps are bare because `create` hoisted their connection
2090
+ // off; re-stamp it first. Merging without that made every insert look
2091
+ // "ambiguous" (one named reference + bare steps), which blocks the hoist and
2092
+ // leaves the sequence half-pinned to this session's reference - unportable,
2093
+ // and green on a run that splits it across two browsers.
2094
+ const existingCommands = rehydrateStepConnections(sequence);
2095
+ const normalized = normalizeStepConnections([
2096
+ ...existingCommands.slice(0, insertAfter),
2097
+ ...commandsToInsert,
2098
+ ...existingCommands.slice(insertAfter)
2099
+ ]);
2100
+ const newCommands = normalized.commands;
2101
+ const connectionNote = formatConnectionNote(normalized);
2102
+ if (args.overwrite) {
2103
+ // Update existing sequence in place
2104
+ sequence.commands = newCommands;
2105
+ if (normalized.hoisted)
2106
+ sequence.recordedConnection = normalized.hoisted;
2107
+ else
2108
+ delete sequence.recordedConnection;
2109
+ return { content: [{ type: 'text', text: formatInsertResult(sequence.name, sequence.id, commandsToInsert.length, insertAfter, newCommands.length, true) + connectionNote }] };
2110
+ }
2111
+ else {
2112
+ // Create new sequence
2113
+ const newName = args.newName || `${sequence.name}-modified`;
2114
+ const newSequence = await recorder.createSequence(newName, [], { description: sequence.description, expectedOutcome: sequence.expectedOutcome, startUrl: sequence.startUrl });
2115
+ if (!newSequence) {
2116
+ return createErrorResponse('CREATE_FAILED', { message: 'Failed to create new sequence' });
2117
+ }
2118
+ // Manually set commands
2119
+ newSequence.commands = newCommands;
2120
+ if (normalized.hoisted)
2121
+ newSequence.recordedConnection = normalized.hoisted;
2122
+ return { content: [{ type: 'text', text: formatInsertResult(newName, newSequence.id, commandsToInsert.length, insertAfter, newCommands.length, false) + connectionNote }] };
2123
+ }
2124
+ }
2125
+ /**
2126
+ * Add a `conditional` step to a sequence.
2127
+ *
2128
+ * `conditional` is a virtual step, never a registered tool, so it cannot be
2129
+ * recorded and cannot come out of `create`/`insert`. This is its only
2130
+ * authoring route.
2131
+ */
2132
+ async function handleAddConditional(args, recorder) {
2133
+ if (!args.condition) {
2134
+ return createErrorResponse('MISSING_PARAMETER', {
2135
+ action: 'addConditional',
2136
+ missing: 'condition',
2137
+ message: 'The "addConditional" action requires a "condition" parameter, e.g. "{{selector:.cookie-banner}}"'
2138
+ });
2139
+ }
2140
+ if (!args.thenSequence) {
2141
+ return createErrorResponse('MISSING_PARAMETER', {
2142
+ action: 'addConditional',
2143
+ missing: 'thenSequence',
2144
+ message: 'The "addConditional" action requires a "thenSequence" parameter naming the sequence to run when the condition holds'
2145
+ });
2146
+ }
2147
+ const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
2148
+ if (!loadResult.success) {
2149
+ return handleLoadSequenceError(loadResult, 'addConditional');
2150
+ }
2151
+ const sequence = loadResult.sequence;
2152
+ const syntax = validateConditionSyntax(args.condition);
2153
+ if (!syntax.ok) {
2154
+ return createErrorResponse('INVALID_PARAMETER', {
2155
+ parameter: 'condition',
2156
+ value: args.condition,
2157
+ message: syntax.reason
2158
+ });
2159
+ }
2160
+ // Self-reference recurses until the depth cap truncates it.
2161
+ if (args.thenSequence === sequence.name) {
2162
+ return createErrorResponse('INVALID_PARAMETER', {
2163
+ parameter: 'thenSequence',
2164
+ value: args.thenSequence,
2165
+ message: `A conditional cannot branch to its own sequence ("${sequence.name}") - that recurses until maxConditionalDepth stops it.`
2166
+ });
2167
+ }
2168
+ // The target resolves by name at run time, so an unchecked typo fails
2169
+ // halfway through a run.
2170
+ const inMemory = recorder.listSequences().some(s => s.name === args.thenSequence);
2171
+ const onDisk = await recorder.listSavedSequencesOnDisk();
2172
+ if (!inMemory && !onDisk.some(s => s.name === args.thenSequence)) {
2173
+ // A disk sequence is in memory once loaded, so the lists overlap.
2174
+ const available = [...new Set([
2175
+ ...recorder.listSequences().map(s => s.name),
2176
+ ...onDisk.map(s => s.name)
2177
+ ])];
2178
+ return createErrorResponse('SEQUENCE_NOT_FOUND', {
2179
+ message: `No sequence named "${args.thenSequence}" to branch to. Available: ${available.join(', ') || 'none'}`
2180
+ });
2181
+ }
2182
+ const commands = sequence.commands;
2183
+ const insertAfter = args.insertAfterStep !== undefined ? args.insertAfterStep : commands.length;
2184
+ if (insertAfter < 0 || insertAfter > commands.length) {
2185
+ return createErrorResponse('INVALID_PARAMETER', {
2186
+ parameter: 'insertAfterStep',
2187
+ value: String(insertAfter),
2188
+ message: `insertAfterStep must be between 0 (before the first step) and ${commands.length} (after the last). Omit it to append.`
2189
+ });
2190
+ }
2191
+ const step = {
2192
+ tool: 'conditional',
2193
+ params: { if: args.condition, then: args.thenSequence },
2194
+ ...(args.comment ? { comment: args.comment } : {})
2195
+ };
2196
+ sequence.commands = [
2197
+ ...commands.slice(0, insertAfter),
2198
+ step,
2199
+ ...commands.slice(insertAfter)
2200
+ ];
2201
+ // Write back to the file this came from; a memory-only sequence waits for
2202
+ // `export`, which is where it gets its filename.
2203
+ let persisted;
2204
+ const existingFile = onDisk.find(s => s.name === sequence.name);
2205
+ if (existingFile) {
2206
+ const saved = await recorder.saveSequenceToDisk(sequence.id, existingFile.location === 'global', true);
2207
+ if (saved?.success)
2208
+ persisted = saved.filepath;
2209
+ }
2210
+ return {
2211
+ content: [{
2212
+ type: 'text',
2213
+ text: formatConditionalAdded({
2214
+ sequenceName: sequence.name,
2215
+ condition: args.condition,
2216
+ thenSequence: args.thenSequence,
2217
+ position: insertAfter,
2218
+ totalSteps: sequence.commands.length,
2219
+ persistedTo: persisted
2220
+ })
2221
+ }]
2222
+ };
2223
+ }
2224
+ /**
2225
+ * Tidy a tag list into the form selection can rely on: trimmed, lowercased,
2226
+ * de-duplicated, order preserved.
2227
+ *
2228
+ * Case and stray whitespace are normalised rather than rejected because a tag
2229
+ * is matched, not displayed - `runAll({ tags: ['UI'] })` skipping a sequence
2230
+ * tagged `ui` would be a silent miss, which for a suite means quietly running
2231
+ * less than you asked for.
2232
+ */
2233
+ function normalizeTags(tags) {
2234
+ const out = [];
2235
+ for (const raw of tags) {
2236
+ const tag = raw.trim().toLowerCase();
2237
+ if (!tag) {
2238
+ return { error: 'An empty tag cannot select anything - drop it, or pass [] to clear the list.' };
2239
+ }
2240
+ if (/\s/.test(tag)) {
2241
+ return { error: `"${raw.trim()}" contains a space. Tags are single words so they stay unambiguous in a filter - use a hyphen ("${tag.replace(/\s+/g, '-')}").` };
2242
+ }
2243
+ if (!out.includes(tag))
2244
+ out.push(tag);
2245
+ }
2246
+ return { tags: out };
2247
+ }
2248
+ /**
2249
+ * Set what a sequence DECLARES: the browsers it needs, the sockets its
2250
+ * assertions ride on, and what kind of sequence it is.
2251
+ *
2252
+ * Declarations cannot be recorded - they are statements about a run, not steps
2253
+ * in it - so before this the only way to add them was to open the JSON and
2254
+ * type them in, against advice that otherwise says to keep sequences inside
2255
+ * the tools. That also put them squarely in the path of the bug where an
2256
+ * edited file was shadowed by the copy in memory.
2257
+ *
2258
+ * Each list REPLACES its field, and `[]` clears it: a declaration set is a
2259
+ * whole statement about the run, and merging would make "remove the second
2260
+ * browser" unexpressible.
2261
+ */
2262
+ async function handleDeclare(args, recorder) {
2263
+ if (args.requiredConnections === undefined && args.requiredSockets === undefined && args.tags === undefined) {
2264
+ return createErrorResponse('MISSING_PARAMETER', {
2265
+ action: 'declare',
2266
+ missing: 'requiredConnections, requiredSockets or tags',
2267
+ message: 'The "declare" action needs at least one of "requiredConnections" (browsers the sequence needs), ' +
2268
+ '"requiredSockets" (URL substrings of the WebSockets its assertions ride on), or "tags" (what kind of ' +
2269
+ 'sequence this is, which runAll selects on). Pass [] to clear one.',
2270
+ });
2271
+ }
2272
+ const loadResult = await loadSequence({ name: args.name, sequenceId: args.sequenceId }, recorder);
2273
+ if (!loadResult.success) {
2274
+ return handleLoadSequenceError(loadResult, 'declare');
2275
+ }
2276
+ const sequence = loadResult.sequence;
2277
+ if (args.requiredConnections !== undefined) {
2278
+ const seen = new Map();
2279
+ for (const decl of args.requiredConnections) {
2280
+ const reference = sanitizeReference(decl.reference);
2281
+ if (!reference) {
2282
+ return createErrorResponse('INVALID_PARAMETER', {
2283
+ parameter: 'requiredConnections',
2284
+ value: decl.reference,
2285
+ message: `"${decl.reference}" is not a usable connection reference.`,
2286
+ });
2287
+ }
2288
+ if (seen.has(reference)) {
2289
+ return createErrorResponse('INVALID_PARAMETER', {
2290
+ parameter: 'requiredConnections',
2291
+ value: reference,
2292
+ message: `"${reference}" is declared twice. One entry per browser - a second entry cannot mean anything the first does not.`,
2293
+ });
2294
+ }
2295
+ seen.set(reference, decl.profile ?? '');
2296
+ if (decl.profile) {
2297
+ try {
2298
+ normalizeProfileName(decl.profile);
2299
+ }
2300
+ catch (err) {
2301
+ return createErrorResponse('INVALID_PARAMETER', {
2302
+ parameter: 'requiredConnections',
2303
+ value: decl.profile,
2304
+ message: err?.message || String(err),
2305
+ });
2306
+ }
2307
+ }
2308
+ }
2309
+ // Same rule the run enforces, applied at authoring time so it fails while
2310
+ // you are writing the declaration rather than on the next run.
2311
+ const conflict = declaredProfileConflict(args.requiredConnections, undefined);
2312
+ if (conflict) {
2313
+ return createErrorResponse('INVALID_PARAMETER', {
2314
+ parameter: 'requiredConnections',
2315
+ value: sequence.name,
2316
+ message: conflict,
2317
+ });
2318
+ }
2319
+ sequence.requiredConnections = args.requiredConnections.length > 0
2320
+ ? args.requiredConnections.map(d => ({ ...d, reference: sanitizeReference(d.reference) }))
2321
+ : undefined;
2322
+ }
2323
+ if (args.requiredSockets !== undefined) {
2324
+ const blank = args.requiredSockets.find(s => s.trim().length === 0);
2325
+ if (blank !== undefined) {
2326
+ return createErrorResponse('INVALID_PARAMETER', {
2327
+ parameter: 'requiredSockets',
2328
+ value: '(empty string)',
2329
+ message: 'An empty socket pattern matches every socket, including the dev server\'s own - name the path your app uses, e.g. "/api/sync/socket".',
2330
+ });
2331
+ }
2332
+ sequence.requiredSockets = args.requiredSockets.length > 0 ? args.requiredSockets : undefined;
2333
+ }
2334
+ if (args.tags !== undefined) {
2335
+ const cleaned = normalizeTags(args.tags);
2336
+ if ('error' in cleaned) {
2337
+ return createErrorResponse('INVALID_PARAMETER', {
2338
+ parameter: 'tags',
2339
+ value: args.tags.join(', '),
2340
+ message: cleaned.error,
2341
+ });
2342
+ }
2343
+ sequence.tags = cleaned.tags.length > 0 ? cleaned.tags : undefined;
2344
+ }
2345
+ // Write back to the file this came from; a memory-only sequence waits for
2346
+ // `export`, which is where it gets its filename.
2347
+ let persisted;
2348
+ const existingFile = (await recorder.listSavedSequencesOnDisk())
2349
+ .find(s => s.name === sequence.name);
2350
+ if (existingFile) {
2351
+ const saved = await recorder.saveSequenceToDisk(sequence.id, existingFile.location === 'global', true);
2352
+ if (saved?.success)
2353
+ persisted = saved.filepath;
2354
+ }
2355
+ return {
2356
+ content: [{
2357
+ type: 'text',
2358
+ text: formatDeclarations(sequence, persisted),
2359
+ }],
2360
+ };
2361
+ }
2362
+ // =============================================================================
2363
+ // Interaction Recording Handlers
2364
+ // =============================================================================
2365
+ async function handleRecordInteraction(args, executeToolCall, getPageForConnection, recorder, abortSignal) {
2366
+ if (!args.connectionReason) {
2367
+ return createErrorResponse('MISSING_PARAMETER', {
2368
+ action: 'recordInteraction',
2369
+ missing: 'connectionReason',
2370
+ message: 'The "recordInteraction" action requires a "connectionReason" to identify the browser tab'
2371
+ });
2372
+ }
2373
+ /**
2374
+ * The failure text, or null when the navigation worked. A failed goto THROWS
2375
+ * in production (executeToolCall rethrows isError), so the NAVIGATION_FAILED
2376
+ * responses below never fired and the recorder went on to record against
2377
+ * whatever page happened to be open - the same try/catch shape
2378
+ * navigateToStartUrl already uses.
2379
+ */
2380
+ const navigateTo = async (url) => {
2381
+ try {
2382
+ const navResult = await executeToolCall('navigate', {
2383
+ action: 'goto',
2384
+ connectionReason: args.connectionReason,
2385
+ url
2386
+ });
2387
+ return navResult?.isError ? (navResult?.content?.[0]?.text || 'Unknown error') : null;
2388
+ }
2389
+ catch (navError) {
2390
+ return navError?.response?.content?.[0]?.text || navError?.message || 'Unknown error';
2391
+ }
2392
+ };
2393
+ if (!getPageForConnection) {
2394
+ return createErrorResponse('NOT_SUPPORTED', {
2395
+ message: 'Interaction recording is not supported in this context'
2396
+ });
2397
+ }
2398
+ // If issueId is provided, look up the issue and use its details
2399
+ let issueId = args.issueId;
2400
+ let issueType = args.issueType;
2401
+ let issueTitle = args.issueTitle;
2402
+ let startUrl = args.startUrl;
2403
+ if (issueId) {
2404
+ const issue = await getIssue(issueId);
2405
+ if (!issue) {
2406
+ return createErrorResponse('ISSUES_NOT_FOUND', {
2407
+ id: issueId,
2408
+ message: `Issue #${issueId} not found`
2409
+ });
2410
+ }
2411
+ // Use issue details (override any provided args)
2412
+ issueType = issue.type;
2413
+ issueTitle = issue.title;
2414
+ startUrl = startUrl || issue.startUrl; // Use provided startUrl or fall back to issue's startUrl
2415
+ }
2416
+ let page = await getPageForConnection(args.connectionReason);
2417
+ // Auto-launch Chrome if no connection found (requires startUrl)
2418
+ if (!page) {
2419
+ if (!startUrl) {
2420
+ return createErrorResponse('MISSING_PARAMETER', {
2421
+ action: 'recordInteraction',
2422
+ missing: 'startUrl',
2423
+ message: 'Chrome is not running. Provide a "startUrl" or "issueId" (with startUrl) to auto-launch Chrome and navigate before recording.'
2424
+ });
2425
+ }
2426
+ const launchResult = await autoLaunchChrome(executeToolCall, args.connectionReason, 'recordInteraction');
2427
+ if (!launchResult.success) {
2428
+ return createErrorResponse(launchResult.errorType, {
2429
+ reference: args.connectionReason,
2430
+ error: launchResult.error
2431
+ });
2432
+ }
2433
+ // Navigate to the startUrl
2434
+ const navFailure = await navigateTo(startUrl);
2435
+ if (navFailure) {
2436
+ return createErrorResponse('NAVIGATION_FAILED', {
2437
+ url: startUrl,
2438
+ message: `Failed to navigate to startUrl: ${navFailure}`
2439
+ });
2440
+ }
2441
+ // Try getting the page again after launch
2442
+ page = await getPageForConnection(args.connectionReason);
2443
+ if (!page) {
2444
+ return createErrorResponse('CONNECTION_NOT_FOUND', {
2445
+ connectionReason: args.connectionReason,
2446
+ message: 'Failed to connect to Chrome after auto-launch'
2447
+ });
2448
+ }
2449
+ }
2450
+ else if (startUrl) {
2451
+ // Page already exists but startUrl provided - navigate to it
2452
+ const navFailure = await navigateTo(startUrl);
2453
+ if (navFailure) {
2454
+ return createErrorResponse('NAVIGATION_FAILED', {
2455
+ url: startUrl,
2456
+ message: `Failed to navigate to startUrl: ${navFailure}`
2457
+ });
2458
+ }
2459
+ }
2460
+ const showOverlay = args.showOverlay !== false;
2461
+ const sequenceName = args.name || (issueId ? `${issueType}-${issueId}-repro` : args.connectionReason);
2462
+ // startRecording now blocks until recording completes
2463
+ // If issueId is provided, startRecording will show a fullscreen overlay with issue details
2464
+ const result = await startRecording(page, args.connectionReason, {
2465
+ showOverlay,
2466
+ abortSignal,
2467
+ issueId
2468
+ });
2469
+ // Close the tab if requested by the recording result
2470
+ if (result.closeTab) {
2471
+ try {
2472
+ await executeToolCall('tab', {
2473
+ action: 'close',
2474
+ reference: args.connectionReason,
2475
+ });
2476
+ }
2477
+ catch {
2478
+ // Non-fatal - tab may already be closed
2479
+ }
2480
+ }
2481
+ if (!result.success) {
2482
+ if (result.cancelled) {
2483
+ return {
2484
+ content: [{
2485
+ type: 'text',
2486
+ text: '**Recording cancelled** - no sequence created.'
2487
+ }],
2488
+ // Structurally too: callers were deciding this by searching the
2489
+ // sentence for "cancelled", which any recorded page title could also
2490
+ // have contained.
2491
+ _meta: {
2492
+ tool: 'replay',
2493
+ action: 'recordInteraction',
2494
+ timestamp: Date.now(),
2495
+ replay: { totalSteps: 0, cancelled: true },
2496
+ },
2497
+ };
2498
+ }
2499
+ return createErrorResponse('RECORDING_FAILED', { message: result.error });
2500
+ }
2501
+ // Recording completed - create the sequence
2502
+ const recording = result.recording;
2503
+ const summary = recording.summary;
2504
+ const replayConfig = configManager.getReplayConfig();
2505
+ // Recording options come from args; the defaults are the values that used to
2506
+ // be hardcoded here, so omitting them keeps the previous behaviour.
2507
+ // preferSelectors wins over preferCoordinates when both are set.
2508
+ const commands = eventsToCommands(recording.events, {
2509
+ simplify: args.simplifyEvents ?? true,
2510
+ includeDelays: true,
2511
+ includeHovers: args.includeHovers ?? false,
2512
+ preferCoordinates: args.preferCoordinates ?? false,
2513
+ preferSelectors: args.preferSelectors ?? false,
2514
+ maxDelayMs: replayConfig.maxDelayMs,
2515
+ });
2516
+ // Generate condensed timeline
2517
+ const timeline = generateCondensedTimeline(recording.events);
2518
+ // Check for BUG and FEATURE comments
2519
+ const bugComments = recording.events
2520
+ .filter((e) => isCommentEvent(e) && e.category === 'bug');
2521
+ const featureComments = recording.events
2522
+ .filter((e) => isCommentEvent(e) && e.category === 'feature');
2523
+ const hasIssues = bugComments.length > 0 || featureComments.length > 0;
2524
+ // Build sequence data for saving
2525
+ const sequenceData = {
2526
+ id: `seq-${Date.now()}`,
2527
+ name: sequenceName,
2528
+ commands,
2529
+ createdAt: Date.now(),
2530
+ startUrl: recording.startUrl,
2531
+ description: `Recorded from ${args.connectionReason}`,
2532
+ };
2533
+ // Only create in-memory sequence if no issues (issues go to issues folder only)
2534
+ let sequence = null;
2535
+ if (!hasIssues && recorder) {
2536
+ // Delete existing sequence if overwriting
2537
+ if (args.overwrite && recorder.sequenceNameExists(sequenceName)) {
2538
+ const existingSeq = recorder.listSequences().find(s => s.name === sequenceName);
2539
+ if (existingSeq) {
2540
+ recorder.deleteSequence(existingSeq.id);
2541
+ }
2542
+ }
2543
+ // Check for name conflict
2544
+ if (recorder.sequenceNameExists(sequenceName) && !args.overwrite) {
2545
+ return createSuccessResponse('RECORDING_NAME_CONFLICT', {
2546
+ sequenceName,
2547
+ connectionReason: args.connectionReason
2548
+ });
2549
+ }
2550
+ sequence = await recorder.createSequenceFromCommands(sequenceName, commands, {
2551
+ startUrl: recording.startUrl,
2552
+ description: `Recorded from ${args.connectionReason}`,
2553
+ });
2554
+ }
2555
+ const createdIssues = [];
2556
+ // Initialize issue tracker
2557
+ await initializeTracker();
2558
+ // Create issues and save sequences for each bug/feature comment
2559
+ // Each issue gets its own sequence with a unique ID
2560
+ for (const comment of [...bugComments, ...featureComments]) {
2561
+ const issueType = comment.category;
2562
+ // Create the issue first (with temp filename, will be updated by saveIssueSequence)
2563
+ const issue = await addIssue({
2564
+ type: issueType,
2565
+ title: comment.text,
2566
+ sequenceFile: '',
2567
+ recordingName: sequenceName,
2568
+ initialStatus: 'pending',
2569
+ startUrl: recording.startUrl || '',
2570
+ });
2571
+ // Create a unique sequence for this issue (each issue gets its own copy)
2572
+ const issueSequenceData = {
2573
+ ...sequenceData,
2574
+ id: `seq-${Date.now()}-${issue.id}`,
2575
+ name: `${issueType}-${issue.id}-repro`,
2576
+ };
2577
+ // Save sequence and link to issue
2578
+ await saveIssueSequence(issue.id, issueType, comment.text, issueSequenceData);
2579
+ createdIssues.push({
2580
+ id: issue.id,
2581
+ type: issueType,
2582
+ title: comment.text,
2583
+ });
2584
+ }
2585
+ // If issueId provided, save sequence to issues folder and link to existing issue
2586
+ if (issueId && issueType && issueTitle) {
2587
+ await saveIssueSequence(issueId, issueType, issueTitle, sequenceData, `CDP Tools verification sequence for ${issueType} #${issueId}: ${issueTitle}`);
2588
+ }
2589
+ const response = createSuccessResponse('RECORDING_STOPPED', {
2590
+ name: sequence?.name || sequenceData.name,
2591
+ sequenceId: sequence?.id || sequenceData.id,
2592
+ duration: (recording.duration / 1000).toFixed(1),
2593
+ startUrl: recording.startUrl,
2594
+ commandCount: commands.length,
2595
+ clicks: summary.clicks,
2596
+ drags: summary.drags,
2597
+ scrolls: summary.scrolls,
2598
+ keyPresses: summary.keyPresses,
2599
+ navigations: summary.navigations > 0 ? summary.navigations : null,
2600
+ comments: summary.comments > 0 ? summary.comments : null,
2601
+ // Selector coverage. Only surfaced when some click fell back to raw
2602
+ // coordinates, because that is the only case the user can act on - a
2603
+ // fully selector-based recording needs no warning, and a warning that
2604
+ // fires every time stops being read.
2605
+ coordinateClicks: summary.coordinatesOnly > 0 ? summary.coordinatesOnly : null,
2606
+ coverageNote: summary.coordinatesOnly > 0
2607
+ ? `${summary.selectorsAvailable}/${summary.clicks} clicks captured a selector; ${summary.coordinatesOnly} fell back to coordinates${summary.canvasInteractions > 0 ? ` (${summary.canvasInteractions} on canvas, where that is expected)` : ''}. Coordinate clicks break on re-render or layout change.`
2608
+ : null,
2609
+ timeline: timeline || null,
2610
+ bugCount: bugComments.length > 0 ? bugComments.length : null,
2611
+ featureCount: featureComments.length > 0 ? featureComments.length : null,
2612
+ hasIssues: createdIssues.length > 0,
2613
+ issuesCreatedList: createdIssues.length > 0
2614
+ ? createdIssues.map(i => `#${i.id} (${i.type})`).join(', ')
2615
+ : null,
2616
+ });
2617
+ // outputFormat dumps the underlying data alongside the summary. The raw
2618
+ // events only exist here - a saved sequence keeps commands, not events.
2619
+ if (args.outputFormat === 'events') {
2620
+ response.content[0].text += `\n\n**Raw recorded events (${recording.events.length})**\n\n\`\`\`json\n${JSON.stringify(recording.events, null, 2)}\n\`\`\``;
2621
+ }
2622
+ else if (args.outputFormat === 'commands') {
2623
+ response.content[0].text += `\n\n**Commands (JSON)**\n\n\`\`\`json\n${JSON.stringify(commands, null, 2)}\n\`\`\``;
2624
+ }
2625
+ else if (args.outputFormat === 'review') {
2626
+ response.content[0].text += `\n\n**Event Review (${recording.events.length} raw events)**\n\n${formatEventsForReview(recording.events)}`;
2627
+ }
2628
+ else if (args.outputFormat === 'playwright') {
2629
+ response.content[0].text += `\n\n**Playwright Code**\n\n\`\`\`typescript\n${generatePlaywrightCode(commands, recording.startUrl)}\n\`\`\``;
2630
+ }
2631
+ else if (args.outputFormat === 'puppeteer') {
2632
+ response.content[0].text += `\n\n**Puppeteer Code**\n\n\`\`\`javascript\n${generatePuppeteerCode(commands, recording.startUrl)}\n\`\`\``;
2633
+ }
2634
+ return response;
2635
+ }
2636
+ /**
2637
+ * Escape a string for use in JavaScript code generation
2638
+ * Handles newlines, quotes, backslashes, and other special characters
2639
+ */
2640
+ function escapeJsString(str) {
2641
+ return str
2642
+ .replace(/\\/g, '\\\\') // Backslashes first
2643
+ .replace(/'/g, "\\'") // Single quotes
2644
+ .replace(/\n/g, '\\n') // Newlines
2645
+ .replace(/\r/g, '\\r') // Carriage returns
2646
+ .replace(/\t/g, '\\t'); // Tabs
2647
+ }
2648
+ /**
2649
+ * Generate Puppeteer test code from sequence commands
2650
+ */
2651
+ /**
2652
+ * One page variable per recorded connection, for the code generators.
2653
+ *
2654
+ * A sequence that drove two browsers has to generate two pages: emitting every
2655
+ * step against a single `page` is the bug-018 collapse relocated into the
2656
+ * exported test, and it is silent - the generated file looks perfectly
2657
+ * reasonable and passes while never involving the second browser. The first
2658
+ * recorded reference keeps the name `page` so single-connection output is
2659
+ * byte-identical to before.
2660
+ */
2661
+ function buildPageVars(commands) {
2662
+ const { references, mixed } = analyzeRecordedStepConnections(commands);
2663
+ const vars = new Map();
2664
+ references.forEach((ref, i) => {
2665
+ vars.set(ref, i === 0
2666
+ ? 'page'
2667
+ : 'page' + ref.split(/[^a-zA-Z0-9]+/).filter(Boolean).map(w => w[0].toUpperCase() + w.slice(1)).join(''));
2668
+ });
2669
+ return {
2670
+ references,
2671
+ mixed,
2672
+ multi: references.length > 1,
2673
+ /** The page a step runs against; bare steps fall back to the first page. */
2674
+ varFor: (cmd) => (typeof cmd.params.connectionReason === 'string' && vars.get(sanitizeReference(cmd.params.connectionReason))) || 'page',
2675
+ /** `page` is declared by the caller's preamble; these are the extras. */
2676
+ extras: references.slice(1).map(ref => ({ ref, name: vars.get(ref) })),
2677
+ };
2678
+ }
2679
+ /**
2680
+ * Retarget the lines a single command emitted onto its own page variable.
2681
+ * Done as a post-pass over the emitted slice so the (long, per-tool) generator
2682
+ * bodies stay untouched and keep emitting the plain `page`.
2683
+ */
2684
+ function rewritePage(lines, from, pageVar) {
2685
+ if (pageVar === 'page')
2686
+ return;
2687
+ for (let i = from; i < lines.length; i++) {
2688
+ lines[i] = lines[i].replace(/\bpage\b/g, pageVar);
2689
+ }
2690
+ }
2691
+ /** Header explaining a multi-browser export, so the collapse can't happen quietly. */
2692
+ function generatedCodeHeader(pages) {
2693
+ if (!pages.multi)
2694
+ return [];
2695
+ const out = [
2696
+ `// This sequence drove ${pages.references.length} browsers (${pages.references.join(', ')}).`,
2697
+ `// Each gets its own page below - do NOT merge them, the recording exists to`,
2698
+ `// test what crosses between them.`,
2699
+ ];
2700
+ if (pages.mixed) {
2701
+ out.push(`// WARNING: some steps named no connection and are emitted against '${'page'}';`);
2702
+ out.push(`// check them by hand - which browser they belonged to was not recorded.`);
2703
+ }
2704
+ return out;
2705
+ }
2706
+ function generatePuppeteerCode(commands, startUrl) {
2707
+ const pages = buildPageVars(commands);
2708
+ const lines = [
2709
+ '// Generated from cdp-tools interaction recording',
2710
+ ...generatedCodeHeader(pages),
2711
+ 'const puppeteer = require(\'puppeteer\');',
2712
+ '',
2713
+ 'async function runTest() {',
2714
+ ' const browser = await puppeteer.launch({ headless: false });',
2715
+ ' const page = await browser.newPage();',
2716
+ ...pages.extras.map(e => ` const ${e.name} = await browser.newPage(); // ${e.ref}`),
2717
+ '',
2718
+ ];
2719
+ if (startUrl) {
2720
+ lines.push(` await page.goto('${startUrl}');`);
2721
+ lines.push('');
2722
+ }
2723
+ let generatedSteps = 0;
2724
+ for (const cmd of commands) {
2725
+ // Everything this command emits is rewritten onto its own page below.
2726
+ const emittedFrom = lines.length;
2727
+ if (cmd.tool === 'navigate') {
2728
+ const { action, ...params } = cmd.params;
2729
+ if (action === 'goto' && params.url) {
2730
+ lines.push(` await page.goto('${params.url}');`);
2731
+ lines.push('');
2732
+ }
2733
+ else if (action === 'reload') {
2734
+ lines.push(` await page.reload();`);
2735
+ lines.push('');
2736
+ }
2737
+ }
2738
+ else if (cmd.tool === 'input') {
2739
+ const { action, ...params } = cmd.params;
2740
+ switch (action) {
2741
+ case 'drag':
2742
+ lines.push(` // Drag from (${params.from.x}, ${params.from.y}) to (${params.to.x}, ${params.to.y})`);
2743
+ lines.push(` await page.mouse.move(${params.from.x}, ${params.from.y});`);
2744
+ lines.push(` await page.mouse.down();`);
2745
+ lines.push(` await page.mouse.move(${params.to.x}, ${params.to.y});`);
2746
+ lines.push(` await page.mouse.up();`);
2747
+ lines.push('');
2748
+ break;
2749
+ case 'scroll':
2750
+ lines.push(` // Scroll at (${params.x}, ${params.y})`);
2751
+ if (params.x !== undefined && params.y !== undefined) {
2752
+ lines.push(` await page.mouse.move(${params.x}, ${params.y});`);
2753
+ }
2754
+ lines.push(` await page.mouse.wheel({ deltaX: ${params.deltaX || 0}, deltaY: ${params.deltaY || 0} });`);
2755
+ lines.push('');
2756
+ break;
2757
+ case 'mousemove':
2758
+ lines.push(` await page.mouse.move(${params.x}, ${params.y});`);
2759
+ break;
2760
+ case 'click':
2761
+ if (typeof params.x === 'number' && typeof params.y === 'number') {
2762
+ lines.push(` await page.mouse.click(${params.x}, ${params.y});`);
2763
+ }
2764
+ else if (params.selector) {
2765
+ lines.push(` await page.click('${params.selector}');`);
2766
+ }
2767
+ lines.push('');
2768
+ break;
2769
+ case 'type':
2770
+ lines.push(` await page.keyboard.type('${escapeJsString(params.text)}');`);
2771
+ lines.push('');
2772
+ break;
2773
+ case 'press':
2774
+ lines.push(` await page.keyboard.press('${escapeJsString(params.key)}');`);
2775
+ lines.push('');
2776
+ break;
2777
+ }
2778
+ }
2779
+ // Same rule as the Playwright generator: a dropped step leaves a hole.
2780
+ if (lines.length === emittedFrom) {
2781
+ lines.push(` // [not generated] ${describeUngeneratedStep(cmd)}`);
2782
+ }
2783
+ else {
2784
+ generatedSteps++;
2785
+ }
2786
+ rewritePage(lines, emittedFrom, pages.varFor(cmd));
2787
+ }
2788
+ lines.push(...ungeneratedTestGuard(generatedSteps, commands.length, Boolean(startUrl)));
2789
+ lines.push(' await browser.close();');
2790
+ lines.push('}');
2791
+ lines.push('');
2792
+ lines.push('runTest().catch(console.error);');
2793
+ return lines.join('\n');
2794
+ }
2795
+ function generatePlaywrightCode(commands, startUrl) {
2796
+ const pages = buildPageVars(commands);
2797
+ const lines = [
2798
+ '// Generated from cdp-tools interaction recording',
2799
+ ...generatedCodeHeader(pages),
2800
+ "import { test, expect } from '@playwright/test';",
2801
+ '',
2802
+ // A second browser needs its own context, so the multi-connection form takes
2803
+ // the `browser` fixture instead of `page` and opens the pages itself.
2804
+ pages.multi
2805
+ ? "test('recorded interaction', async ({ browser }) => {"
2806
+ : "test('recorded interaction', async ({ page }) => {",
2807
+ ...(pages.multi ? [' const page = await (await browser.newContext()).newPage();'] : []),
2808
+ ...pages.extras.map(e => ` const ${e.name} = await (await browser.newContext()).newPage(); // ${e.ref}`),
2809
+ ];
2810
+ if (startUrl) {
2811
+ lines.push(` await page.goto('${startUrl}');`);
2812
+ lines.push('');
2813
+ }
2814
+ let generatedSteps = 0;
2815
+ for (const cmd of commands) {
2816
+ const emittedFrom = lines.length;
2817
+ // Add comment if present
2818
+ if (cmd.comment) {
2819
+ lines.push(` // ${cmd.comment}`);
2820
+ }
2821
+ // Add delay if present
2822
+ if (cmd.delay && cmd.delay > 100) {
2823
+ lines.push(` await page.waitForTimeout(${cmd.delay});`);
2824
+ }
2825
+ const bodyFrom = lines.length;
2826
+ if (cmd.tool === 'navigate') {
2827
+ const { action, ...params } = cmd.params;
2828
+ if (action === 'goto' && params.url) {
2829
+ lines.push(` await page.goto('${params.url}');`);
2830
+ lines.push('');
2831
+ }
2832
+ else if (action === 'reload') {
2833
+ lines.push(` await page.reload();`);
2834
+ lines.push('');
2835
+ }
2836
+ else if (action === 'back') {
2837
+ lines.push(` await page.goBack();`);
2838
+ lines.push('');
2839
+ }
2840
+ else if (action === 'forward') {
2841
+ lines.push(` await page.goForward();`);
2842
+ lines.push('');
2843
+ }
2844
+ }
2845
+ else if (cmd.tool === 'input') {
2846
+ const { action, ...params } = cmd.params;
2847
+ switch (action) {
2848
+ case 'drag':
2849
+ lines.push(` // Drag from (${params.from.x}, ${params.from.y}) to (${params.to.x}, ${params.to.y})`);
2850
+ lines.push(` await page.mouse.move(${params.from.x}, ${params.from.y});`);
2851
+ lines.push(` await page.mouse.down();`);
2852
+ lines.push(` await page.mouse.move(${params.to.x}, ${params.to.y});`);
2853
+ lines.push(` await page.mouse.up();`);
2854
+ lines.push('');
2855
+ break;
2856
+ case 'scroll':
2857
+ lines.push(` // Scroll at (${params.x || 0}, ${params.y || 0})`);
2858
+ if (params.x !== undefined && params.y !== undefined) {
2859
+ lines.push(` await page.mouse.move(${params.x}, ${params.y});`);
2860
+ }
2861
+ lines.push(` await page.mouse.wheel(${params.deltaX || 0}, ${params.deltaY || 0});`);
2862
+ lines.push('');
2863
+ break;
2864
+ case 'mousemove':
2865
+ lines.push(` await page.mouse.move(${params.x}, ${params.y});`);
2866
+ break;
2867
+ case 'click':
2868
+ if (typeof params.x === 'number' && typeof params.y === 'number') {
2869
+ lines.push(` await page.mouse.click(${params.x}, ${params.y});`);
2870
+ }
2871
+ else if (params.selector) {
2872
+ lines.push(` await page.click('${params.selector}');`);
2873
+ }
2874
+ lines.push('');
2875
+ break;
2876
+ case 'type':
2877
+ // Playwright uses type() for key-by-key typing, fill() for setting value directly
2878
+ lines.push(` await page.keyboard.type('${escapeJsString(params.text)}');`);
2879
+ lines.push('');
2880
+ break;
2881
+ case 'press':
2882
+ lines.push(` await page.keyboard.press('${escapeJsString(params.key)}');`);
2883
+ lines.push('');
2884
+ break;
2885
+ case 'hover':
2886
+ if (params.selector) {
2887
+ lines.push(` await page.hover('${params.selector}');`);
2888
+ }
2889
+ lines.push('');
2890
+ break;
2891
+ }
2892
+ }
2893
+ // A step with no Playwright equivalent (conditional, launchChrome, inspect,
2894
+ // storage, wait, breakpoint...) must leave a visible hole. Dropping it
2895
+ // silently is how a sequence turns into a test that passes without doing
2896
+ // anything it was recorded to do.
2897
+ if (lines.length === bodyFrom) {
2898
+ lines.push(` // [not generated] ${describeUngeneratedStep(cmd)}`);
2899
+ }
2900
+ else {
2901
+ generatedSteps++;
2902
+ }
2903
+ rewritePage(lines, emittedFrom, pages.varFor(cmd));
2904
+ }
2905
+ lines.push(...ungeneratedTestGuard(generatedSteps, commands.length, Boolean(startUrl)));
2906
+ lines.push('});');
2907
+ return lines.join('\n');
2908
+ }
2909
+ /** Names a step the generators have no equivalent for, for the emitted comment. */
2910
+ function describeUngeneratedStep(cmd) {
2911
+ const action = typeof cmd.params?.action === 'string' ? `({ action: '${cmd.params.action}' })` : '';
2912
+ const extra = cmd.tool === 'conditional' && cmd.params?.then
2913
+ ? ` — runs the sequence "${cmd.params.then}" when ${cmd.params.if}`
2914
+ : '';
2915
+ return `${cmd.tool}${action}${extra}`;
2916
+ }
2917
+ /**
2918
+ * Body for a generated test that ended up with nothing to run. Returning an
2919
+ * empty test would export a permanently GREEN file - the failure mode this
2920
+ * whole tool exists to avoid - so the generated test fails and says why.
2921
+ */
2922
+ function ungeneratedTestGuard(generatedSteps, totalSteps, hasStartUrl) {
2923
+ if (generatedSteps > 0 || hasStartUrl)
2924
+ return [];
2925
+ return [
2926
+ '',
2927
+ ` throw new Error('cdp-tools: none of the ${totalSteps} recorded step(s) have a generated equivalent`
2928
+ + ` (see the "[not generated]" comments above) - this exported test would otherwise pass without doing anything.`
2929
+ + ` Run it with replay({ action: "run" }) instead.');`,
2930
+ ];
2931
+ }
2932
+ // =============================================================================
2933
+ // Tool Export
2934
+ // =============================================================================
2935
+ export function createReplayTools(commandRecorder, executeToolCall, getPageForConnection, getConnectionPort,
2936
+ /**
2937
+ * Lazy provider for the set of registered tool names, used to reject sequence
2938
+ * steps naming a nonexistent tool at create/load time (bug-010). Lazy because
2939
+ * the tool map is built after this factory runs. When omitted, tool names are
2940
+ * not validated (previous behaviour).
2941
+ */
2942
+ getKnownToolNames) {
2943
+ return {
2944
+ replay: createTool('Record and replay command sequences for testing and automation. Actions: repeat (immediately re-execute commands by history index - use this to repeat recent actions), history (view command history), recordInteraction (record real mouse/keyboard/navigation via a browser overlay - BLOCKS until the person finishes, so do not call it unattended; tune the capture with simplifyEvents/includeHovers/preferCoordinates/preferSelectors, and add outputFormat: events|commands|review|playwright|puppeteer to dump the recording - review is a human-readable walkthrough of the captured events), create (create sequence from history indices), list (list in-memory sequences), get (get sequence details; outputFormat: commands|playwright|puppeteer returns the raw command JSON or generated test code), delete (delete from memory), export (write a sequence to disk as sequence/playwright/puppeteer), load (load sequence from disk), listSaved (list saved files), deleteSaved (delete saved file), run (start executing a sequence in the background - returns a runId immediately; poll progress/results with status, stop it with cancel; wait: true blocks until completion and returns the full result), runAll (run every sequence in a folder of the sequences dir, or only those carrying a given tag - loads the whole tree first so cross-folder name references resolve, runs only the chosen folder, skips folders whose name starts with an underscore unless named explicitly, and reports a pass/fail line per sequence; continueOnFailure defaults true), runFromLog (execute commands from log lines), step (execute next N commands in a paused sequence), finish (complete remaining commands), insert (insert recorded commands into a sequence), addConditional (add a guarded branch step: condition + thenSequence, optionally insertAfterStep), declare (set what the sequence needs and what it is: requiredConnections - the browsers, optionally each on a persistent profile - requiredSockets - URL substrings of the WebSockets its assertions ride on - and tags, which runAll selects on; each list replaces the field, [] clears it, and the sequence is written back to its file), status (with runId: one run\'s progress or final result; without: paused session + recent runs), cancel (with runId: stop that run; without: drop the paused session, or the only executing run)', replaySchema, async (args, abortSignal) => {
2945
+ switch (args.action) {
2946
+ case 'history':
2947
+ return handleHistory(args, commandRecorder);
2948
+ case 'create':
2949
+ return handleCreate(args, commandRecorder, getKnownToolNames);
2950
+ case 'list':
2951
+ return handleList(commandRecorder);
2952
+ case 'get':
2953
+ return handleGet(args, commandRecorder);
2954
+ case 'delete':
2955
+ return handleDelete(args, commandRecorder);
2956
+ case 'export':
2957
+ return handleExport(args, commandRecorder);
2958
+ case 'load':
2959
+ return handleLoad(args, commandRecorder, getKnownToolNames);
2960
+ case 'listSaved':
2961
+ return handleListSaved(args, commandRecorder);
2962
+ case 'deleteSaved':
2963
+ return handleDeleteSaved(args, commandRecorder);
2964
+ case 'run':
2965
+ return handleRun(args, commandRecorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort);
2966
+ case 'runAll':
2967
+ return handleRunAll(args, commandRecorder, executeToolCall, getPageForConnection, abortSignal, getConnectionPort);
2968
+ case 'status':
2969
+ return handleStatus(args, commandRecorder);
2970
+ case 'step':
2971
+ return handleStep(args, commandRecorder, executeToolCall);
2972
+ case 'finish':
2973
+ return handleFinish(commandRecorder, executeToolCall);
2974
+ case 'insert':
2975
+ return handleInsert(args, commandRecorder);
2976
+ case 'addConditional':
2977
+ return handleAddConditional(args, commandRecorder);
2978
+ case 'declare':
2979
+ return handleDeclare(args, commandRecorder);
2980
+ case 'cancel':
2981
+ return handleCancel(args, commandRecorder);
2982
+ case 'repeat':
2983
+ return handleRepeat(args, commandRecorder, executeToolCall);
2984
+ case 'runFromLog':
2985
+ return handleRunFromLog(args, executeToolCall);
2986
+ case 'recordInteraction':
2987
+ return handleRecordInteraction(args, executeToolCall, getPageForConnection, commandRecorder, abortSignal);
2988
+ default:
2989
+ return createErrorResponse('INVALID_ACTION', { action: args.action });
2990
+ }
2991
+ }),
2992
+ };
2993
+ }
2994
+ //# sourceMappingURL=replay-tools.js.map