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,2347 @@
1
+ /**
2
+ * Replay Executor - Core execution engine for command sequences
3
+ */
4
+ import { abortableDelayResult } from '../utils/abort.js';
5
+ import { debugLog } from '../debug-logger.js';
6
+ import { sanitizeReference, requireValidReference } from '../reference-validator.js';
7
+ import { checkUrlPort } from '../utils/port-check.js';
8
+ import { configManager } from '../config.js';
9
+ import { interpolateParams } from './interpolation.js';
10
+ import { getMessage, isElementNotFoundFailure } from '../messages.js';
11
+ // Re-export replay cursor functions
12
+ export { injectReplayCursor, showClickEffect, showKeyPress, removeReplayCursor } from '../replay-cursor.js';
13
+ let replayCursorCallbacks = {};
14
+ export function setReplayCursorCallbacks(callbacks) {
15
+ replayCursorCallbacks = callbacks;
16
+ }
17
+ // =============================================================================
18
+ // Constants
19
+ // =============================================================================
20
+ /**
21
+ * Which tools a sequence step's `saveAs` can capture from, and what a capture
22
+ * actually stores. Each entry pulls the value out of the tool's structured
23
+ * `_meta` - never out of its display text - and returns `undefined` when this
24
+ * particular call produced nothing capturable (wrong action, older response).
25
+ *
26
+ * `request` stores the whole response object, so later steps address into it
27
+ * ({{var:login.body.token}}). `inspect` stores the evaluated value itself, so
28
+ * a captured string is usable as {{var:pairingUrl}} directly.
29
+ *
30
+ * Adding `dom`/`content` later is a matter of adding an entry here plus the
31
+ * matching `_meta` on that tool.
32
+ */
33
+ const CAPTURE_SOURCES = {
34
+ request: (meta) => meta?.request
35
+ ? { found: true, value: meta.request }
36
+ : { found: false },
37
+ inspect: (meta) => meta?.inspect
38
+ ? { found: true, value: meta.inspect.value }
39
+ : { found: false },
40
+ };
41
+ /** Human-readable list of what supports saveAs, for error messages. */
42
+ const CAPTURE_CAPABLE_TOOLS = Object.keys(CAPTURE_SOURCES).join(', ');
43
+ /**
44
+ * Iterations a `forEach` will run before stopping, unless the step raises it
45
+ * with `maxItems`. A backstop against a source that unexpectedly returns
46
+ * thousands of rows, not a considered limit - the stop is always logged.
47
+ */
48
+ const DEFAULT_FOREACH_MAX_ITEMS = 100;
49
+ /**
50
+ * Teardown's default total budget. Separate from the run's `totalTimeout`
51
+ * because it must survive that budget being exhausted.
52
+ */
53
+ const DEFAULT_TEARDOWN_TIMEOUT = 60000;
54
+ /**
55
+ * Resolve what a step's `saveAs` should write to the variable store.
56
+ * A `saveAs` that cannot be honoured is an error, not a silent no-op: the
57
+ * later {{var:...}} step would otherwise fail somewhere far away with a
58
+ * confusing "no variable named" message.
59
+ */
60
+ export function captureVariable(tool, params, result) {
61
+ const source = CAPTURE_SOURCES[tool];
62
+ if (!source) {
63
+ return {
64
+ ok: false,
65
+ error: `saveAs is not supported on "${tool}" steps (supported: ${CAPTURE_CAPABLE_TOOLS})`,
66
+ };
67
+ }
68
+ const captured = source(result?._meta);
69
+ if (!captured.found) {
70
+ const action = params.action ? ` (action: ${params.action})` : '';
71
+ return {
72
+ ok: false,
73
+ error: `saveAs: "${tool}"${action} returned no capturable result` +
74
+ (tool === 'inspect' ? ' - only inspect({ action: "evaluateExpression" }) can be captured' : ''),
75
+ };
76
+ }
77
+ return { ok: true, value: captured.value };
78
+ }
79
+ /**
80
+ * Tools that can only run against a *browser*. Used to decide whether a sequence
81
+ * needs Chrome auto-launched (analyzeSequenceConnections / sequenceNeedsConnection
82
+ * and the auto-launch paths in replay-tools).
83
+ *
84
+ * Deliberately excludes tools that are equally valid against a Node target
85
+ * (`inspect`, `execution`, `breakpoint`, `getSourceCode`, `request`) - listing
86
+ * those here would make a Node-only sequence spuriously launch Chrome.
87
+ */
88
+ export const TOOLS_NEEDING_CONNECTION = [
89
+ 'navigate', 'content', 'input', 'console', 'network', 'dom', 'screenshot', 'storage'
90
+ ];
91
+ /**
92
+ * Tools whose params accept a `connectionReason` and should therefore have the
93
+ * run-level connection injected when the step doesn't name one itself. Superset of
94
+ * TOOLS_NEEDING_CONNECTION: it adds the target-agnostic (Chrome *or* Node) debugging
95
+ * tools, which need to be pinned to the run's target but must NOT drag a browser
96
+ * launch in with them.
97
+ *
98
+ * `request` is handled separately - only `destination: 'browser'` takes a connection.
99
+ */
100
+ export const TOOLS_ACCEPTING_CONNECTION = [
101
+ ...TOOLS_NEEDING_CONNECTION,
102
+ 'inspect', 'execution', 'breakpoint', 'getSourceCode', 'detectModals', 'dismissModal', 'assert',
103
+ 'wait'
104
+ ];
105
+ /**
106
+ * Whether a single step requires a *browser* connection (drives Chrome
107
+ * auto-launch). Param-aware variant of `TOOLS_NEEDING_CONNECTION.includes(tool)`:
108
+ * `wait` is browser-bound only in its selector/selectorGone forms.
109
+ * - `wait({ ms })` is a plain sleep and must not drag a Chrome launch in.
110
+ * - `wait({ expression })` is target-agnostic (valid against Node too), so it
111
+ * behaves like `inspect`: the run connection is injected, but it never
112
+ * forces a browser launch on its own.
113
+ */
114
+ export function commandNeedsBrowserConnection(cmd) {
115
+ if (cmd.tool === 'wait') {
116
+ const p = cmd.params || {};
117
+ return p.selector !== undefined || p.selectorGone !== undefined;
118
+ }
119
+ return TOOLS_NEEDING_CONNECTION.includes(cmd.tool);
120
+ }
121
+ /**
122
+ * Whether a bare step will have the run-level connection injected into it -
123
+ * i.e. whether leaving it bare is AMBIGUOUS about which browser it belongs to.
124
+ *
125
+ * Deliberately wider than `commandNeedsBrowserConnection`, which answers a
126
+ * different question (does this drag a Chrome launch in?). `inspect`,
127
+ * `execution`, `storage` and friends take an optional connectionReason, so a
128
+ * recording made without one captures nothing about which browser it ran
129
+ * against - and on replay it silently lands wherever the run-level connection
130
+ * points. Measuring ambiguity with the narrower predicate missed exactly those
131
+ * tools, which are the ones people actually leave bare.
132
+ */
133
+ export function commandTakesInjectedConnection(cmd) {
134
+ // wait({ ms }) is a plain sleep - no connection is injected, nothing ambiguous.
135
+ if (cmd.tool === 'wait')
136
+ return (cmd.params || {}).ms === undefined;
137
+ return TOOLS_ACCEPTING_CONNECTION.includes(cmd.tool);
138
+ }
139
+ /** The condition types `evaluateCondition` knows how to answer. */
140
+ export const CONDITION_TYPES = ['selector', 'url', 'cookie', 'localStorage', 'indexedDB'];
141
+ /** Shape of a handlebar condition: `{{type:value}}` or `{{!type:value}}`. */
142
+ const CONDITION_PATTERN = /^\{\{(!?)(\w+):(.+)\}\}$/;
143
+ /**
144
+ * Check a condition at authoring time: shape, type, and the `url`/`indexedDB`
145
+ * sub-forms. A value holding a `{{var:...}}` token is skipped - it is
146
+ * substituted at run time, so its final shape is unknowable here.
147
+ */
148
+ export function validateConditionSyntax(condition, maxRegexLength = configManager.getReplayConfig().maxRegexLength) {
149
+ const match = condition.match(CONDITION_PATTERN);
150
+ if (!match) {
151
+ return {
152
+ ok: false,
153
+ reason: `Invalid condition format: "${condition}". Expected {{type:value}} or {{!type:value}}. Supported types: ${CONDITION_TYPES.join(', ')}`
154
+ };
155
+ }
156
+ const [, , type, value] = match;
157
+ if (!CONDITION_TYPES.includes(type)) {
158
+ return {
159
+ ok: false,
160
+ reason: `Unknown condition type: "${type}". Supported types: ${CONDITION_TYPES.join(', ')}`
161
+ };
162
+ }
163
+ const interpolated = value.includes('{{');
164
+ if (type === 'url' && value.startsWith('matches:') && !interpolated) {
165
+ const pattern = value.substring('matches:'.length);
166
+ if (pattern.length > maxRegexLength) {
167
+ return {
168
+ ok: false,
169
+ reason: `Regex pattern too long (${pattern.length} chars, max ${maxRegexLength}). Simplify the pattern or increase maxRegexLength in config.`
170
+ };
171
+ }
172
+ try {
173
+ new RegExp(pattern);
174
+ }
175
+ catch (regexError) {
176
+ return {
177
+ ok: false,
178
+ reason: `Invalid regex pattern "${pattern}": ${regexError.message}. Check syntax at https://regex101.com (JavaScript flavor).`
179
+ };
180
+ }
181
+ }
182
+ if (type === 'indexedDB' && !interpolated) {
183
+ const [db, store, ...rest] = value.split('/');
184
+ if (!db || !store) {
185
+ return {
186
+ ok: false,
187
+ reason: `Invalid indexedDB condition "${value}". Expected {{indexedDB:DB/STORE/KEY}} or {{indexedDB:DB/STORE}}.`
188
+ };
189
+ }
190
+ if (rest.length > 0 && !rest.join('/')) {
191
+ return {
192
+ ok: false,
193
+ reason: `Invalid indexedDB condition "${value}": the key is empty.`
194
+ + ` Use {{indexedDB:${db}/${store}}} to ask whether the store holds anything.`
195
+ };
196
+ }
197
+ }
198
+ return { ok: true };
199
+ }
200
+ /**
201
+ * Evaluate a handlebar-style condition
202
+ * Supported patterns:
203
+ * {{selector:CSS_SELECTOR}} - true if element exists
204
+ * {{!selector:CSS_SELECTOR}} - true if element does NOT exist
205
+ * {{url:contains:STRING}} - true if URL contains string
206
+ * {{url:matches:REGEX}} - true if URL matches regex
207
+ * {{cookie:NAME}} - true if cookie exists
208
+ * {{!cookie:NAME}} - true if cookie does NOT exist
209
+ * {{localStorage:KEY}} - true if localStorage key exists
210
+ * {{!localStorage:KEY}} - true if localStorage key does NOT exist
211
+ * {{indexedDB:DB/STORE/KEY}} - true if that IndexedDB record exists
212
+ * {{indexedDB:DB/STORE}} - true if that object store holds any record
213
+ * {{!indexedDB:...}} - negation of either form
214
+ */
215
+ export async function evaluateCondition(condition, ctx) {
216
+ const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
217
+ const replayConfig = configManager.getReplayConfig();
218
+ // Parse the handlebar pattern
219
+ const match = condition.match(CONDITION_PATTERN);
220
+ if (!match) {
221
+ return {
222
+ met: false,
223
+ reason: `Invalid condition format: "${condition}". Expected {{type:value}} or {{!type:value}}. Supported types: ${CONDITION_TYPES.join(', ')}`,
224
+ isError: true
225
+ };
226
+ }
227
+ const [, negated, type, value] = match;
228
+ const isNegated = negated === '!';
229
+ await debugLog(logPrefix, `Evaluating condition: ${type}${isNegated ? ' (negated)' : ''} = ${value}`);
230
+ try {
231
+ let conditionMet = false;
232
+ switch (type) {
233
+ case 'selector': {
234
+ // Absence is the answer, not a broken condition: it arrives as a thrown
235
+ // ToolError, which any other failure does too - hence the classifier.
236
+ const probeSelector = async () => {
237
+ try {
238
+ const res = await executeToolCall('dom', {
239
+ action: 'querySelector',
240
+ selector: value,
241
+ connectionReason
242
+ });
243
+ return { res };
244
+ }
245
+ catch (selectorError) {
246
+ return {
247
+ failure: selectorError?.message || String(selectorError),
248
+ errorId: selectorError?.response?._errorId,
249
+ };
250
+ }
251
+ };
252
+ const attempt = await probeSelector();
253
+ if (attempt.failure) {
254
+ if (isElementNotFoundFailure({ errorId: attempt.errorId, text: attempt.failure })) {
255
+ conditionMet = false;
256
+ break;
257
+ }
258
+ return {
259
+ met: false,
260
+ reason: `Error evaluating selector condition "${value}": ${attempt.failure}`,
261
+ isError: true
262
+ };
263
+ }
264
+ conditionMet = (attempt.res?.content?.[0]?.text || '').includes('Element found');
265
+ break;
266
+ }
267
+ case 'url': {
268
+ const pageInfo = await executeToolCall('navigate', {
269
+ action: 'info',
270
+ connectionReason
271
+ });
272
+ // From `_meta` where it exists: the text fallback stops the URL at the
273
+ // first comma or space, so a data: URL or a `?ids=1,2` query compared as
274
+ // a truncated prefix - `{{url:EXACT}}` could never match one.
275
+ const currentUrl = pageInfo?._meta?.navigate?.url ?? '';
276
+ if (value.startsWith('contains:')) {
277
+ const searchStr = value.substring('contains:'.length);
278
+ conditionMet = currentUrl.includes(searchStr);
279
+ }
280
+ else if (value.startsWith('matches:')) {
281
+ const pattern = value.substring('matches:'.length);
282
+ // Check regex length limit
283
+ if (pattern.length > replayConfig.maxRegexLength) {
284
+ return {
285
+ met: false,
286
+ reason: `Regex pattern too long (${pattern.length} chars, max ${replayConfig.maxRegexLength}). Simplify the pattern or increase maxRegexLength in config.`,
287
+ isError: true
288
+ };
289
+ }
290
+ // Safely compile regex
291
+ let regex;
292
+ try {
293
+ regex = new RegExp(pattern);
294
+ }
295
+ catch (regexError) {
296
+ return {
297
+ met: false,
298
+ reason: `Invalid regex pattern "${pattern}": ${regexError.message}. Check syntax at https://regex101.com (JavaScript flavor).`,
299
+ isError: true
300
+ };
301
+ }
302
+ conditionMet = regex.test(currentUrl);
303
+ }
304
+ else {
305
+ conditionMet = currentUrl === value;
306
+ }
307
+ break;
308
+ }
309
+ case 'cookie': {
310
+ const result = await executeToolCall('storage', {
311
+ action: 'getCookies',
312
+ connectionReason
313
+ });
314
+ // Names from `_meta`: grepping the rendered JSON matched another
315
+ // cookie's VALUE, and the `name=wanted` form matched any cookie whose
316
+ // name merely ENDS with it.
317
+ conditionMet = (result?._meta?.storage?.cookieNames ?? []).includes(value);
318
+ break;
319
+ }
320
+ case 'localStorage': {
321
+ const result = await executeToolCall('storage', {
322
+ action: 'getLocalStorage',
323
+ key: value,
324
+ connectionReason
325
+ });
326
+ // Presence from `_meta`. The old text test read the whole rendered
327
+ // response, so a key whose VALUE was "null" - or contained "not found",
328
+ // or any OTHER key's value did - reported the key as missing, and a
329
+ // `{{localStorage:...}}` guard skipped work it should have done. An
330
+ // empty string is a stored value and counts as present.
331
+ conditionMet = result?._meta?.storage?.found === true;
332
+ break;
333
+ }
334
+ case 'indexedDB': {
335
+ // DB/STORE/KEY, where the key may itself contain slashes. Two segments
336
+ // ask "does this store hold anything at all".
337
+ const segments = value.split('/');
338
+ const [db, store, ...rest] = segments;
339
+ const key = rest.join('/');
340
+ if (!db || !store) {
341
+ return {
342
+ met: false,
343
+ reason: `Invalid indexedDB condition "${value}". Expected {{indexedDB:DB/STORE/KEY}} or {{indexedDB:DB/STORE}}.`,
344
+ isError: true
345
+ };
346
+ }
347
+ // A key segment that interpolated to nothing must NOT quietly become the
348
+ // store form ("is anything in here"), which answers a different question
349
+ // and would flip a setup decision with no signal.
350
+ if (rest.length > 0 && !key) {
351
+ return {
352
+ met: false,
353
+ reason: `Invalid indexedDB condition "${value}": the key is empty.`
354
+ + ` Use {{indexedDB:${db}/${store}}} to ask whether the store holds anything,`
355
+ + ` or check the {{var:...}} that produced the key.`,
356
+ isError: true
357
+ };
358
+ }
359
+ // A database or store that doesn't exist yet is the record being
360
+ // ABSENT, not a broken condition: that is the state a fresh profile is
361
+ // in, and the state a setup sequence exists to heal. It arrives as a
362
+ // thrown ToolError, like any other tool failure.
363
+ const probe = async (probeKey) => {
364
+ try {
365
+ const res = probeKey !== undefined
366
+ ? await executeToolCall('storage', { action: 'idbGet', db, store, key: probeKey, connectionReason })
367
+ : await executeToolCall('storage', { action: 'idbGetAll', db, store, limit: 1, connectionReason });
368
+ return { res };
369
+ }
370
+ catch (idbError) {
371
+ return { failure: idbError?.message || String(idbError) };
372
+ }
373
+ };
374
+ const isAbsence = (failure) => /does not exist|not found in database|no object store/i.test(failure);
375
+ /**
376
+ * Presence comes from the tool's structured `_meta`, never from its
377
+ * rendered text: a record whose VALUE contains "No record found for
378
+ * this key." (or "**Count:** 0") read as absent when this grepped the
379
+ * markdown.
380
+ */
381
+ const presentIn = (res) => {
382
+ const meta = res?._meta?.storage;
383
+ return key ? meta?.found === true : (meta?.count ?? 0) > 0;
384
+ };
385
+ let attempt = await probe(key || undefined);
386
+ if (attempt.failure) {
387
+ if (isAbsence(attempt.failure)) {
388
+ conditionMet = false;
389
+ break;
390
+ }
391
+ return {
392
+ met: false,
393
+ reason: `Error evaluating indexedDB condition "${value}": ${attempt.failure}`,
394
+ isError: true
395
+ };
396
+ }
397
+ conditionMet = presentIn(attempt.res);
398
+ // A condition is written as text, so a numerically-keyed store ("42")
399
+ // would never match its own record - IndexedDB keys 42 and "42" are
400
+ // different keys. Retry as a number before concluding absence.
401
+ if (!conditionMet && key && /^-?\d+(\.\d+)?$/.test(key)) {
402
+ const numeric = await probe(Number(key));
403
+ if (!numeric.failure && presentIn(numeric.res)) {
404
+ await debugLog(logPrefix, `indexedDB key "${key}" matched as a number, not a string`);
405
+ conditionMet = true;
406
+ }
407
+ }
408
+ break;
409
+ }
410
+ default:
411
+ return {
412
+ met: false,
413
+ reason: `Unknown condition type: "${type}". Supported types: ${CONDITION_TYPES.join(', ')}`,
414
+ isError: true
415
+ };
416
+ }
417
+ // Apply negation
418
+ const finalResult = isNegated ? !conditionMet : conditionMet;
419
+ await debugLog(logPrefix, `Condition ${condition} = ${finalResult}`);
420
+ if (finalResult) {
421
+ return { met: true };
422
+ }
423
+ else {
424
+ return { met: false };
425
+ }
426
+ }
427
+ catch (error) {
428
+ // Tool execution errors are real errors, not just "condition not met"
429
+ return {
430
+ met: false,
431
+ reason: `Error evaluating ${type} condition: ${error.message}`,
432
+ isError: true
433
+ };
434
+ }
435
+ }
436
+ /**
437
+ * Shared preparation for any sequence run INSIDE another one (`conditional`'s
438
+ * `then`, `forEach`'s `do`): decide which of its `launchChrome` steps still
439
+ * apply, and which browser its bare steps belong to.
440
+ *
441
+ * Drop launchChrome steps whose browser already exists - the caller handed us a
442
+ * live connection and relaunching it would throw the session away. Keep the ones
443
+ * whose reference is NOT live: a setup sequence that spans two browsers has to be
444
+ * able to create the second one, or it can only ever heal identity in browsers
445
+ * that happened to be open already. Probed only when there is a launch to reason
446
+ * about, so the common nested call costs no extra tool call.
447
+ */
448
+ async function prepareNestedSequence(sequence, ctx, label, logPrefix) {
449
+ const liveRefs = sequence.commands.some(cmd => cmd.tool === 'launchChrome')
450
+ ? await probeLiveConnectionReferences(ctx.executeToolCall)
451
+ : null;
452
+ const keptLaunches = [];
453
+ const filteredCommands = sequence.commands.filter(cmd => {
454
+ if (cmd.tool !== 'launchChrome')
455
+ return true;
456
+ const recorded = typeof cmd.params?.reference === 'string'
457
+ ? sanitizeReference(cmd.params.reference)
458
+ : undefined;
459
+ // No reference to reason about, or no readable connection list: fall back to
460
+ // the old always-drop behaviour rather than risk killing the live browser.
461
+ if (!recorded || !liveRefs)
462
+ return false;
463
+ const resolved = ctx.connectionMap?.[recorded] ?? recorded;
464
+ if (liveRefs.has(resolved))
465
+ return false;
466
+ debugLog(logPrefix, `Keeping launchChrome for "${resolved}" in nested sequence "${label}": no such connection in this session`);
467
+ keptLaunches.push(resolved);
468
+ return true;
469
+ });
470
+ // A launch we KEPT created a browser that only this sub-sequence knows about,
471
+ // and `create` hoists a uniform connection OFF the steps - so the setup
472
+ // sequence is a launch followed by BARE steps. Left on the parent's
473
+ // connection, those steps run in the caller's browser: the run creates a
474
+ // browser, does nothing in it, and reports success. Bind the sub-run to the
475
+ // browser it just launched, exactly as a top-level run of that sequence would
476
+ // (extractConnectionFromSequence).
477
+ //
478
+ // Only for a launch we kept. A launch that was DROPPED means the browser
479
+ // already existed, and re-pointing bare steps at it would hijack a nested
480
+ // login/setup sequence that has always run in whatever browser called it.
481
+ const nestedAnalysis = analyzeSequenceConnections(filteredCommands);
482
+ const launchedConnection = extractConnectionFromSequence(filteredCommands, nestedAnalysis);
483
+ const nestedConnection = launchedConnection && keptLaunches.includes(launchedConnection)
484
+ ? launchedConnection
485
+ : undefined;
486
+ if (nestedConnection) {
487
+ await debugLog(logPrefix, `Nested sequence "${label}" runs against the browser it launched ("${nestedConnection}"), not the caller's "${ctx.connectionReason}"`);
488
+ }
489
+ return { filteredSequence: { ...sequence, commands: filteredCommands }, filteredCommands, nestedConnection };
490
+ }
491
+ /**
492
+ * Execute a conditional flow - runs a sequence if condition is met
493
+ */
494
+ export async function executeConditionalFlow(condition, sequenceName, ctx, recorder,
495
+ /**
496
+ * The parent run's timeout budget, so substeps are bounded the way the
497
+ * caller asked rather than silently falling back to the defaults.
498
+ *
499
+ * `totalTimeout` must be the parent's REMAINING budget, not a fresh copy of
500
+ * its original value - otherwise wrapping steps in a conditional becomes a
501
+ * way to extend the total, and a caller who set a tight bound to fail fast
502
+ * would not get it.
503
+ */
504
+ budget,
505
+ /**
506
+ * The parent RUN's signal. Without it a nested sequence is deaf to
507
+ * `replay cancel` even at its own step boundaries - the substep loop would
508
+ * run to completion after the user cancelled.
509
+ */
510
+ abortSignal) {
511
+ const { logPrefix = 'executor' } = ctx;
512
+ const replayConfig = configManager.getReplayConfig();
513
+ const currentDepth = ctx.conditionalDepth ?? 0;
514
+ const callStack = ctx.conditionalCallStack ?? [];
515
+ // Check recursion depth limit - allows oscillating patterns (A→B→A) up to max depth
516
+ if (currentDepth >= replayConfig.maxConditionalDepth) {
517
+ const chain = [...callStack, sequenceName].join(' → ');
518
+ await debugLog(logPrefix, `Conditional depth limit exceeded: ${chain}`);
519
+ return {
520
+ success: false,
521
+ executed: false,
522
+ sequenceName,
523
+ error: `Conditional depth limit (${replayConfig.maxConditionalDepth}) reached: ${chain}. Increase maxConditionalDepth in config if this is intentional.`
524
+ };
525
+ }
526
+ // Evaluate the condition
527
+ const condResult = await evaluateCondition(condition, ctx);
528
+ if (!condResult.met) {
529
+ // Check if this is an error vs genuine "condition not met"
530
+ if ('isError' in condResult && condResult.isError) {
531
+ // This is an ERROR - fail the sequence
532
+ await debugLog(logPrefix, `Condition evaluation ERROR: ${condResult.reason}`);
533
+ return {
534
+ success: false,
535
+ executed: false,
536
+ sequenceName,
537
+ error: `Condition evaluation failed: ${condResult.reason}`
538
+ };
539
+ }
540
+ // Genuine "condition not met" - this is success (we correctly evaluated and skipped)
541
+ if ('reason' in condResult && condResult.reason) {
542
+ await debugLog(logPrefix, `Condition not met: ${condResult.reason}`);
543
+ }
544
+ else {
545
+ await debugLog(logPrefix, `Condition ${condition} not met, skipping sequence ${sequenceName}`);
546
+ }
547
+ return { success: true, executed: false, sequenceName };
548
+ }
549
+ await debugLog(logPrefix, `Condition ${condition} met, loading sequence: ${sequenceName}`);
550
+ // Load the sequence
551
+ const loadResult = await loadSequence({ name: sequenceName }, recorder);
552
+ if (!loadResult.success) {
553
+ return { success: false, executed: false, sequenceName, error: `Sequence "${sequenceName}" not found: ${loadResult.error}` };
554
+ }
555
+ const { filteredSequence, nestedConnection, filteredCommands } = await prepareNestedSequence(loadResult.sequence, ctx, sequenceName, logPrefix);
556
+ await debugLog(logPrefix, `Executing conditional sequence "${filteredSequence.name}" with ${filteredCommands.length} commands (depth: ${currentDepth + 1})`);
557
+ // Execute the sequence with updated call stack and depth
558
+ const execResult = await executeSteps({
559
+ sequence: filteredSequence,
560
+ startStep: 0,
561
+ ctx: {
562
+ ...ctx,
563
+ ...(nestedConnection ? { connectionReason: nestedConnection } : {}),
564
+ conditionalDepth: currentDepth + 1,
565
+ conditionalCallStack: [...callStack, sequenceName]
566
+ },
567
+ // Omitted keys fall back to executeSteps' own defaults, so an unbudgeted
568
+ // caller behaves exactly as before.
569
+ ...(budget?.stepTimeout !== undefined ? { stepTimeout: budget.stepTimeout } : {}),
570
+ ...(budget?.totalTimeout !== undefined ? { totalTimeout: budget.totalTimeout } : {}),
571
+ abortSignal,
572
+ });
573
+ // Check for failures
574
+ const failedStep = execResult.results.find(r => !r.success);
575
+ if (failedStep) {
576
+ // Don't wrap errors that are already from nested conditionals - just pass through
577
+ const isNestedConditionalError = failedStep.tool === 'conditional' ||
578
+ failedStep.error?.includes('Conditional depth limit') ||
579
+ failedStep.error?.includes('Condition evaluation failed');
580
+ const error = isNestedConditionalError
581
+ ? failedStep.error
582
+ : `Conditional sequence "${sequenceName}" failed at step ${failedStep.step} (${failedStep.tool}): ${failedStep.error}`;
583
+ return {
584
+ success: false,
585
+ executed: true,
586
+ sequenceName,
587
+ substeps: execResult.results,
588
+ error,
589
+ durationMs: execResult.durationMs
590
+ };
591
+ }
592
+ await debugLog(logPrefix, `Conditional sequence completed successfully in ${execResult.durationMs}ms`);
593
+ return {
594
+ success: true,
595
+ executed: true,
596
+ sequenceName,
597
+ substeps: execResult.results,
598
+ durationMs: execResult.durationMs
599
+ };
600
+ }
601
+ /**
602
+ * Resolve a `forEach` source to the array it enumerates.
603
+ *
604
+ * Two forms, deliberately no more. `{{var:name}}` reads an array a previous
605
+ * `saveAs` step captured - which is how anything non-DOM is enumerated, since
606
+ * `inspect({ action: 'evaluateExpression' })` can already return exactly the
607
+ * list the caller wants and is a recordable step. `{{selectorAll:CSS}}` covers
608
+ * the DOM case without making the caller hand-write an evaluate for it.
609
+ *
610
+ * Note the asymmetry with `conditional`'s conditions: those ask whether one
611
+ * named thing exists, so they can't express "give me every X". That gap is the
612
+ * whole reason this step exists.
613
+ */
614
+ export async function resolveForEachItems(source, ctx) {
615
+ // Already an array: `{{var:rows}}` is a whole-string token, so the run's
616
+ // normal param interpolation has resolved it before this step is dispatched -
617
+ // and it preserves type, so what arrives IS the captured array. That is the
618
+ // common path; the string form below only survives for a nested path that
619
+ // resolved to something odd, and for direct calls.
620
+ if (Array.isArray(source))
621
+ return { ok: true, items: source };
622
+ if (typeof source !== 'string') {
623
+ return {
624
+ ok: false,
625
+ error: `forEach: source resolved to ${source === null ? 'null' : typeof source}, not an array. Expected {{var:name}} pointing at an array, or {{selectorAll:CSS}}.`,
626
+ };
627
+ }
628
+ const varMatch = source.match(/^\{\{var:([^}]+)\}\}$/);
629
+ if (varMatch) {
630
+ const path = varMatch[1].trim();
631
+ const [head, ...rest] = path.split('.');
632
+ let value = ctx.variableStore?.[head];
633
+ if (value === undefined) {
634
+ return { ok: false, error: `forEach: no variable named "${head}". Capture one first with a { saveAs } step.` };
635
+ }
636
+ for (const key of rest) {
637
+ value = value?.[key];
638
+ if (value === undefined) {
639
+ return { ok: false, error: `forEach: "${path}" is undefined on the captured variable.` };
640
+ }
641
+ }
642
+ if (!Array.isArray(value)) {
643
+ return { ok: false, error: `forEach: "${path}" is ${typeof value}, not an array. The source must resolve to an array.` };
644
+ }
645
+ return { ok: true, items: value };
646
+ }
647
+ const selectorMatch = source.match(/^\{\{selectorAll:(.+)\}\}$/);
648
+ if (selectorMatch) {
649
+ const selector = selectorMatch[1];
650
+ // Elements themselves can't cross the CDP boundary, so each item is a plain
651
+ // descriptor. `index` is what a `do` sequence uses to address the element
652
+ // again (:nth-of-type and friends); text/id/class cover the common filters.
653
+ const expression = `(() => Array.from(document.querySelectorAll(${JSON.stringify(selector)})).map((el, index) => ({
654
+ index,
655
+ text: (el.textContent || '').trim(),
656
+ id: el.id || null,
657
+ className: typeof el.className === 'string' ? el.className : null,
658
+ href: el.getAttribute && el.getAttribute('href'),
659
+ value: 'value' in el ? el.value : undefined,
660
+ })))()`;
661
+ try {
662
+ const result = await ctx.executeToolCall('inspect', {
663
+ action: 'evaluateExpression',
664
+ expression,
665
+ ...(ctx.connectionReason ? { connectionReason: ctx.connectionReason } : {}),
666
+ });
667
+ const value = result?._meta?.inspect?.value;
668
+ if (!Array.isArray(value)) {
669
+ return { ok: false, error: `forEach: {{selectorAll:${selector}}} did not evaluate to a list.` };
670
+ }
671
+ return { ok: true, items: value };
672
+ }
673
+ catch (err) {
674
+ return { ok: false, error: `forEach: enumerating {{selectorAll:${selector}}} failed: ${err?.message || String(err)}` };
675
+ }
676
+ }
677
+ return {
678
+ ok: false,
679
+ error: `forEach: unrecognised source "${source}". Expected {{var:name}} (an array captured by a previous saveAs) or {{selectorAll:CSS}}.`,
680
+ };
681
+ }
682
+ /**
683
+ * Evaluate a `where` predicate for one item.
684
+ *
685
+ * The predicate is JavaScript with `item` and `index` in scope, evaluated in the
686
+ * page - NOT the `{{...}}` condition grammar. Conditions probe the browser for
687
+ * one named thing; a filter has to read fields off an arbitrary object, which
688
+ * that grammar cannot express, and inventing a second mini-language to sit
689
+ * beside it would leave two half-expressive syntaxes instead of one real one.
690
+ */
691
+ async function evaluateForEachFilter(where, item, index, ctx) {
692
+ const expression = `(() => { const item = ${JSON.stringify(item)}; const index = ${index}; return !!(${where}); })()`;
693
+ try {
694
+ const result = await ctx.executeToolCall('inspect', {
695
+ action: 'evaluateExpression',
696
+ expression,
697
+ ...(ctx.connectionReason ? { connectionReason: ctx.connectionReason } : {}),
698
+ });
699
+ return { ok: true, keep: result?._meta?.inspect?.value === true };
700
+ }
701
+ catch (err) {
702
+ // A filter that cannot be evaluated is an error, not a quiet "exclude" -
703
+ // the same rule conditions follow. Silently dropping every item would make
704
+ // a typo'd predicate look like an empty result set.
705
+ return { ok: false, error: `forEach: where "${where}" could not be evaluated: ${err?.message || String(err)}` };
706
+ }
707
+ }
708
+ /**
709
+ * Run a sequence once per item of an enumerated source.
710
+ *
711
+ * Each iteration binds the item to `as` in the run's variable store (and its
712
+ * position to `<as>Index`), so the body addresses it with {{var:<as>.field}}
713
+ * exactly like any captured variable. The binding is REPLACED per iteration
714
+ * rather than scoped, because the variable store is shared by reference across
715
+ * nested runs - which also means a body's own `saveAs` captures survive into
716
+ * the next iteration, and a caller relying on that should say so.
717
+ */
718
+ export async function executeForEachFlow(params, ctx, recorder,
719
+ /** The parent's REMAINING budget - a loop must not extend the total. */
720
+ budget, abortSignal) {
721
+ const { logPrefix = 'executor' } = ctx;
722
+ const replayConfig = configManager.getReplayConfig();
723
+ const startedAt = Date.now();
724
+ const sequenceName = params.do;
725
+ const currentDepth = ctx.conditionalDepth ?? 0;
726
+ const callStack = ctx.conditionalCallStack ?? [];
727
+ // Shares the conditional depth budget: a loop body that loops is the same
728
+ // runaway risk, and one cap is easier to reason about than two.
729
+ if (currentDepth >= replayConfig.maxConditionalDepth) {
730
+ const chain = [...callStack, sequenceName].join(' → ');
731
+ return {
732
+ success: false, sequenceName, itemsFound: 0, iterations: 0,
733
+ error: `Nesting depth limit (${replayConfig.maxConditionalDepth}) reached: ${chain}. Increase maxConditionalDepth in config if this is intentional.`,
734
+ };
735
+ }
736
+ const resolved = await resolveForEachItems(params.in, ctx);
737
+ if (!resolved.ok) {
738
+ return { success: false, sequenceName, itemsFound: 0, iterations: 0, error: resolved.error };
739
+ }
740
+ const itemsFound = resolved.items.length;
741
+ const cap = params.maxItems ?? DEFAULT_FOREACH_MAX_ITEMS;
742
+ const loadResult = await loadSequence({ name: sequenceName }, recorder);
743
+ if (!loadResult.success) {
744
+ return {
745
+ success: false, sequenceName, itemsFound, iterations: 0,
746
+ error: `Sequence "${sequenceName}" not found: ${loadResult.error}`,
747
+ };
748
+ }
749
+ const { filteredSequence, nestedConnection } = await prepareNestedSequence(loadResult.sequence, ctx, sequenceName, logPrefix);
750
+ const substeps = [];
751
+ let iterations = 0;
752
+ for (let index = 0; index < itemsFound; index++) {
753
+ if (abortSignal?.aborted) {
754
+ return {
755
+ success: false, sequenceName, itemsFound, iterations, substeps,
756
+ error: 'Replay aborted by user', durationMs: Date.now() - startedAt,
757
+ };
758
+ }
759
+ if (iterations >= cap) {
760
+ await debugLog(logPrefix, `forEach: stopping at maxItems (${cap}) with ${itemsFound - index} item(s) unvisited`);
761
+ break;
762
+ }
763
+ const item = resolved.items[index];
764
+ if (params.where) {
765
+ const filtered = await evaluateForEachFilter(params.where, item, index, ctx);
766
+ if (!filtered.ok) {
767
+ return {
768
+ success: false, sequenceName, itemsFound, iterations, substeps,
769
+ error: filtered.error, durationMs: Date.now() - startedAt,
770
+ };
771
+ }
772
+ if (!filtered.keep)
773
+ continue;
774
+ }
775
+ const store = (ctx.variableStore ??= {});
776
+ store[params.as] = item;
777
+ store[`${params.as}Index`] = index;
778
+ iterations++;
779
+ const elapsed = Date.now() - startedAt;
780
+ const execResult = await executeSteps({
781
+ sequence: filteredSequence,
782
+ startStep: 0,
783
+ ctx: {
784
+ ...ctx,
785
+ ...(nestedConnection ? { connectionReason: nestedConnection } : {}),
786
+ conditionalDepth: currentDepth + 1,
787
+ conditionalCallStack: [...callStack, sequenceName],
788
+ },
789
+ ...(budget?.stepTimeout !== undefined ? { stepTimeout: budget.stepTimeout } : {}),
790
+ ...(budget?.totalTimeout !== undefined
791
+ ? { totalTimeout: Math.max(0, budget.totalTimeout - elapsed) }
792
+ : {}),
793
+ abortSignal,
794
+ });
795
+ substeps.push(...execResult.results);
796
+ const failedStep = execResult.results.find(r => !r.success);
797
+ if (failedStep) {
798
+ return {
799
+ success: false, sequenceName, itemsFound, iterations, substeps,
800
+ error: `forEach body "${sequenceName}" failed on item ${index + 1}/${itemsFound} at step ${failedStep.step} (${failedStep.tool}): ${failedStep.error}`,
801
+ durationMs: Date.now() - startedAt,
802
+ };
803
+ }
804
+ }
805
+ await debugLog(logPrefix, `forEach over ${itemsFound} item(s) ran ${iterations} iteration(s) of "${sequenceName}"`);
806
+ return { success: true, sequenceName, itemsFound, iterations, substeps, durationMs: Date.now() - startedAt };
807
+ }
808
+ /**
809
+ * Load a sequence from memory (by sequenceId) or disk (by name)
810
+ */
811
+ export async function loadSequence(args, recorder) {
812
+ if (args.sequenceId) {
813
+ // Disk wins when the file is newer: memory used to shadow an edited
814
+ // sequence for the whole session, so a re-run silently executed the old
815
+ // version (issue #134).
816
+ const sequence = await recorder.getFreshSequence(args.sequenceId);
817
+ if (!sequence) {
818
+ return {
819
+ success: false,
820
+ error: `Sequence "${args.sequenceId}" not found in memory. Use listSaved to see disk sequences or list for memory sequences.`,
821
+ errorCode: 'SEQUENCE_NOT_FOUND'
822
+ };
823
+ }
824
+ return { success: true, sequence };
825
+ }
826
+ if (args.name) {
827
+ // Check memory first
828
+ const memorySequences = recorder.listSequences();
829
+ const memoryMatch = memorySequences.find(s => s.name === args.name);
830
+ if (memoryMatch) {
831
+ const current = await recorder.getFreshSequence(memoryMatch.id) ?? memoryMatch;
832
+ await debugLog('executor', `Found sequence "${current.name}" in memory`);
833
+ return { success: true, sequence: current };
834
+ }
835
+ // Then check disk (loadSequenceFromDisk has fuzzy matching built in)
836
+ const sequence = await recorder.loadSequenceFromDisk(args.name);
837
+ if (!sequence) {
838
+ const savedSequences = await recorder.listSavedSequencesOnDisk();
839
+ const availableNames = [
840
+ ...memorySequences.map(s => s.name),
841
+ ...savedSequences.map(s => s.name)
842
+ ].join(', ');
843
+ return {
844
+ success: false,
845
+ error: `No sequence found matching "${args.name}". Available: ${availableNames || 'none'}`,
846
+ errorCode: 'SEQUENCE_NOT_FOUND'
847
+ };
848
+ }
849
+ await debugLog('executor', `Loaded sequence "${sequence.name}" via fuzzy match for "${args.name}"`);
850
+ return { success: true, sequence };
851
+ }
852
+ return {
853
+ success: false,
854
+ error: 'Either "name" or "sequenceId" parameter is required.',
855
+ errorCode: 'MISSING_PARAMETER',
856
+ templateVars: { missing: 'name or sequenceId' }
857
+ };
858
+ }
859
+ // =============================================================================
860
+ // Run-time Rebasing
861
+ // =============================================================================
862
+ /**
863
+ * Return a deep copy of the sequence retargeted at another deployment:
864
+ * every absolute http(s) URL — the startUrl and any string param in any
865
+ * command (navigate goto, request url, ...) — keeps its path/query/hash but
866
+ * takes `baseUrl`'s origin. Relative URLs are untouched (they already follow
867
+ * the page origin). An explicit `startUrl` replaces the sequence's startUrl
868
+ * wholesale, after rebasing, for runs whose entry point differs per target
869
+ * (e.g. a freshly minted share link). The stored sequence is never mutated —
870
+ * loadSequence can return the recorder's in-memory object.
871
+ */
872
+ export function rebaseSequence(sequence, overrides) {
873
+ const origin = overrides.baseUrl ? new URL(overrides.baseUrl).origin : null;
874
+ const rebase = (value) => {
875
+ if (!origin || !/^https?:\/\//i.test(value))
876
+ return value;
877
+ try {
878
+ const u = new URL(value);
879
+ return origin + u.pathname + u.search + u.hash;
880
+ }
881
+ catch {
882
+ return value;
883
+ }
884
+ };
885
+ const walk = (value) => {
886
+ if (typeof value === 'string')
887
+ return rebase(value);
888
+ if (Array.isArray(value))
889
+ return value.map(walk);
890
+ if (value && typeof value === 'object') {
891
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, walk(v)]));
892
+ }
893
+ return value;
894
+ };
895
+ return {
896
+ ...sequence,
897
+ startUrl: overrides.startUrl ?? (sequence.startUrl ? rebase(sequence.startUrl) : sequence.startUrl),
898
+ commands: sequence.commands.map(cmd => ({
899
+ ...cmd,
900
+ params: walk(cmd.params),
901
+ })),
902
+ };
903
+ }
904
+ // =============================================================================
905
+ // Connection Analysis
906
+ // =============================================================================
907
+ /**
908
+ * Analyze sequence commands to find launchChrome and determine connection requirements
909
+ */
910
+ export function analyzeSequenceConnections(commands) {
911
+ let launchChromeIndex = -1;
912
+ let firstConnectionToolIndex = -1;
913
+ for (let i = 0; i < commands.length; i++) {
914
+ if (commands[i].tool === 'launchChrome' && launchChromeIndex === -1) {
915
+ launchChromeIndex = i;
916
+ }
917
+ if (commandNeedsBrowserConnection(commands[i]) && firstConnectionToolIndex === -1) {
918
+ firstConnectionToolIndex = i;
919
+ }
920
+ }
921
+ const hasLaunchBeforeConnection = launchChromeIndex !== -1 &&
922
+ (firstConnectionToolIndex === -1 || launchChromeIndex < firstConnectionToolIndex);
923
+ return { launchChromeIndex, firstConnectionToolIndex, hasLaunchBeforeConnection };
924
+ }
925
+ /**
926
+ * Extract connectionReason from sequence's launchChrome command if present
927
+ */
928
+ export function extractConnectionFromSequence(commands, analysis) {
929
+ if (analysis.hasLaunchBeforeConnection) {
930
+ const launchParams = commands[analysis.launchChromeIndex].params;
931
+ if (launchParams.reference) {
932
+ return sanitizeReference(launchParams.reference);
933
+ }
934
+ }
935
+ return undefined;
936
+ }
937
+ /**
938
+ * What connections a recorded/stored sequence's steps name.
939
+ */
940
+ export function analyzeRecordedStepConnections(commands) {
941
+ const references = [];
942
+ let bareBrowserSteps = 0;
943
+ for (const cmd of commands) {
944
+ const raw = cmd.params?.connectionReason;
945
+ if (typeof raw === 'string' && raw.trim()) {
946
+ const ref = sanitizeReference(raw);
947
+ if (!references.includes(ref))
948
+ references.push(ref);
949
+ }
950
+ else if (commandTakesInjectedConnection(cmd)) {
951
+ bareBrowserSteps++;
952
+ }
953
+ }
954
+ return {
955
+ references,
956
+ ...(references.length === 1 ? { uniform: references[0] } : {}),
957
+ mixed: references.length > 0 && bareBrowserSteps > 0,
958
+ multiConnection: references.length > 1,
959
+ };
960
+ }
961
+ /**
962
+ * Hoist a uniform per-step connection back off the steps so the sequence stays
963
+ * portable: `replay({ action: 'run', connectionReason: 'other' })` can then
964
+ * retarget the whole thing. Steps keep their own connection only where the
965
+ * sequence genuinely spans connections (or where it is ambiguous - see
966
+ * `mixed`), which is the case a run-level connection cannot express.
967
+ *
968
+ * Returns a new command array; the input is never mutated.
969
+ */
970
+ export function normalizeStepConnections(commands) {
971
+ const analysis = analyzeRecordedStepConnections(commands);
972
+ if (analysis.uniform === undefined || analysis.mixed) {
973
+ return { commands, analysis };
974
+ }
975
+ const hoisted = analysis.uniform;
976
+ const stripped = commands.map(cmd => {
977
+ if (cmd.params?.connectionReason === undefined)
978
+ return cmd;
979
+ const { connectionReason, ...rest } = cmd.params;
980
+ return { ...cmd, params: rest };
981
+ });
982
+ return { commands: stripped, hoisted, analysis };
983
+ }
984
+ /**
985
+ * Normalize a recorded-reference -> session-reference map (both sides sanitized,
986
+ * so `{ 'Duo Member Two': 'My Second Browser' }` works the same as the
987
+ * hyphenated form). Returns undefined for an empty/absent map.
988
+ */
989
+ export function sanitizeConnectionMap(map) {
990
+ if (!map)
991
+ return undefined;
992
+ const out = {};
993
+ for (const [from, to] of Object.entries(map)) {
994
+ if (typeof to !== 'string' || !to.trim())
995
+ continue;
996
+ out[sanitizeReference(from)] = sanitizeReference(to);
997
+ }
998
+ return Object.keys(out).length > 0 ? out : undefined;
999
+ }
1000
+ /**
1001
+ * Check if sequence needs a connection
1002
+ */
1003
+ export function sequenceNeedsConnection(commands) {
1004
+ return commands.some(cmd => commandNeedsBrowserConnection(cmd) && !cmd.params.connectionReason);
1005
+ }
1006
+ // =============================================================================
1007
+ // Connection Management
1008
+ // =============================================================================
1009
+ /**
1010
+ * The connection references live in this session, as `listConnections` reports
1011
+ * them. Returns null when that cannot be determined (probe failed, or a stubbed
1012
+ * executeToolCall returned nothing parseable) - callers must treat null as
1013
+ * "unknown" and NOT as "empty", or every per-step connection would be rejected.
1014
+ */
1015
+ export async function probeLiveConnectionReferences(executeToolCall) {
1016
+ try {
1017
+ const result = await executeToolCall('listConnections', {});
1018
+ const text = result?.content?.[0]?.text || '';
1019
+ const parsed = parseConnectionList(text);
1020
+ if (!parsed)
1021
+ return null;
1022
+ // A connection whose socket has already dropped is not somewhere a step can
1023
+ // run, so it must not count as live - otherwise a healing sequence skips the
1024
+ // launch that would have replaced it.
1025
+ const refs = new Set();
1026
+ for (const c of parsed) {
1027
+ if (c.connected !== false)
1028
+ refs.add(sanitizeReference(c.reference));
1029
+ }
1030
+ return refs.size > 0 ? refs : null;
1031
+ }
1032
+ catch {
1033
+ return null;
1034
+ }
1035
+ }
1036
+ /**
1037
+ * The `connections` array out of a `listConnections` response, or null when the
1038
+ * response carries no parseable JSON block (a stub, or a future format) - null
1039
+ * means "unknown", never "empty".
1040
+ */
1041
+ export function parseConnectionList(text) {
1042
+ const block = text.match(/```json\s*([\s\S]*?)```/);
1043
+ if (!block)
1044
+ return null;
1045
+ try {
1046
+ const parsed = JSON.parse(block[1]);
1047
+ const list = parsed?.connections;
1048
+ if (!Array.isArray(list))
1049
+ return null;
1050
+ return list.filter((c) => typeof c?.reference === 'string');
1051
+ }
1052
+ catch {
1053
+ return null;
1054
+ }
1055
+ }
1056
+ /**
1057
+ * Resolve a step's RECORDED connectionReason onto this session, and refuse to
1058
+ * proceed if it doesn't exist here (bug-018).
1059
+ *
1060
+ * Falling back to the run-level connection with a warning is exactly the failure
1061
+ * this exists to prevent: a sequence whose purpose is proving something crosses
1062
+ * a browser boundary would run entirely in one browser and still pass.
1063
+ */
1064
+ export function formatMissingStepConnection(opts) {
1065
+ const { step, tool, recorded, resolved, mapped, runConnection, live } = opts;
1066
+ const via = mapped ? ` (mapped from recorded "${recorded}")` : '';
1067
+ return [
1068
+ `Step ${step} (${tool}) needs connection "${resolved}"${via}, which does not exist in this session.`,
1069
+ `Active connections: ${live.length ? live.join(', ') : 'none'}.`,
1070
+ `The step names its own connection, so it is NOT run against` +
1071
+ ` the run-level connection${runConnection ? ` "${runConnection}"` : ''} - that would replay a` +
1072
+ ` multi-browser sequence in a single browser and report success.`,
1073
+ `Either create it (launchChrome({ reference: "${resolved}" })) or rebind it:` +
1074
+ ` replay({ action: 'run', ..., connections: { "${recorded}": "<a reference from this session>" } }).`,
1075
+ ].join(' ');
1076
+ }
1077
+ /**
1078
+ * Check if debugger is paused and return breakpoint info if so
1079
+ */
1080
+ export async function checkIfPaused(ctx) {
1081
+ const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
1082
+ if (!connectionReason)
1083
+ return null;
1084
+ try {
1085
+ const callStackResult = await executeToolCall('inspect', {
1086
+ action: 'getCallStack',
1087
+ connectionReason
1088
+ });
1089
+ const callStackText = callStackResult?.content?.[0]?.text || '';
1090
+ const isPaused = callStackText.includes('callFrameId');
1091
+ if (isPaused) {
1092
+ // Extract pause location from call stack - try header format first, then JSON
1093
+ let url = 'unknown';
1094
+ let lineNumber = 0;
1095
+ let columnNumber;
1096
+ let functionName;
1097
+ // Try header format: "Paused at: http://localhost:3101/client.js:6"
1098
+ const pausedAtMatch = callStackText.match(/Paused at:\s*([^:\s]+):(\d+)/);
1099
+ if (pausedAtMatch) {
1100
+ url = pausedAtMatch[1];
1101
+ lineNumber = parseInt(pausedAtMatch[2], 10);
1102
+ }
1103
+ // Try JSON format for more details
1104
+ const sourceMatch = callStackText.match(/"source":\s*"([^"]+)"/);
1105
+ const lineMatch = callStackText.match(/"line":\s*(\d+)/);
1106
+ const colMatch = callStackText.match(/"column":\s*(\d+)/);
1107
+ const funcMatch = callStackText.match(/"functionName":\s*"([^"]+)"/);
1108
+ if (sourceMatch)
1109
+ url = sourceMatch[1];
1110
+ if (lineMatch)
1111
+ lineNumber = parseInt(lineMatch[1], 10);
1112
+ if (colMatch)
1113
+ columnNumber = parseInt(colMatch[1], 10);
1114
+ if (funcMatch && funcMatch[1])
1115
+ functionName = funcMatch[1];
1116
+ return { url, lineNumber, columnNumber, functionName };
1117
+ }
1118
+ return null;
1119
+ }
1120
+ catch {
1121
+ return null;
1122
+ }
1123
+ }
1124
+ /**
1125
+ * Check if debugger is paused and auto-resume if so
1126
+ */
1127
+ export async function resumeIfPaused(ctx) {
1128
+ const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
1129
+ if (!connectionReason)
1130
+ return;
1131
+ const pauseInfo = await checkIfPaused(ctx);
1132
+ if (pauseInfo) {
1133
+ debugLog(logPrefix, `Debugger is paused at ${pauseInfo.url}:${pauseInfo.lineNumber}, auto-resuming`);
1134
+ try {
1135
+ await executeToolCall('execution', {
1136
+ action: 'resume',
1137
+ connectionReason
1138
+ });
1139
+ await new Promise(resolve => setTimeout(resolve, 100));
1140
+ }
1141
+ catch {
1142
+ // Ignore resume errors
1143
+ }
1144
+ }
1145
+ }
1146
+ /**
1147
+ * Validate reference and auto-launch Chrome if needed.
1148
+ * This is the shared helper for all auto-launch scenarios.
1149
+ */
1150
+ export async function autoLaunchChrome(executeToolCall, connectionReason, logPrefix = 'auto-launch', forceNewInstance = false) {
1151
+ // Validate connectionReason before launch (throws InvalidReferenceError if invalid)
1152
+ requireValidReference(connectionReason);
1153
+ await debugLog(logPrefix, `Auto-launching Chrome with reference: ${connectionReason} (forceNewInstance=${forceNewInstance})`);
1154
+ // A launch failure arrives as a THROW in production (executeToolCall rethrows
1155
+ // isError) - and this helper is called from inside ensureConnection's catch,
1156
+ // so letting it through escaped the run entirely: the caller's LAUNCH_FAILED
1157
+ // handling, and its "launch Chrome manually first" suggestion, never ran and
1158
+ // the user saw a raw tool error instead.
1159
+ try {
1160
+ await executeToolCall('launchChrome', { reference: connectionReason, forceNewInstance });
1161
+ }
1162
+ catch (launchError) {
1163
+ return {
1164
+ success: false,
1165
+ error: `Failed to auto-launch Chrome: ${launchError?.response?.content?.[0]?.text || launchError?.message || 'Unknown error'}`,
1166
+ errorType: 'LAUNCH_FAILED'
1167
+ };
1168
+ }
1169
+ await debugLog(logPrefix, `Chrome launched successfully with reference: ${connectionReason}`);
1170
+ return { success: true };
1171
+ }
1172
+ /**
1173
+ * Ensure a connection is available, auto-launching Chrome if needed
1174
+ */
1175
+ export async function ensureConnection(ctx, needsConnection, hasLaunchBeforeConnection) {
1176
+ const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
1177
+ if (!needsConnection || hasLaunchBeforeConnection) {
1178
+ return { success: true, didAutoLaunch: false };
1179
+ }
1180
+ try {
1181
+ await debugLog(logPrefix, `Checking connection: ${connectionReason}`);
1182
+ const infoResult = await executeToolCall('navigate', { action: 'info', connectionReason });
1183
+ // In production this throws instead, into the same catch below; the check
1184
+ // is for a caller wired not to rethrow.
1185
+ if (infoResult?.isError) {
1186
+ throw new Error('Connection not active');
1187
+ }
1188
+ await debugLog(logPrefix, `Connection ${connectionReason} is active`);
1189
+ // Auto-resume if paused at a breakpoint
1190
+ await resumeIfPaused(ctx);
1191
+ return { success: true, didAutoLaunch: false };
1192
+ }
1193
+ catch {
1194
+ await debugLog(logPrefix, `Connection ${connectionReason} not active, launching Chrome...`);
1195
+ // Sequence runs always get a fresh Chrome process, not a tab in an existing one
1196
+ const launchResult = await autoLaunchChrome(executeToolCall, connectionReason, logPrefix, true);
1197
+ if (!launchResult.success) {
1198
+ return { success: false, error: launchResult.error };
1199
+ }
1200
+ return { success: true, didAutoLaunch: true };
1201
+ }
1202
+ }
1203
+ /**
1204
+ * Check if a URL's port is open (for localhost URLs only)
1205
+ * Returns success if port is open or URL is not localhost
1206
+ */
1207
+ export async function checkPortBeforeNavigation(url, logPrefix = 'executor') {
1208
+ const portCheck = await checkUrlPort(url, 2000);
1209
+ // null means non-localhost URL - skip check
1210
+ if (portCheck === null) {
1211
+ return { success: true };
1212
+ }
1213
+ if (!portCheck.open) {
1214
+ const error = `Port ${portCheck.port} is not open on ${portCheck.host}`;
1215
+ await debugLog(logPrefix, error);
1216
+ return { success: false, error };
1217
+ }
1218
+ await debugLog(logPrefix, `Port ${portCheck.port} is open on ${portCheck.host}`);
1219
+ return { success: true };
1220
+ }
1221
+ /**
1222
+ * Navigate to startUrl if sequence has one and doesn't start with navigate
1223
+ */
1224
+ export async function navigateToStartUrl(ctx, sequence, analysis) {
1225
+ const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
1226
+ if (!sequence.startUrl || !connectionReason) {
1227
+ return { success: true };
1228
+ }
1229
+ const commands = sequence.commands;
1230
+ const firstNavigateIndex = commands.findIndex(cmd => cmd.tool === 'navigate' && cmd.params.action === 'goto');
1231
+ const startsWithNavigate = firstNavigateIndex === 0 ||
1232
+ (analysis.hasLaunchBeforeConnection && firstNavigateIndex === analysis.launchChromeIndex + 1);
1233
+ if (startsWithNavigate) {
1234
+ return { success: true };
1235
+ }
1236
+ // Check if port is open before navigating (localhost only)
1237
+ const portCheck = await checkPortBeforeNavigation(sequence.startUrl, logPrefix);
1238
+ if (!portCheck.success) {
1239
+ return portCheck;
1240
+ }
1241
+ await debugLog(logPrefix, `Auto-navigating to startUrl: ${sequence.startUrl}`);
1242
+ try {
1243
+ await executeToolCall('navigate', {
1244
+ action: 'goto',
1245
+ url: sequence.startUrl,
1246
+ connectionReason
1247
+ });
1248
+ await debugLog(logPrefix, `Navigated to startUrl: ${sequence.startUrl}`);
1249
+ return { success: true };
1250
+ }
1251
+ catch (navError) {
1252
+ return {
1253
+ success: false,
1254
+ error: `Failed to navigate to startUrl: ${navError.message}`
1255
+ };
1256
+ }
1257
+ }
1258
+ // =============================================================================
1259
+ // Command Execution
1260
+ // =============================================================================
1261
+ /**
1262
+ * Execute a single command with retry logic for element not found errors
1263
+ */
1264
+ export async function executeCommandWithRetry(executeToolCall, tool, params, logPrefix = 'executor',
1265
+ /**
1266
+ * The RUN's signal, forwarded to the tool handler so handlers that honour
1267
+ * it (currently `wait`) are interrupted mid-step by `replay cancel`. Note
1268
+ * this helper still RESOLVES `{ success: false }` when a handler throws an
1269
+ * abort - executeSteps consults the signal on the failure path to classify
1270
+ * it as "Replay aborted by user" rather than a genuine step failure.
1271
+ */
1272
+ abortSignal) {
1273
+ const isRetryableAction = tool === 'input' && ['click', 'type', 'hover'].includes(params.action);
1274
+ const maxRetries = isRetryableAction ? 5 : 1;
1275
+ const retryDelayMs = 500;
1276
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
1277
+ // A tool error arrives as an EXCEPTION: executeToolCall raises a ToolError
1278
+ // for any isError response. The catch is what drives element-not-found
1279
+ // retries (e.g. an async-rendered button that hasn't mounted yet).
1280
+ let result;
1281
+ try {
1282
+ result = await executeToolCall(tool, params, abortSignal);
1283
+ }
1284
+ catch (err) {
1285
+ const errorText = err?.response?.content?.[0]?.text || err?.message || '';
1286
+ // A bare "not found" also matched CONNECTION_NOT_FOUND, SEQUENCE_NOT_FOUND
1287
+ // and friends, so a click against a dead connection burned all five
1288
+ // retries and 2.5s before reporting what was wrong on the first attempt.
1289
+ const isElementNotFound = isElementNotFoundFailure({
1290
+ errorId: err?.response?._errorId,
1291
+ text: errorText,
1292
+ });
1293
+ if (isRetryableAction && isElementNotFound && attempt < maxRetries) {
1294
+ debugLog(logPrefix, `Element not found, retrying... (attempt ${attempt}/${maxRetries})`);
1295
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs));
1296
+ continue;
1297
+ }
1298
+ return { success: false, error: errorText.split('\n')[0] || 'Unknown error' };
1299
+ }
1300
+ return { success: true, result };
1301
+ }
1302
+ return { success: false, error: 'Max retries exceeded' };
1303
+ }
1304
+ /**
1305
+ * Validate that navigation succeeded (page loaded correctly)
1306
+ */
1307
+ export async function validateNavigation(ctx, expectedUrl) {
1308
+ const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
1309
+ try {
1310
+ const infoResult = await executeToolCall('navigate', {
1311
+ action: 'info',
1312
+ connectionReason
1313
+ });
1314
+ const infoText = infoResult?.content?.[0]?.text || '';
1315
+ // Check for common error patterns
1316
+ if (infoText.includes('about:blank') && expectedUrl && !expectedUrl.includes('about:blank')) {
1317
+ return { success: false, error: 'Page failed to load (stuck on about:blank)' };
1318
+ }
1319
+ // Check for Chrome error pages
1320
+ if (infoText.includes('chrome-error://') || infoText.includes('ERR_')) {
1321
+ const errMatch = infoText.match(/(ERR_[A-Z_]+)/);
1322
+ return { success: false, error: `Page failed to load: ${errMatch?.[1] || 'connection error'}` };
1323
+ }
1324
+ // Check title for error indicators
1325
+ const titleMatch = infoText.match(/\*\*Title:\*\*\s*([^\n]+)/);
1326
+ const title = titleMatch?.[1]?.toLowerCase() || '';
1327
+ if (title.includes("site can't be reached") || title.includes("this site can't be reached")) {
1328
+ return { success: false, error: 'Site cannot be reached' };
1329
+ }
1330
+ debugLog(logPrefix, `Navigation validated successfully`);
1331
+ return { success: true };
1332
+ }
1333
+ catch (err) {
1334
+ debugLog(logPrefix, `Navigation validation failed: ${err.message}`);
1335
+ return { success: false, error: `Could not validate navigation: ${err.message}` };
1336
+ }
1337
+ }
1338
+ /**
1339
+ * Wait for an element to appear
1340
+ */
1341
+ export async function waitForElement(ctx, selector) {
1342
+ const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
1343
+ debugLog(logPrefix, `Waiting for element: ${selector}`);
1344
+ let retries = 5;
1345
+ while (retries > 0) {
1346
+ try {
1347
+ const result = await executeToolCall('dom', {
1348
+ action: 'querySelector',
1349
+ selector,
1350
+ connectionReason
1351
+ });
1352
+ if (result && !result.isError) {
1353
+ debugLog(logPrefix, `Element ${selector} found`);
1354
+ return;
1355
+ }
1356
+ }
1357
+ catch {
1358
+ // Ignore errors during wait
1359
+ }
1360
+ retries--;
1361
+ if (retries > 0) {
1362
+ debugLog(logPrefix, `Element ${selector} not found, waiting... (${retries} retries left)`);
1363
+ await new Promise(resolve => setTimeout(resolve, 500));
1364
+ }
1365
+ }
1366
+ debugLog(logPrefix, `Warning: Element ${selector} not found after waiting`);
1367
+ }
1368
+ /**
1369
+ * Validate typed text was entered correctly
1370
+ */
1371
+ export async function validateTypedText(ctx, selector, expectedText, append = false) {
1372
+ const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
1373
+ debugLog(logPrefix, `Validating typed text in ${selector}${append ? ' (append mode)' : ''}`);
1374
+ try {
1375
+ await new Promise(resolve => setTimeout(resolve, 100));
1376
+ // Check .value for inputs/textareas, fall back to .innerText for contenteditable elements (e.g., ProseMirror)
1377
+ const evalResult = await executeToolCall('inspect', {
1378
+ action: 'evaluateExpression',
1379
+ expression: `(() => {
1380
+ const el = document.querySelector('${selector.replace(/'/g, "\\'")}');
1381
+ if (!el) return '';
1382
+ // For input/textarea, use .value
1383
+ if (el.value !== undefined && el.value !== '') return el.value;
1384
+ // For contenteditable (ProseMirror, etc.), use innerText
1385
+ if (el.isContentEditable || el.contentEditable === 'true') return el.innerText?.trim() || '';
1386
+ return el.value || '';
1387
+ })()`,
1388
+ connectionReason
1389
+ });
1390
+ let actualValue = '';
1391
+ if (evalResult?.content?.[0]?.text) {
1392
+ const codeBlockMatch = evalResult.content[0].text.match(/```(?:json)?\n([\s\S]*?)\n```/);
1393
+ if (codeBlockMatch) {
1394
+ actualValue = codeBlockMatch[1].trim();
1395
+ if (actualValue.startsWith('"') && actualValue.endsWith('"')) {
1396
+ actualValue = JSON.parse(actualValue);
1397
+ }
1398
+ }
1399
+ }
1400
+ // In append mode, check if the field ends with the expected text
1401
+ // In replace mode, check for exact match
1402
+ if (append) {
1403
+ if (!actualValue.endsWith(expectedText)) {
1404
+ debugLog(logPrefix, `Text validation failed (append): expected to end with "${expectedText}", got "${actualValue}"`);
1405
+ throw new Error(`Text validation failed for ${selector}: expected to end with "${expectedText}", got "${actualValue}"`);
1406
+ }
1407
+ }
1408
+ else {
1409
+ if (actualValue !== expectedText) {
1410
+ debugLog(logPrefix, `Text validation failed: expected "${expectedText}", got "${actualValue}"`);
1411
+ throw new Error(`Text validation failed for ${selector}: expected "${expectedText}", got "${actualValue}"`);
1412
+ }
1413
+ }
1414
+ debugLog(logPrefix, `Text validated: "${actualValue}" ${append ? 'ends with' : 'matches'} expected`);
1415
+ }
1416
+ catch (error) {
1417
+ if (error.message?.includes('Text validation failed')) {
1418
+ throw error;
1419
+ }
1420
+ debugLog(logPrefix, `Warning: Could not validate typed text: ${error}`);
1421
+ }
1422
+ }
1423
+ /**
1424
+ * Capture pre-click state for delta comparison
1425
+ */
1426
+ export async function capturePreClickState(ctx) {
1427
+ const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
1428
+ let consoleErrorCount = 0;
1429
+ let consoleWarnCount = 0;
1430
+ let consoleTotalCount = 0;
1431
+ let networkRequestCount = 0;
1432
+ let url = '';
1433
+ try {
1434
+ // Get console counts via _meta
1435
+ const consoleResult = await executeToolCall('console', {
1436
+ action: 'list', limit: 1, connectionReason
1437
+ });
1438
+ consoleErrorCount = consoleResult?._meta?.console?.errorCount || 0;
1439
+ consoleWarnCount = consoleResult?._meta?.console?.warnCount || 0;
1440
+ consoleTotalCount = consoleResult?._meta?.console?.totalCount || 0;
1441
+ }
1442
+ catch {
1443
+ debugLog(logPrefix, 'Warning: Could not get pre-click console state');
1444
+ }
1445
+ try {
1446
+ // Get network request count via _meta
1447
+ const networkResult = await executeToolCall('network', {
1448
+ action: 'list', limit: 1, connectionReason
1449
+ });
1450
+ networkRequestCount = networkResult?._meta?.network?.totalCount || 0;
1451
+ }
1452
+ catch {
1453
+ debugLog(logPrefix, 'Warning: Could not get pre-click network state');
1454
+ }
1455
+ try {
1456
+ // Get current URL via _meta
1457
+ const pageResult = await executeToolCall('navigate', {
1458
+ action: 'info', connectionReason
1459
+ });
1460
+ url = pageResult?._meta?.navigate?.url || '';
1461
+ }
1462
+ catch {
1463
+ debugLog(logPrefix, 'Warning: Could not get pre-click URL');
1464
+ }
1465
+ return { consoleErrorCount, consoleWarnCount, consoleTotalCount, networkRequestCount, url };
1466
+ }
1467
+ /**
1468
+ * Validate click action results
1469
+ */
1470
+ export async function validateClickAction(ctx, preState, clickResult, config) {
1471
+ const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
1472
+ const errors = [];
1473
+ const warnings = [];
1474
+ const info = [];
1475
+ // Get structured data from _meta
1476
+ const clickMeta = clickResult?._meta?.click;
1477
+ // Small delay before validation
1478
+ if (config.postClickDelayMs > 0) {
1479
+ await new Promise(r => setTimeout(r, config.postClickDelayMs));
1480
+ }
1481
+ // 1. Check if click had any effect (DOM changes)
1482
+ if (config.requireDomChanges && clickMeta?.domChanges) {
1483
+ if (clickMeta.domChanges.mutationCount === 0) {
1484
+ const msg = 'Click had no DOM effect (0 mutations)';
1485
+ if (config.domChangesFailMode === 'error') {
1486
+ errors.push(msg);
1487
+ }
1488
+ else {
1489
+ warnings.push(msg);
1490
+ }
1491
+ }
1492
+ }
1493
+ // 2. Check for navigation and validate it
1494
+ if (config.validateNavigation && clickMeta?.navigationOccurred) {
1495
+ const navResult = await validateNavigation(ctx);
1496
+ if (!navResult.success) {
1497
+ errors.push(`Navigation failed: ${navResult.error}`);
1498
+ }
1499
+ }
1500
+ // 3. Check for new console messages
1501
+ try {
1502
+ const consoleResult = await executeToolCall('console', {
1503
+ action: 'list', limit: 1, connectionReason
1504
+ });
1505
+ const newErrorCount = consoleResult?._meta?.console?.errorCount || 0;
1506
+ const newWarnCount = consoleResult?._meta?.console?.warnCount || 0;
1507
+ const newTotalCount = consoleResult?._meta?.console?.totalCount || 0;
1508
+ // Report new errors (respecting failOnConsoleErrors config)
1509
+ if (config.failOnConsoleErrors && newErrorCount > preState.consoleErrorCount) {
1510
+ const diff = newErrorCount - preState.consoleErrorCount;
1511
+ const msg = `${diff} new console error(s) after click`;
1512
+ if (config.consoleErrorsFailMode === 'error') {
1513
+ errors.push(msg);
1514
+ }
1515
+ else {
1516
+ warnings.push(msg);
1517
+ }
1518
+ }
1519
+ // Report new warnings as info
1520
+ if (newWarnCount > preState.consoleWarnCount) {
1521
+ const diff = newWarnCount - preState.consoleWarnCount;
1522
+ info.push(`${diff} new console warning(s)`);
1523
+ }
1524
+ // Report other new messages (log/info) as info
1525
+ const newLogInfoCount = (newTotalCount - newErrorCount - newWarnCount) -
1526
+ (preState.consoleTotalCount - preState.consoleErrorCount - preState.consoleWarnCount);
1527
+ if (newLogInfoCount > 0) {
1528
+ info.push(`${newLogInfoCount} new console log(s)`);
1529
+ }
1530
+ }
1531
+ catch {
1532
+ debugLog(logPrefix, 'Warning: Could not check console after click');
1533
+ }
1534
+ // 4. Check for network request failures
1535
+ if (config.validateNetworkPayload) {
1536
+ try {
1537
+ const networkResult = await executeToolCall('network', {
1538
+ action: 'list', connectionReason
1539
+ });
1540
+ const newCount = networkResult?._meta?.network?.totalCount || 0;
1541
+ if (newCount > preState.networkRequestCount) {
1542
+ // Check for failed POST requests
1543
+ const failedResult = await executeToolCall('network', {
1544
+ action: 'search', method: 'POST', statusCode: '4', connectionReason
1545
+ });
1546
+ const failedCount = failedResult?._meta?.network?.matchCount || 0;
1547
+ if (failedCount > 0) {
1548
+ const msg = 'POST request returned 4xx error';
1549
+ if (config.networkFailMode === 'error') {
1550
+ errors.push(msg);
1551
+ }
1552
+ else {
1553
+ warnings.push(msg);
1554
+ }
1555
+ }
1556
+ }
1557
+ }
1558
+ catch {
1559
+ debugLog(logPrefix, 'Warning: Could not check network after click');
1560
+ }
1561
+ }
1562
+ // Log validation result
1563
+ if (errors.length > 0) {
1564
+ debugLog(logPrefix, `Click validation failed: ${errors.join('; ')}`);
1565
+ }
1566
+ else if (warnings.length > 0) {
1567
+ debugLog(logPrefix, `Click validation warnings: ${warnings.join('; ')}`);
1568
+ }
1569
+ return {
1570
+ valid: errors.length === 0,
1571
+ errors,
1572
+ warnings,
1573
+ info
1574
+ };
1575
+ }
1576
+ /**
1577
+ * Gather diagnostic information after a failure
1578
+ */
1579
+ async function gatherDiagnostics(ctx) {
1580
+ const { executeToolCall, connectionReason } = ctx;
1581
+ if (!connectionReason)
1582
+ return '';
1583
+ try {
1584
+ const consoleResult = await executeToolCall('console', {
1585
+ action: 'list',
1586
+ type: 'error',
1587
+ connectionReason
1588
+ });
1589
+ // Counts come from `_meta`, as validateClickAction's do. Counting
1590
+ // `**error**` in the rendered console text also counted the word inside a
1591
+ // logged MESSAGE, and `\d{3}` over the network text matched any three
1592
+ // digits anywhere - a timestamp, a byte count, an id in a URL.
1593
+ const errorCount = consoleResult?._meta?.console?.errorCount ?? 0;
1594
+ // `network search` REQUIRES a pattern and reads statusCode as an exact code
1595
+ // or an "Nxx" class. Asking for `{ statusCode: '4' }` with no pattern was
1596
+ // rejected outright, so this whole helper threw and every step failure was
1597
+ // reported with no page state at all - and had it got through, '4' would
1598
+ // have matched no request either.
1599
+ const countRequests = async (statusCode) => {
1600
+ const result = await executeToolCall('network', {
1601
+ action: 'search',
1602
+ pattern: '.',
1603
+ statusCode,
1604
+ connectionReason
1605
+ });
1606
+ return result?._meta?.network?.matchCount ?? 0;
1607
+ };
1608
+ const failedRequests = (await countRequests('4xx')) + (await countRequests('5xx'));
1609
+ const interactiveResult = await executeToolCall('content', {
1610
+ action: 'findInteractive',
1611
+ connectionReason
1612
+ });
1613
+ const interactiveCount = interactiveResult?._meta?.content?.totalCount ?? 'unknown';
1614
+ return ` | Page state: ${interactiveCount} interactive elements, ${errorCount} console errors, ${failedRequests} failed requests`;
1615
+ }
1616
+ catch (err) {
1617
+ // Say why. Swallowing this silently is how a malformed network probe hid
1618
+ // for as long as it did: every step failure simply carried no page state,
1619
+ // and nothing anywhere said the probe had failed.
1620
+ debugLog(ctx.logPrefix || 'executor', `Could not gather diagnostics: ${err?.message || err}`);
1621
+ return '';
1622
+ }
1623
+ }
1624
+ /**
1625
+ * Execute a range of steps from a sequence
1626
+ */
1627
+ export async function executeSteps(options) {
1628
+ const { sequence, startStep, endStep, ctx, variables, record, stepTimeout = 30000, totalTimeout = 300000, overrideConnectionReason, abortSignal, onProgress } = options;
1629
+ const { executeToolCall, commandRecorder, connectionReason, connectionMap, logPrefix = 'executor' } = ctx;
1630
+ const commands = sequence.commands;
1631
+ const targetEnd = endStep ?? commands.length;
1632
+ const results = [];
1633
+ const startTime = Date.now();
1634
+ // A sequence whose steps name more than one connection can only be replayed
1635
+ // faithfully against those connections. `overrideConnectionReason` (the
1636
+ // run-level connectionReason) must therefore NOT be stamped onto its
1637
+ // launchChrome steps - that would point every launch at one reference and
1638
+ // collapse the very interleaving the sequence exists to reproduce (bug-018).
1639
+ const recordedConnections = analyzeRecordedStepConnections(commands);
1640
+ /** Recorded reference -> this session's reference. */
1641
+ const mapConnection = (ref) => connectionMap?.[sanitizeReference(ref)] ?? sanitizeReference(ref);
1642
+ // Live-connection references, probed lazily and re-probed on a miss (a step
1643
+ // earlier in the sequence may have launched the browser a later step needs).
1644
+ let liveConnections = null;
1645
+ const stepConnectionExists = async (ref) => {
1646
+ if (!liveConnections?.has(ref)) {
1647
+ liveConnections = await probeLiveConnectionReferences(executeToolCall);
1648
+ }
1649
+ // null = could not determine. Treat as "unknown", never as "absent": the
1650
+ // tool call itself still fails loudly (CONNECTION_NOT_FOUND) if the
1651
+ // reference really is missing, and it never silently falls back.
1652
+ if (liveConnections === null)
1653
+ return { known: true, live: [] };
1654
+ return { known: liveConnections.has(ref), live: [...liveConnections] };
1655
+ };
1656
+ // {{timestamp}} must be stable across every step of a run (including a
1657
+ // later step/finish call), not recomputed per-step - cache once on ctx.
1658
+ const runTimestamp = ctx.runTimestamp ?? (ctx.runTimestamp = Date.now());
1659
+ // Seed the captured-variable store ONCE, on the caller's own ctx, and use
1660
+ // this single object everywhere below (interpolation, per-step ctx clones,
1661
+ // captures). Creating it lazily at a capture site would attach it to
1662
+ // whichever ctx happened to be in hand - for a nested sequence that is the
1663
+ // child's clone, so the parent would silently never see the capture.
1664
+ const variableStore = (ctx.variableStore ??= {});
1665
+ // Track breakpoints set during this sequence run (url:line format)
1666
+ const expectedBreakpoints = new Set();
1667
+ // Auto-resume if debugger is paused from a previous run
1668
+ if (connectionReason && startStep === 0) {
1669
+ await resumeIfPaused(ctx);
1670
+ }
1671
+ // Timeout helper. On timeout the losing tool call is NOT cancelled (handlers
1672
+ // receive the RUN's signal, but the step timer is not wired to it), so it may
1673
+ // still settle in the background - swallow its eventual rejection so it can't
1674
+ // surface as an unhandled rejection after the run has already reported the
1675
+ // timeout. The run stops at the timed-out step, so no later step races
1676
+ // against the orphan.
1677
+ const executeWithTimeout = async (promise, timeoutMs, timeoutMessage) => {
1678
+ let timeoutId;
1679
+ const timeoutPromise = new Promise((_, reject) => {
1680
+ timeoutId = setTimeout(() => {
1681
+ promise.catch(() => { });
1682
+ reject(new Error(timeoutMessage));
1683
+ }, timeoutMs);
1684
+ });
1685
+ try {
1686
+ return await Promise.race([promise, timeoutPromise]);
1687
+ }
1688
+ finally {
1689
+ clearTimeout(timeoutId);
1690
+ }
1691
+ };
1692
+ for (let i = startStep; i < targetEnd; i++) {
1693
+ const cmd = commands[i];
1694
+ // Check if aborted
1695
+ if (abortSignal?.aborted) {
1696
+ debugLog(logPrefix, `Replay aborted at step ${i + 1}`);
1697
+ results.push({
1698
+ step: i + 1,
1699
+ tool: cmd.tool,
1700
+ success: false,
1701
+ error: 'Replay aborted by user'
1702
+ });
1703
+ break;
1704
+ }
1705
+ // Check total timeout
1706
+ const elapsed = Date.now() - startTime;
1707
+ if (elapsed >= totalTimeout) {
1708
+ debugLog(logPrefix, `Total timeout exceeded after ${elapsed}ms`);
1709
+ results.push({
1710
+ step: i + 1,
1711
+ tool: cmd.tool,
1712
+ success: false,
1713
+ error: `Total timeout exceeded (${totalTimeout}ms)`
1714
+ });
1715
+ break;
1716
+ }
1717
+ // Wait for delay if specified (for recorded interactions)
1718
+ if (cmd.delay && cmd.delay > 0) {
1719
+ debugLog(logPrefix, `Waiting ${cmd.delay}ms before step ${i + 1}`);
1720
+ const wasAborted = await abortableDelayResult(cmd.delay, abortSignal);
1721
+ if (wasAborted)
1722
+ continue;
1723
+ }
1724
+ onProgress?.({ step: i + 1, totalSteps: commands.length, tool: cmd.tool });
1725
+ try {
1726
+ // Log comment if present
1727
+ if (cmd.comment) {
1728
+ debugLog(logPrefix, `Comment: ${cmd.comment}`);
1729
+ }
1730
+ debugLog(logPrefix, `Executing step ${i + 1}/${commands.length}: ${cmd.tool}`);
1731
+ // Build params
1732
+ let params = { ...cmd.params };
1733
+ // Resolve {{var:name.path}} / {{timestamp}} tokens against the run's
1734
+ // variable store. Throws InterpolationError on an unresolvable token -
1735
+ // caught by this step's try/catch below, same as any other step failure.
1736
+ params = interpolateParams(params, variableStore, runTimestamp);
1737
+ // Apply variable substitutions
1738
+ if (variables && cmd.tool === 'input' && params.action === 'type' && params.text) {
1739
+ const varName = `var_${i}_${params.selector?.replace(/[^a-zA-Z0-9]/g, '_') || 'text'}`;
1740
+ if (variables[varName] !== undefined) {
1741
+ params.text = variables[varName];
1742
+ debugLog(logPrefix, `Substituted ${varName}: "${params.text}"`);
1743
+ }
1744
+ }
1745
+ // A nested `replay run` STEP must block: `run` is background-by-default
1746
+ // for direct callers, but a sequence step that starts another sequence
1747
+ // needs its result (the parent's success depends on it). Without this a
1748
+ // nested run would register as its own top-level run and the step would
1749
+ // "succeed" instantly, fire-and-forget. An explicit wait:false on the
1750
+ // step is honoured for callers who genuinely want that.
1751
+ if (cmd.tool === 'replay' && params.action === 'run' && params.wait === undefined) {
1752
+ params.wait = true;
1753
+ }
1754
+ // A per-step connectionReason is a reference from the RECORDING session, so
1755
+ // rebind it onto this one before anything uses it, then require that it
1756
+ // actually exists here. There is deliberately no fallback to the run-level
1757
+ // connection: that is precisely how a two-browser sequence used to replay
1758
+ // green in one browser (bug-018).
1759
+ if (typeof params.connectionReason === 'string' && params.connectionReason.trim()) {
1760
+ const recorded = sanitizeReference(params.connectionReason);
1761
+ const resolved = mapConnection(recorded);
1762
+ params.connectionReason = resolved;
1763
+ // Checked for ANY step naming a connection other than the run's, not
1764
+ // just multi-connection sequences: a single-reference sequence pointed
1765
+ // at a browser that isn't here otherwise fails deep inside the tool
1766
+ // with a generic "Not connected to browser" and never names the
1767
+ // connection it wanted.
1768
+ if (resolved !== connectionReason) {
1769
+ const { known, live } = await stepConnectionExists(resolved);
1770
+ if (!known) {
1771
+ results.push({
1772
+ step: i + 1,
1773
+ tool: cmd.tool,
1774
+ success: false,
1775
+ error: formatMissingStepConnection({
1776
+ step: i + 1,
1777
+ tool: cmd.tool,
1778
+ recorded,
1779
+ resolved,
1780
+ mapped: resolved !== recorded,
1781
+ runConnection: connectionReason,
1782
+ live,
1783
+ }),
1784
+ });
1785
+ break;
1786
+ }
1787
+ }
1788
+ }
1789
+ // launchChrome/connectDebugger steps CREATE the reference, so a mapping has
1790
+ // to rename the launch too or the sequence would open the recorded name and
1791
+ // then drive a differently-named one.
1792
+ let launchRenamedByMap = false;
1793
+ if ((cmd.tool === 'launchChrome' || cmd.tool === 'connectDebugger') &&
1794
+ typeof params.reference === 'string' && connectionMap) {
1795
+ const mappedRef = connectionMap[sanitizeReference(params.reference)];
1796
+ if (mappedRef) {
1797
+ params.reference = mappedRef;
1798
+ launchRenamedByMap = true;
1799
+ }
1800
+ }
1801
+ // Inject the run-level connectionReason for tools that accept one, unless the
1802
+ // step names its own (per-step connection wins - multi-device sequences).
1803
+ if (connectionReason && TOOLS_ACCEPTING_CONNECTION.includes(cmd.tool) && !params.connectionReason) {
1804
+ params.connectionReason = connectionReason;
1805
+ }
1806
+ // request({ destination: 'browser' }) needs a connectionReason too, but request
1807
+ // is deliberately in neither list (destination:'node' sequences must not force a
1808
+ // Chrome auto-launch, and destination:'node' takes no connection at all)
1809
+ if (cmd.tool === 'request' && params.destination === 'browser' && !params.connectionReason && connectionReason) {
1810
+ params.connectionReason = connectionReason;
1811
+ }
1812
+ // The connection this step actually runs against: its own if it named one,
1813
+ // otherwise the run-level connection. Everything wrapped around the step -
1814
+ // pre/post-click state, navigation + typed-text validation, pause detection,
1815
+ // failure diagnostics - must observe THIS connection, not the run-level one.
1816
+ // Helpers keep reading ctx.connectionReason; we just hand them a ctx whose
1817
+ // connection is the step's (bug-009).
1818
+ const stepConnection = params.connectionReason || connectionReason;
1819
+ const stepCtx = stepConnection === connectionReason
1820
+ ? ctx
1821
+ : {
1822
+ ...ctx,
1823
+ connectionReason: stepConnection,
1824
+ // share the run's variable store with the clone, don't fork it
1825
+ variableStore,
1826
+ };
1827
+ // Override launchChrome reference if custom connectionReason provided.
1828
+ // Skipped for multi-connection sequences: stamping one reference onto every
1829
+ // launch would collapse them into a single browser (see recordedConnections).
1830
+ // An explicit `connections` entry for this launch is the more specific
1831
+ // instruction and must win - otherwise the map renames the launch and this
1832
+ // silently renames it back, with the two writers disagreeing and no signal.
1833
+ if (cmd.tool === 'launchChrome' && overrideConnectionReason) {
1834
+ if (recordedConnections.multiConnection) {
1835
+ debugLog(logPrefix, `Not overriding launchChrome reference "${params.reference}" with "${overrideConnectionReason}": sequence spans ${recordedConnections.references.length} connections`);
1836
+ }
1837
+ else if (launchRenamedByMap) {
1838
+ debugLog(logPrefix, `Not overriding launchChrome reference "${params.reference}" with "${overrideConnectionReason}": connections mapping already rebound this launch`);
1839
+ }
1840
+ else {
1841
+ params.reference = overrideConnectionReason;
1842
+ }
1843
+ }
1844
+ // Handle stale callFrameId for getVariables
1845
+ if (cmd.tool === 'inspect' && params.action === 'getVariables' && params.callFrameId && stepConnection) {
1846
+ debugLog(logPrefix, `Refreshing stale callFrameId`);
1847
+ try {
1848
+ const callStackResult = await executeToolCall('inspect', {
1849
+ action: 'getCallStack',
1850
+ connectionReason: stepConnection
1851
+ });
1852
+ const callStackText = callStackResult?.content?.[0]?.text || '';
1853
+ const callFrameIdMatch = callStackText.match(/"callFrameId":\s*"([^"]+)"/);
1854
+ if (callFrameIdMatch?.[1]) {
1855
+ params.callFrameId = callFrameIdMatch[1];
1856
+ }
1857
+ }
1858
+ catch (err) {
1859
+ debugLog(logPrefix, `Warning: Failed to get fresh callFrameId: ${err.message}`);
1860
+ }
1861
+ }
1862
+ // Check port before navigate goto (localhost only)
1863
+ if (cmd.tool === 'navigate' && params.action === 'goto' && params.url) {
1864
+ const portCheck = await checkPortBeforeNavigation(params.url, logPrefix);
1865
+ if (!portCheck.success) {
1866
+ results.push({
1867
+ step: i + 1,
1868
+ tool: cmd.tool,
1869
+ success: false,
1870
+ error: portCheck.error
1871
+ });
1872
+ break;
1873
+ }
1874
+ }
1875
+ // Replay cursor visual feedback
1876
+ if (cmd.tool === 'input') {
1877
+ if (params.action === 'click' && typeof params.x === 'number' && typeof params.y === 'number') {
1878
+ // Coordinate-based click - show cursor effect
1879
+ if (replayCursorCallbacks.onClickBefore) {
1880
+ await replayCursorCallbacks.onClickBefore(params.x, params.y, false);
1881
+ }
1882
+ }
1883
+ else if (params.action === 'press' && params.key) {
1884
+ // Key press - show key indicator
1885
+ if (replayCursorCallbacks.onKeyPress) {
1886
+ await replayCursorCallbacks.onKeyPress(params.key);
1887
+ }
1888
+ }
1889
+ }
1890
+ // Handle conditional command specially
1891
+ if (cmd.tool === 'conditional') {
1892
+ // Validate required parameters
1893
+ if (!params.if || typeof params.if !== 'string') {
1894
+ results.push({
1895
+ step: i + 1,
1896
+ tool: cmd.tool,
1897
+ success: false,
1898
+ error: 'Conditional command requires "if" parameter with a condition string. Expected format: {{selector:.class}}, {{url:contains:text}}, {{cookie:name}}, or {{localStorage:key}}'
1899
+ });
1900
+ break;
1901
+ }
1902
+ if (!params.then || typeof params.then !== 'string') {
1903
+ results.push({
1904
+ step: i + 1,
1905
+ tool: cmd.tool,
1906
+ success: false,
1907
+ error: 'Conditional command requires "then" parameter with the sequence name to execute when condition is met'
1908
+ });
1909
+ break;
1910
+ }
1911
+ const condResult = await executeConditionalFlow(params.if, params.then, stepCtx, commandRecorder,
1912
+ // Remaining, not the original: nesting must not extend the total.
1913
+ { stepTimeout, totalTimeout: Math.max(0, totalTimeout - (Date.now() - startTime)) },
1914
+ // The run's signal, so cancel reaches the nested sequence too.
1915
+ abortSignal);
1916
+ // Build the step result with substeps
1917
+ const stepResult = {
1918
+ step: i + 1,
1919
+ tool: cmd.tool,
1920
+ success: condResult.success,
1921
+ sequenceName: condResult.sequenceName,
1922
+ conditionMet: condResult.executed,
1923
+ substeps: condResult.substeps,
1924
+ error: condResult.error
1925
+ };
1926
+ results.push(stepResult);
1927
+ if (!condResult.success) {
1928
+ break;
1929
+ }
1930
+ const substepCount = condResult.substeps?.length || 0;
1931
+ debugLog(logPrefix, `Step ${i + 1} completed: conditional ${condResult.executed ? `ran ${substepCount} substeps` : 'skipped (condition not met)'}`);
1932
+ continue; // Skip the regular execution path
1933
+ }
1934
+ // Handle forEach the same way - a virtual step the executor runs itself.
1935
+ if (cmd.tool === 'forEach') {
1936
+ // `in` is checked for presence only, not for being a string: whole-string
1937
+ // {{var:}} interpolation has already turned it into the captured array.
1938
+ const missing = ['in', 'as', 'do'].filter(k => k === 'in' ? params[k] === undefined || params[k] === '' : typeof params[k] !== 'string' || !params[k]);
1939
+ if (missing.length > 0) {
1940
+ results.push({
1941
+ step: i + 1,
1942
+ tool: cmd.tool,
1943
+ success: false,
1944
+ error: `forEach requires ${missing.map(m => `"${m}"`).join(', ')}. Expected { in: '{{var:rows}}' | '{{selectorAll:CSS}}', as: 'row', do: '<sequence name>' }`
1945
+ });
1946
+ break;
1947
+ }
1948
+ const loopResult = await executeForEachFlow({ in: params.in, as: params.as, do: params.do, where: params.where, maxItems: params.maxItems }, stepCtx, commandRecorder,
1949
+ // Remaining, not the original: looping must not extend the total.
1950
+ { stepTimeout, totalTimeout: Math.max(0, totalTimeout - (Date.now() - startTime)) }, abortSignal);
1951
+ results.push({
1952
+ step: i + 1,
1953
+ tool: cmd.tool,
1954
+ success: loopResult.success,
1955
+ sequenceName: loopResult.sequenceName,
1956
+ itemsFound: loopResult.itemsFound,
1957
+ iterations: loopResult.iterations,
1958
+ substeps: loopResult.substeps,
1959
+ error: loopResult.error
1960
+ });
1961
+ if (!loopResult.success) {
1962
+ break;
1963
+ }
1964
+ debugLog(logPrefix, `Step ${i + 1} completed: forEach ran ${loopResult.iterations}/${loopResult.itemsFound} item(s) of "${loopResult.sequenceName}"`);
1965
+ continue; // Skip the regular execution path
1966
+ }
1967
+ // Capture pre-click state for validation
1968
+ let preClickState = null;
1969
+ const clickConfig = configManager.getClickValidationConfig();
1970
+ if (cmd.tool === 'input' && params.action === 'click' && stepConnection && clickConfig.enabled) {
1971
+ preClickState = await capturePreClickState(stepCtx);
1972
+ }
1973
+ // Execute with retry, raced against the per-step timeout so a hung tool
1974
+ // call fails its own step instead of hanging the whole run.
1975
+ //
1976
+ // The bound is min(stepTimeout, remaining totalTimeout), computed fresh
1977
+ // here (after any cmd.delay) so the delay doesn't inflate the budget.
1978
+ //
1979
+ // `wait` steps are exempt from stepTimeout: wait carries its own
1980
+ // documented timeoutMs bound (default 15000) and fails itself on expiry;
1981
+ // racing stepTimeout against it would silently override that parameter
1982
+ // for waits longer than 30s. They are still capped by remaining
1983
+ // totalTimeout as a backstop.
1984
+ //
1985
+ // Breakpoint pauses are NOT affected: input tools detect a pause and
1986
+ // return immediately (pausedAtBreakpoint / pausedDuringClick), so a
1987
+ // legitimate pause never blocks inside the tool call - it is handled by
1988
+ // checkIfPaused after the step. replay({action:'step'}) pauses between
1989
+ // steps, outside this race.
1990
+ const remainingTotal = Math.max(1, totalTimeout - (Date.now() - startTime));
1991
+ const boundedByTotal = cmd.tool === 'wait' || remainingTotal < stepTimeout;
1992
+ const stepBound = cmd.tool === 'wait' ? remainingTotal : Math.min(stepTimeout, remainingTotal);
1993
+ const execResult = await executeWithTimeout(executeCommandWithRetry(executeToolCall, cmd.tool, params, logPrefix, abortSignal), stepBound, getMessage('REPLAY_STEP_TIMEOUT', {
1994
+ step: i + 1,
1995
+ tool: cmd.tool,
1996
+ timeoutMs: stepBound,
1997
+ limitSource: boundedByTotal ? 'remaining totalTimeout' : 'stepTimeout',
1998
+ }));
1999
+ if (!execResult.success) {
2000
+ // A step that failed while the run signal is aborted is the CANCEL
2001
+ // surfacing (e.g. the wait handler throwing an abort mid-poll), not a
2002
+ // genuine failure: report the canonical abort message and skip
2003
+ // diagnostics - the user cancelled, don't interrogate a browser they
2004
+ // may already be tearing down.
2005
+ if (abortSignal?.aborted) {
2006
+ debugLog(logPrefix, `Replay aborted during step ${i + 1}`);
2007
+ results.push({
2008
+ step: i + 1,
2009
+ tool: cmd.tool,
2010
+ success: false,
2011
+ error: 'Replay aborted by user'
2012
+ });
2013
+ break;
2014
+ }
2015
+ const diagnostics = await gatherDiagnostics(stepCtx);
2016
+ results.push({
2017
+ step: i + 1,
2018
+ tool: cmd.tool,
2019
+ success: false,
2020
+ error: `${execResult.error}${diagnostics}`
2021
+ });
2022
+ break;
2023
+ }
2024
+ // Note a browser this step created, so the run can close what it opened
2025
+ // and leave what it borrowed. The reference is read from the response
2026
+ // rather than the params: `reused: true` means the reference already
2027
+ // existed and the browser is someone else's (issue #103).
2028
+ if (cmd.tool === 'launchChrome' && ctx.launchedConnections) {
2029
+ const launchMeta = execResult.result?._meta?.launchChrome;
2030
+ if (launchMeta?.reference && launchMeta.reused === false) {
2031
+ ctx.launchedConnections.add(launchMeta.reference);
2032
+ }
2033
+ }
2034
+ // Track breakpoints set by this sequence
2035
+ // Due to CDP line number handling (0-based vs 1-based) and resolution to nearest valid line,
2036
+ // we track the requested line and ±1 variants to handle edge cases
2037
+ if (cmd.tool === 'breakpoint' && params.action === 'set' && params.url) {
2038
+ const requestedLine = params.lineNumber;
2039
+ // Try to get the actual resolved line from the response
2040
+ // Format: "Breakpoint set at URL:LINE" or "CDP resolved to line LINE"
2041
+ const responseText = execResult.result?.content?.[0]?.text || '';
2042
+ const setAtMatch = responseText.match(/Breakpoint set at [^:]+:(\d+)/);
2043
+ const resolvedMatch = responseText.match(/CDP resolved to line (\d+)/);
2044
+ const reportedLine = resolvedMatch
2045
+ ? parseInt(resolvedMatch[1], 10)
2046
+ : setAtMatch
2047
+ ? parseInt(setAtMatch[1], 10)
2048
+ : requestedLine;
2049
+ // Track the reported line
2050
+ expectedBreakpoints.add(`${params.url}:${reportedLine}`);
2051
+ debugLog(logPrefix, `Tracking expected breakpoint: ${params.url}:${reportedLine}`);
2052
+ // Also track ±1 to handle 0-based/1-based conversion edge cases
2053
+ expectedBreakpoints.add(`${params.url}:${reportedLine - 1}`);
2054
+ expectedBreakpoints.add(`${params.url}:${reportedLine + 1}`);
2055
+ // Track requested line if different
2056
+ if (requestedLine !== reportedLine) {
2057
+ expectedBreakpoints.add(`${params.url}:${requestedLine}`);
2058
+ expectedBreakpoints.add(`${params.url}:${requestedLine - 1}`);
2059
+ expectedBreakpoints.add(`${params.url}:${requestedLine + 1}`);
2060
+ }
2061
+ debugLog(logPrefix, `Expected breakpoints for ${params.url}: ${[...expectedBreakpoints].filter(k => k.startsWith(params.url)).map(k => k.split(':').pop()).join(', ')}`);
2062
+ }
2063
+ // Post-step validation (before marking as success)
2064
+ if (cmd.tool === 'navigate' && stepConnection) {
2065
+ // Validate navigation succeeded
2066
+ const expectedUrl = params.action === 'goto' ? params.url : undefined;
2067
+ const navValidation = await validateNavigation(stepCtx, expectedUrl);
2068
+ if (!navValidation.success) {
2069
+ throw new Error(navValidation.error || 'Navigation failed');
2070
+ }
2071
+ }
2072
+ // Click validation (after successful execution)
2073
+ if (cmd.tool === 'input' && params.action === 'click' && stepConnection && preClickState && clickConfig.enabled) {
2074
+ const clickValidation = await validateClickAction(stepCtx, preClickState, execResult.result, clickConfig);
2075
+ // Log info messages (console activity)
2076
+ for (const infoMsg of clickValidation.info) {
2077
+ debugLog(logPrefix, `Click info: ${infoMsg}`);
2078
+ }
2079
+ // Log warnings
2080
+ for (const warn of clickValidation.warnings) {
2081
+ debugLog(logPrefix, `Click warning: ${warn}`);
2082
+ }
2083
+ // Handle errors - pause sequence for inspection/retry instead of failing
2084
+ if (!clickValidation.valid) {
2085
+ debugLog(logPrefix, `Click validation failed at step ${i + 1}, pausing for inspection`);
2086
+ // Mark this step as failed but allow retry
2087
+ results.push({
2088
+ step: i + 1,
2089
+ tool: cmd.tool,
2090
+ success: false,
2091
+ error: `Click validation: ${clickValidation.errors.join('; ')}`
2092
+ });
2093
+ return {
2094
+ results,
2095
+ totalCommands: commands.length,
2096
+ durationMs: Date.now() - startTime,
2097
+ pausedAtStep: i + 1,
2098
+ clickValidationFailure: {
2099
+ step: i + 1,
2100
+ selector: params.selector || 'unknown',
2101
+ errors: clickValidation.errors,
2102
+ warnings: clickValidation.warnings,
2103
+ info: clickValidation.info,
2104
+ }
2105
+ };
2106
+ }
2107
+ }
2108
+ // Capture a { saveAs } step's result into the run's variable store.
2109
+ // Before the step is marked successful: a saveAs that cannot be honoured
2110
+ // is a step failure (throw -> the catch below records it and stops the
2111
+ // run), not a silent no-op that would surface later as a confusing
2112
+ // "no variable named ..." interpolation error.
2113
+ if (params.saveAs) {
2114
+ const captured = captureVariable(cmd.tool, params, execResult.result);
2115
+ if (!captured.ok) {
2116
+ throw new Error(captured.error);
2117
+ }
2118
+ variableStore[params.saveAs] = captured.value;
2119
+ debugLog(logPrefix, `Captured variable "${params.saveAs}" from step ${i + 1} (${cmd.tool})`);
2120
+ }
2121
+ // Record command if enabled (preserve delay and comment)
2122
+ if (record) {
2123
+ commandRecorder.recordCommand(cmd.tool, params, {
2124
+ delay: cmd.delay,
2125
+ comment: cmd.comment
2126
+ });
2127
+ }
2128
+ results.push({ step: i + 1, tool: cmd.tool, success: true });
2129
+ debugLog(logPrefix, `Step ${i + 1} completed successfully`);
2130
+ // Check if we hit a breakpoint after this step (on the step's own connection)
2131
+ if (stepConnection) {
2132
+ const breakpointInfo = await checkIfPaused(stepCtx);
2133
+ if (breakpointInfo) {
2134
+ const breakpointKey = `${breakpointInfo.url}:${breakpointInfo.lineNumber}`;
2135
+ const isExpected = expectedBreakpoints.has(breakpointKey);
2136
+ debugLog(logPrefix, `Breakpoint hit at ${breakpointKey} (expected: ${isExpected})`);
2137
+ if (isExpected) {
2138
+ // Expected breakpoint from this sequence - continue execution
2139
+ debugLog(logPrefix, `Expected breakpoint hit, continuing sequence`);
2140
+ }
2141
+ else {
2142
+ // Unexpected breakpoint - stop and return
2143
+ return {
2144
+ results,
2145
+ totalCommands: commands.length,
2146
+ durationMs: Date.now() - startTime,
2147
+ breakpointHit: breakpointInfo
2148
+ };
2149
+ }
2150
+ }
2151
+ }
2152
+ // Post-step async operations (after marking success)
2153
+ if (cmd.tool === 'input' && params.action === 'type' && params.selector && stepConnection) {
2154
+ await validateTypedText(stepCtx, params.selector, params.text || '', params.append === true);
2155
+ }
2156
+ // Pre-fetch next element after navigation/click. The wait happens where the
2157
+ // NEXT step will run, so it follows that step's connection, not this one's.
2158
+ const isNavigationAction = cmd.tool === 'navigate' ||
2159
+ (cmd.tool === 'input' && params.action === 'click');
2160
+ if (isNavigationAction && i + 1 < commands.length) {
2161
+ const nextCmd = commands[i + 1];
2162
+ // Rebind the same way the step itself will be, or this pre-emptive wait
2163
+ // polls a recorded reference that may not exist in this session - which
2164
+ // costs the step its whole settle budget before being swallowed.
2165
+ const nextConnection = nextCmd.params.connectionReason
2166
+ ? mapConnection(nextCmd.params.connectionReason)
2167
+ : connectionReason;
2168
+ if (nextCmd.tool === 'input' && nextCmd.params.selector && nextConnection) {
2169
+ const nextCtx = nextConnection === connectionReason
2170
+ ? ctx
2171
+ : { ...ctx, connectionReason: nextConnection, variableStore };
2172
+ await waitForElement(nextCtx, nextCmd.params.selector);
2173
+ }
2174
+ }
2175
+ }
2176
+ catch (error) {
2177
+ // Same classification as the resolved-failure path above: an exception
2178
+ // thrown while the run signal is aborted is the cancel surfacing.
2179
+ if (abortSignal?.aborted) {
2180
+ debugLog(logPrefix, `Replay aborted during step ${i + 1}`);
2181
+ results.push({
2182
+ step: i + 1,
2183
+ tool: cmd.tool,
2184
+ success: false,
2185
+ error: 'Replay aborted by user'
2186
+ });
2187
+ break;
2188
+ }
2189
+ debugLog(logPrefix, `Error at step ${i + 1}: ${error.message}`);
2190
+ results.push({
2191
+ step: i + 1,
2192
+ tool: cmd.tool,
2193
+ success: false,
2194
+ error: error.message || 'Unknown error'
2195
+ });
2196
+ break;
2197
+ }
2198
+ }
2199
+ // The loop is over. Every path that reaches here is terminal for the MAIN
2200
+ // steps - success, a failed step, an abort, or the total timeout - EXCEPT a
2201
+ // clean stop at `endStep`, which is a stepTo pause with more steps to come.
2202
+ // The pause paths that return early above (breakpoint, click validation)
2203
+ // never reach here, which is what we want: the run is not over, so cleaning
2204
+ // up would destroy the state the user paused to look at.
2205
+ const stoppedShortOfEnd = targetEnd < commands.length;
2206
+ const anyFailed = results.some(r => !r.success);
2207
+ const isPaused = stoppedShortOfEnd && !anyFailed;
2208
+ const teardownOutcome = isPaused
2209
+ ? undefined
2210
+ : await runTeardown(sequence, {
2211
+ ctx,
2212
+ stepTimeout,
2213
+ teardownTimeout: options.teardownTimeout,
2214
+ overrideConnectionReason,
2215
+ variables,
2216
+ record,
2217
+ });
2218
+ return {
2219
+ results,
2220
+ totalCommands: commands.length,
2221
+ durationMs: Date.now() - startTime,
2222
+ ...(teardownOutcome ? { teardownResults: teardownOutcome.results, teardownFailed: teardownOutcome.failed } : {})
2223
+ };
2224
+ }
2225
+ /**
2226
+ * Run a sequence's `teardown` steps, if it has any.
2227
+ *
2228
+ * Three properties matter, and each one is a way this feature fails if it is
2229
+ * missed:
2230
+ *
2231
+ * - **Its own budget.** `teardownTimeout` is deliberately not drawn from the
2232
+ * run's `totalTimeout`. The commonest reason a run needs cleaning up after is
2233
+ * that it timed out, and sharing the budget would skip teardown exactly then.
2234
+ * - **No abort signal.** The run's signal is not passed down. `replay cancel`
2235
+ * must stop the work, not the cleanup - a cancelled run is precisely one that
2236
+ * has left something behind.
2237
+ * - **The variable store, shared.** Teardown reads `ctx.variableStore`, so it
2238
+ * can revoke whatever setup minted, even though the step that captured it may
2239
+ * have run long before the failure.
2240
+ *
2241
+ * Teardown is always best-effort. A killed process takes any pending teardown
2242
+ * with it, so it reduces accumulation and cannot guarantee a clean world -
2243
+ * assertions that depend on nothing being left over are still wrong.
2244
+ */
2245
+ async function runTeardown(sequence, opts) {
2246
+ const teardown = sequence.teardown;
2247
+ if (!teardown || teardown.length === 0)
2248
+ return undefined;
2249
+ const logPrefix = opts.ctx.logPrefix ?? 'executor';
2250
+ await debugLog(logPrefix, `Running ${teardown.length} teardown step(s) for "${sequence.name}"`);
2251
+ // `teardown: undefined` on the synthetic sequence: without it the teardown
2252
+ // run would reach this same code and run the teardown again, forever.
2253
+ const result = await executeSteps({
2254
+ sequence: { ...sequence, commands: teardown, teardown: undefined },
2255
+ startStep: 0,
2256
+ ctx: opts.ctx,
2257
+ stepTimeout: opts.stepTimeout,
2258
+ totalTimeout: opts.teardownTimeout ?? DEFAULT_TEARDOWN_TIMEOUT,
2259
+ ...(opts.overrideConnectionReason ? { overrideConnectionReason: opts.overrideConnectionReason } : {}),
2260
+ ...(opts.variables ? { variables: opts.variables } : {}),
2261
+ ...(opts.record !== undefined ? { record: opts.record } : {}),
2262
+ });
2263
+ const failed = result.results.some(r => !r.success);
2264
+ if (failed) {
2265
+ await debugLog(logPrefix, `Teardown for "${sequence.name}" had failing steps - reported alongside, not folded into the run's verdict`);
2266
+ }
2267
+ return { results: result.results, failed };
2268
+ }
2269
+ /**
2270
+ * Execute a sequence with pause support (stepTo)
2271
+ */
2272
+ export async function executeSequenceWithPause(options) {
2273
+ const { sequence, ctx, stepTo } = options;
2274
+ const { commandRecorder, connectionReason } = ctx;
2275
+ const result = await executeSteps({
2276
+ ...options,
2277
+ endStep: stepTo
2278
+ });
2279
+ // If we stopped at stepTo and didn't fail, set up paused state
2280
+ if (stepTo !== undefined && result.results.length > 0) {
2281
+ const lastResult = result.results[result.results.length - 1];
2282
+ if (lastResult.success && lastResult.step >= stepTo) {
2283
+ const activeState = {
2284
+ sequenceId: sequence.id,
2285
+ sequenceName: sequence.name,
2286
+ connectionReason: connectionReason || '',
2287
+ currentStep: lastResult.step,
2288
+ totalSteps: sequence.commands.length,
2289
+ pausedAt: Date.now(),
2290
+ historyIndexAtPause: commandRecorder.getCurrentHistoryIndex(),
2291
+ capturedVariables: ctx.variableStore,
2292
+ runTimestamp: ctx.runTimestamp,
2293
+ // step/finish must resolve per-step connections the way this run did
2294
+ ...(ctx.connectionMap && { connectionMap: ctx.connectionMap }),
2295
+ };
2296
+ result.pausedAtStep = lastResult.step;
2297
+ result.activeSequenceState = activeState;
2298
+ }
2299
+ }
2300
+ return result;
2301
+ }
2302
+ /**
2303
+ * Get current debug state (breakpoints, pause status)
2304
+ */
2305
+ export async function getDebugState(ctx) {
2306
+ const { executeToolCall, connectionReason, logPrefix = 'executor' } = ctx;
2307
+ if (!connectionReason)
2308
+ return null;
2309
+ try {
2310
+ const breakpointResult = await executeToolCall('breakpoint', {
2311
+ action: 'list',
2312
+ connectionReason
2313
+ });
2314
+ const breakpointText = breakpointResult?.content?.[0]?.text || '';
2315
+ const totalMatch = breakpointText.match(/\*\*Total:\*\*\s*(\d+)/);
2316
+ const breakpointCount = totalMatch ? parseInt(totalMatch[1], 10) : 0;
2317
+ // "Not paused" is the ANSWER, not a failure - but inspect.getCallStack
2318
+ // reports it as an error response, which the live executeToolCall rethrows.
2319
+ // Letting it reach the outer catch threw away the breakpoint count already
2320
+ // read and reported no debug state at all for the ordinary unpaused run,
2321
+ // which is the run this exists to describe.
2322
+ let callStackText = '';
2323
+ try {
2324
+ const callStackResult = await executeToolCall('inspect', {
2325
+ action: 'getCallStack',
2326
+ connectionReason
2327
+ });
2328
+ callStackText = callStackResult?.content?.[0]?.text || '';
2329
+ }
2330
+ catch (callStackError) {
2331
+ if (callStackError?.response?._errorId !== 'NOT_PAUSED')
2332
+ throw callStackError;
2333
+ }
2334
+ const isPaused = callStackText.includes('callFrameId');
2335
+ let pauseLocation;
2336
+ if (isPaused) {
2337
+ const pauseLocationMatch = callStackText.match(/Paused at:\s*([^\n]+)/);
2338
+ pauseLocation = pauseLocationMatch ? pauseLocationMatch[1] : 'unknown location';
2339
+ }
2340
+ return { isPaused, pauseLocation, breakpointCount };
2341
+ }
2342
+ catch (err) {
2343
+ debugLog(logPrefix, `Could not get debug state: ${err.message}`);
2344
+ return null;
2345
+ }
2346
+ }
2347
+ //# sourceMappingURL=replay-executor.js.map