@tea-agent/loop-agent 0.43.0-next.17 → 0.43.0-next.19

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 (236) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +68 -2309
  3. package/README.md +7 -0
  4. package/dist/application/dag/args.js +8 -0
  5. package/dist/application/task-lifecycle/advance.js +38 -7
  6. package/dist/build-stamp.json +3 -3
  7. package/dist/cli/command-definitions.js +2 -0
  8. package/dist/cli/program.js +3 -1
  9. package/dist/cli/update/init-surface-notifier.js +2 -0
  10. package/dist/cli/update/policy.js +2 -0
  11. package/dist/commands/cursor-prompt.js +2 -0
  12. package/dist/commands/dag-approve.js +2 -0
  13. package/dist/commands/dag-final-verification.js +2 -0
  14. package/dist/commands/dag-init-hybrid.js +2 -0
  15. package/dist/commands/dag-reconcile-tasks.js +2 -0
  16. package/dist/commands/dag-reject.js +2 -0
  17. package/dist/commands/dag-report.js +2 -0
  18. package/dist/commands/dag-rerun.js +2 -0
  19. package/dist/commands/dag-resume.js +2 -0
  20. package/dist/commands/dag-workflow-compile.js +2 -0
  21. package/dist/commands/dag-workflow-plan.js +2 -0
  22. package/dist/commands/dag-workflow-validate.js +2 -0
  23. package/dist/commands/delegate.js +2 -0
  24. package/dist/commands/examples.js +2 -0
  25. package/dist/commands/import-prd.js +2 -0
  26. package/dist/commands/init-model-catalog.js +2 -0
  27. package/dist/commands/init.js +19 -13
  28. package/dist/commands/instructions.js +2 -0
  29. package/dist/commands/knowledge.js +2 -0
  30. package/dist/commands/loop-benchmark.js +2 -0
  31. package/dist/commands/pi-prompt.js +2 -0
  32. package/dist/commands/pi-reuse-benchmark.js +2 -0
  33. package/dist/commands/promote-run.js +2 -0
  34. package/dist/commands/stats.js +1 -1
  35. package/dist/commands/study-init.js +2 -0
  36. package/dist/commands/task-advance.js +34 -0
  37. package/dist/commands/task-source-prepare.js +2 -0
  38. package/dist/commands/worktree-create.js +2 -0
  39. package/dist/commands/worktree-remove.js +2 -0
  40. package/dist/executors/dag-pi-executor.js +1640 -194
  41. package/dist/executors/pi-executor.js +85 -3
  42. package/dist/executors/pi-sdk-executor.js +18 -0
  43. package/dist/executors/shell-executor.js +92 -7
  44. package/dist/executors/shell-verification.js +3 -0
  45. package/dist/governance/exec-plans.js +6 -3
  46. package/dist/infrastructure/console/app-data.js +6 -0
  47. package/dist/shared/artifacts-core.js +2 -0
  48. package/dist/shared/backend-dogfood-preflight.js +47 -0
  49. package/dist/shared/dag-failure-category.js +3 -0
  50. package/dist/shared/git-progress.js +2 -0
  51. package/dist/shared/one-shot-prompt-args.js +2 -0
  52. package/dist/shared/operator/capabilities.js +180 -0
  53. package/dist/shared/package-metadata.js +6 -4
  54. package/dist/shared/pi-context-pressure/sift-bridge.js +1 -1
  55. package/dist/shared/reference-context.js +2 -0
  56. package/dist/task/config-types.js +29 -0
  57. package/dist/task/contract/project.js +9 -0
  58. package/dist/task/contract/recover.js +6 -0
  59. package/dist/task/contract/schema.js +17 -0
  60. package/dist/task/source-prepare/artifact-meta.js +9 -2
  61. package/dist/task/source-prepare/build-draft.js +60 -0
  62. package/dist/task/source-prepare/fragment-inventory.js +4 -1
  63. package/dist/task/source-prepare/parse-intent.js +36 -11
  64. package/dist/task/task-demand-routing.js +5 -2
  65. package/dist/worker/console/chat/browser-automation.js +468 -308
  66. package/dist/worker/console/chat/browser-routes.js +175 -57
  67. package/dist/worker/console/chat/explore-tools.js +5 -2
  68. package/dist/worker/console/chat/pi-mode-loop-isolation.js +155 -0
  69. package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +115 -20
  70. package/dist/worker/console/chat/pi-runtime/custom-tools/operator-tools.js +24 -16
  71. package/dist/worker/console/chat/pi-runtime/custom-tools/scheduled-goal-tools.js +22 -0
  72. package/dist/worker/console/chat/pi-runtime/progressive-tools.js +195 -0
  73. package/dist/worker/console/chat/pi-runtime.js +95 -28
  74. package/dist/worker/console/chat/routes.js +20 -1
  75. package/dist/worker/console/chat/scheduled-goal-booking.js +59 -0
  76. package/dist/worker/console/chat/scheduled-goal-delivery.js +27 -0
  77. package/dist/worker/console/chat/scheduled-goal-request.js +190 -0
  78. package/dist/worker/console/chat/sdd-data-alignment.js +1 -1
  79. package/dist/worker/console/chat/session-mode.js +8 -4
  80. package/dist/worker/console/chat/session-store.js +42 -9
  81. package/dist/worker/console/chat/tool-preview.js +115 -0
  82. package/dist/worker/console/chat/tools.js +7 -1
  83. package/dist/worker/console/chat/turn-order.js +13 -0
  84. package/dist/worker/console/chat/turn-process.js +32 -30
  85. package/dist/worker/console/operator-actions.js +50 -0
  86. package/dist/worker/console/prd-intake-bridge.js +54 -1
  87. package/dist/worker/console/scheduled-goal-host.js +98 -0
  88. package/dist/worker/console/scheduled-goal-operation-adapter.js +127 -0
  89. package/dist/worker/console/server.js +15 -0
  90. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4NXyedy.js → abnfDiagram-N423BO3Z-8-j6y-sd.js} +1 -1
  91. package/dist/worker/console/static/assets/{arc-NZ3qXvBI.js → arc-eoQiMvuk.js} +1 -1
  92. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-COZSRvml.js → architectureDiagram-T3A2C74G-C4A3uMcI.js} +1 -1
  93. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-Ck0Gxi9C.js → blockDiagram-VBNYF7ZC-D4zD2F-Q.js} +1 -1
  94. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BX5HQ4Ox.js → c4Diagram-5PPSVZJV-j1RkJziL.js} +1 -1
  95. package/dist/worker/console/static/assets/channel-ChE7y-cx.js +1 -0
  96. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CoviemSL.js → chunk-2GRJ4B5K-YBHmhik1.js} +1 -1
  97. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DbOdOkbZ.js → chunk-2Q5K7J3B-COfWyo9P.js} +1 -1
  98. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BsG2pmdQ.js → chunk-5RXB4S5H-I99OUkHY.js} +1 -1
  99. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-DGdvrNm8.js → chunk-5VM5RSS4-XKxoJNJ4.js} +1 -1
  100. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-BAy7Yd6y.js → chunk-6Q2QTUOP-DLT_cYx1.js} +1 -1
  101. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D9UA7hUb.js → chunk-GF5L2VYU-CxcKZ9mV.js} +1 -1
  102. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-DPz7SZ8V.js → chunk-JWPE2WC7-V1EqXdjY.js} +1 -1
  103. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-BmuEOIh7.js → chunk-KBJHAD2P-DebTtdFp.js} +1 -1
  104. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CIhBWnd8.js → chunk-RYQCIY6F-B-ivNRDf.js} +1 -1
  105. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CScR6mDD.js → chunk-XXDRQBXY-DC_Ds11b.js} +1 -1
  106. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C01TCf2X.js +1 -0
  107. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C01TCf2X.js +1 -0
  108. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BjpfWaUr.js → cose-bilkent-JH36ORCC-sWEqKwIb.js} +1 -1
  109. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BFQohJgT.js → cynefin-VYW2F7L2-BXa_dcu4.js} +1 -1
  110. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-D3gzRJnS.js → cynefinDiagram-MW4NZA55-C-Mle84F.js} +1 -1
  111. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-5eojkhoA.js → dagre-VZM6K2ZE-CXPDBITe.js} +1 -1
  112. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CpD8LWu_.js → diagram-7IWD3JNH-CC-WJQfa.js} +1 -1
  113. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B8_4qJ2a.js → diagram-B4RE2ZJO-CDAV5Vs4.js} +1 -1
  114. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-HOsXf0eu.js → diagram-LBJQPF4R-CmFiAcNz.js} +1 -1
  115. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BqwRwiWP.js → diagram-Q27KOJAE-DPBZHuyn.js} +1 -1
  116. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BRVCNZe1.js → diagram-UB23O5K3-jUlm_Ds3.js} +1 -1
  117. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Cvb1TgGh.js → ebnfDiagram-BXEA7PRR-DWhQ3mfY.js} +1 -1
  118. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DS0O0qG9.js → erDiagram-JOGREHBK-Tr2gMqet.js} +1 -1
  119. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BHIRvRr3.js → flowDiagram-UKHOOZJN-DJjVQHPA.js} +1 -1
  120. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DcCJ1JWx.js → ganttDiagram-PKOTCBZU-D74dQ4u0.js} +1 -1
  121. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-B9ia5ony.js → gitGraphDiagram-DS77QQ5N-DwW0tZ0X.js} +1 -1
  122. package/dist/worker/console/static/assets/index-BWkIfcrK.css +1 -0
  123. package/dist/worker/console/static/assets/{index-wXlQohNs.js → index-Cdkvw_H6.js} +97 -97
  124. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-3ogpgegn.js → infoDiagram-6WML65LV-ILCbxJyb.js} +1 -1
  125. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-4tgpQ5SL.js → ishikawaDiagram-WSZJBQD7-DeuWBQ89.js} +1 -1
  126. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DNfgHqFx.js → journeyDiagram-NVQOT4AX-CF5ih8Fk.js} +1 -1
  127. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Dz7ngC8z.js → kanban-definition-27J2QSJJ-C7yOSuRO.js} +1 -1
  128. package/dist/worker/console/static/assets/{linear-DI7Jv62E.js → linear-BDZ9riWi.js} +1 -1
  129. package/dist/worker/console/static/assets/{mermaid.core-CQA4Nzmo.js → mermaid.core-7pKqYtpZ.js} +5 -5
  130. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CtsMc8Mf.js → mindmap-definition-FAOFIHXS-LeJDybSU.js} +1 -1
  131. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-ByppE9yH.js → pegDiagram-VL7TDLO6-BJvT3pMD.js} +1 -1
  132. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-B5PtgFgt.js → pieDiagram-7S7Q4E2Y-_rGqpMan.js} +1 -1
  133. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Cq4p0k2K.js → quadrantDiagram-CIZ2JOQS-DPcSv5aA.js} +1 -1
  134. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-DjfkyljN.js → railroadDiagram-AXF67PYL-Drxx4hkJ.js} +1 -1
  135. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DzynNRSl.js → requirementDiagram-LRYGKXZP-BCTUdU4z.js} +1 -1
  136. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Cj7TCpTJ.js → sankeyDiagram-W5VNT64P-B6wzbZmB.js} +1 -1
  137. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-C73C6ykA.js → sequenceDiagram-SI44F4Z6-BGt8d3QQ.js} +1 -1
  138. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-BiNLgN2x.js → sizeCapture-X5ZJPWSS-7nlhJKo7.js} +1 -1
  139. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-UwvjS64-.js → stateDiagram-OKZ733FA-Cv2stqMA.js} +1 -1
  140. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DC7V5vcq.js +1 -0
  141. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-CC7DH98r.js → swimlanes-SLNWSIFB-qDAo4Yc1.js} +2 -2
  142. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bwy4QUTO.js +8 -0
  143. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-LSBXTR70.js → timeline-definition-Z64GVDOM-CKbDNKTr.js} +1 -1
  144. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BAuxWwGP.js → vennDiagram-T6HMQDX7-DI-9EHic.js} +1 -1
  145. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y--E5_gjZx.js → wardleyDiagram-T6FBY63Y-BrzzRDpX.js} +1 -1
  146. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-dJWNmyzF.js → xychartDiagram-ELKLHX3M-BclH5hGh.js} +1 -1
  147. package/dist/worker/console/static/index.html +2 -2
  148. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +41 -11
  149. package/dist/worker/console/static-src/operator-chat/compaction-message.js +3 -17
  150. package/dist/worker/console/static-src/operator-chat/slash-palette-nav.js +15 -5
  151. package/dist/worker/console/static-src/operator-chat/timeline-merge.js +29 -0
  152. package/dist/worker/console/static-src/operator-chat/useChatThread.js +2 -5
  153. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +2 -0
  154. package/dist/worker/console/static-src/prd-file-import.js +3 -1
  155. package/dist/worker/console/workspace-context.js +17 -1
  156. package/dist/worker/delivery/git-transaction.js +2 -0
  157. package/dist/worker/feature/fullstack-validate.js +2 -0
  158. package/dist/worker/loop-agent/loop-agent-client.js +8 -2
  159. package/dist/worker/materialize/harness-task-materializer.js +4 -1
  160. package/dist/worker/metrics/projector.js +1 -1
  161. package/dist/worker/observability/event-store.js +4 -1
  162. package/dist/worker/observability/read-model.js +4 -1
  163. package/dist/worker/observe/node-transparency.js +23 -1
  164. package/dist/worker/observe/routes.js +4 -0
  165. package/dist/worker/observe/static/operator-chrome.js +3 -1
  166. package/dist/worker/outcomes/declared-artifacts.js +2 -0
  167. package/dist/worker/preflight.js +3 -0
  168. package/dist/worker/scheduler/scheduled-goal-dispatch.js +117 -0
  169. package/dist/worker/scheduler/scheduled-goal-evidence.js +167 -0
  170. package/dist/worker/scheduler/scheduled-goal-recovery.js +62 -0
  171. package/dist/worker/scheduler/scheduled-goal-store.js +699 -0
  172. package/dist/worker/scheduler/scheduled-goal-supervisor.js +132 -0
  173. package/dist/worker/scheduler/scheduled-goal-time.js +102 -0
  174. package/dist/worker/scheduler/scheduled-goal-types.js +95 -0
  175. package/dist/worker/task-spec/validate.js +4 -1
  176. package/dist/workflows/dag/backend-test-markdown-workflow.js +6 -24
  177. package/dist/workflows/dag/backend-test-plan-protocol.js +82 -5
  178. package/dist/workflows/dag/dag-retry-schema.js +11 -0
  179. package/dist/workflows/dag/decision-envelope.js +2 -0
  180. package/dist/workflows/dag/dynamic-runtime/shared.js +2 -2
  181. package/dist/workflows/dag/frontend-closeout.js +4 -2
  182. package/dist/workflows/dag/frontend-committed-facts.js +461 -0
  183. package/dist/workflows/dag/frontend-durable-tools.js +15 -3
  184. package/dist/workflows/dag/frontend-implementation-contract.js +365 -8
  185. package/dist/workflows/dag/frontend-plan-canary.js +53 -0
  186. package/dist/workflows/dag/frontend-plan-decision-contract.js +803 -0
  187. package/dist/workflows/dag/frontend-plan-render.js +0 -2
  188. package/dist/workflows/dag/frontend-prewrite-gate.js +3 -3
  189. package/dist/workflows/dag/frontend-provider-capability-matrix.js +8 -61
  190. package/dist/workflows/dag/frontend-recovery-run.js +57 -0
  191. package/dist/workflows/dag/frontend-review-context.js +43 -70
  192. package/dist/workflows/dag/frontend-risk.js +92 -10
  193. package/dist/workflows/dag/frontend-session-budget.js +117 -3
  194. package/dist/workflows/dag/frontend-shape.js +11 -55
  195. package/dist/workflows/dag/frontend-test-execution-evidence.js +35 -10
  196. package/dist/workflows/dag/frontend-typed-event-store.js +32 -25
  197. package/dist/workflows/dag/frontend-verification-trace.js +25 -12
  198. package/dist/workflows/dag/frontend-writer-admission.js +3 -47
  199. package/dist/workflows/dag/frontend-writer-status.js +0 -23
  200. package/dist/workflows/dag/init-hybrid.js +202 -111
  201. package/dist/workflows/dag/interrupt-request.js +2 -0
  202. package/dist/workflows/dag/lifecycle.js +11 -2
  203. package/dist/workflows/dag/node-execution.js +50 -13
  204. package/dist/workflows/dag/reconcile-run.js +2 -0
  205. package/dist/workflows/dag/repair-artifact.js +3 -1
  206. package/dist/workflows/dag/report.js +2 -0
  207. package/dist/workflows/dag/rerun-plan.js +10 -0
  208. package/dist/workflows/dag/rerun-task.js +79 -1
  209. package/dist/workflows/dag/retry-policy.js +18 -0
  210. package/dist/workflows/dag/scheduler.js +2 -4
  211. package/dist/workflows/dag/types.js +44 -6
  212. package/dist/workflows/dag/validate.js +4 -0
  213. package/docs/README.md +1 -0
  214. package/docs/governance/README.md +1 -0
  215. package/docs/init-surface.manifest.json +1 -0
  216. package/docs/operations/README.md +2 -0
  217. package/docs/templates/README.md +1 -1
  218. package/docs/templates/agent-dag.schema.json +2 -2
  219. package/docs/templates/backend-test-dag.json +14 -10
  220. package/docs/templates/frontend-implementation-contract.schema.json +0 -7
  221. package/docs/templates/frontend-implementation-dag.json +8 -9
  222. package/package.json +8 -3
  223. package/skills/frontend-bounded-implement/SKILL.md +3 -4
  224. package/skills/frontend-design-review/SKILL.md +7 -12
  225. package/skills/frontend-design-review/references/review-checklist.md +7 -7
  226. package/skills/frontend-plan/SKILL.md +8 -18
  227. package/skills/frontend-plan/references/decision-contract.md +19 -26
  228. package/skills/frontend-review/SKILL.md +21 -25
  229. package/skills/frontend-review/references/review-findings.md +48 -24
  230. package/dist/worker/console/static/assets/channel-BxdTlb_5.js +0 -1
  231. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-_ByA1Nf8.js +0 -1
  232. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-_ByA1Nf8.js +0 -1
  233. package/dist/worker/console/static/assets/index-B9goN3S5.css +0 -1
  234. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-nLgMZ0D3.js +0 -1
  235. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BcYqTady.js +0 -8
  236. package/dist/workflows/dag/frontend-shadow-dual-write.js +0 -975
@@ -1,363 +1,523 @@
1
1
  /**
2
- * Server-side implementation for the model-callable sidebar browser tools.
3
- *
4
- * Navigation is delivered to the visible BrowserPanel through a small pending
5
- * command queue. Page inspection uses the same http(s) URL loaded by that
6
- * panel and parses the server-visible HTML. Static GET forms are submitted as
7
- * real navigations; dynamic scripts and POST forms are intentionally refused.
2
+ * Workspace-scoped Playwright browser used by both Operator Chat tools and
3
+ * the Browser panel. The browser profile lives outside the repository and is
4
+ * reused across Console restarts, so login state and downloads remain
5
+ * isolated from the user's normal browser without contaminating the repo.
8
6
  */
7
+ import { randomUUID } from "node:crypto";
8
+ import { mkdir } from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { chromium, } from "playwright-core";
9
11
  import { normalizeBrowserUrl } from "./browser-policy.js";
12
+ export const BROWSER_VIEWPORT_WIDTH = 1280;
13
+ export const BROWSER_VIEWPORT_HEIGHT = 900;
14
+ const SEARCH_INPUT_SELECTORS = [
15
+ "input[name='wd']",
16
+ "input[name='q']",
17
+ "input[name='query']",
18
+ "input[type='search']",
19
+ "input[type='text']",
20
+ ];
21
+ const SEARCH_SUBMIT_SELECTORS = [
22
+ "button[type='submit']",
23
+ "input[type='submit']",
24
+ "button:not([type])",
25
+ ];
26
+ const DEFAULT_SEARCH_ENGINE_URL = "https://www.baidu.com/";
27
+ const SEARCH_TYPING_DELAY_MS = 45;
28
+ const DEFAULT_ACTION_TIMEOUT_MS = 15_000;
29
+ const MAX_SNAPSHOT_CHARS = 24_000;
30
+ const MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024;
10
31
  export class BrowserAutomationService {
11
- fetchImpl;
12
- pending = [];
13
- sequence = 0;
14
- constructor(fetchImpl = fetch) {
15
- this.fetchImpl = fetchImpl;
32
+ options;
33
+ browserPromise;
34
+ activePage;
35
+ tabs = new Map();
36
+ nextTabId = 1;
37
+ disposed = false;
38
+ dialogQueue = [];
39
+ downloadQueue = [];
40
+ constructor(options) {
41
+ this.options = options;
16
42
  }
17
- async navigate(rawUrl, options) {
43
+ async navigate(rawUrl) {
18
44
  const normalized = normalizeBrowserUrl(rawUrl);
19
45
  if (normalized.kind !== "ok") {
20
46
  return {
21
47
  ok: false,
22
- error: normalized.kind === "invalid"
23
- ? "invalid browser url"
24
- : "browser urls must use http(s)",
48
+ code: normalized.kind === "blocked" ? "BAD_SCHEME" : "INVALID_URL",
49
+ error: normalized.kind === "blocked"
50
+ ? "browser urls must use http(s)"
51
+ : "invalid browser url",
25
52
  };
26
53
  }
27
- let snapshot;
28
- const snapshotSource = rewriteBaiduSearchUrlForSnapshot(normalized.url);
29
- if (options?.includeSnapshot) {
30
- try {
31
- snapshot = await this.inspect(snapshotSource);
32
- }
33
- catch {
34
- // Navigation itself can still succeed when the panel cannot
35
- // fetch the page server-side; the iframe remains the fallback.
36
- }
37
- }
38
- const displayUrl = snapshotSource === normalized.url ? normalized.url : snapshotSource;
39
- const command = {
40
- kind: "navigate",
41
- id: this.nextId(),
42
- url: displayUrl,
43
- ...(snapshot ? { snapshot } : {}),
44
- createdAt: new Date().toISOString(),
45
- };
46
- this.pending = [command];
47
- return { ok: true, command };
48
- }
49
- reload() {
50
- const command = {
51
- kind: "reload",
52
- id: this.nextId(),
53
- createdAt: new Date().toISOString(),
54
- };
55
- this.pending = [command];
56
- return { ok: true, command };
57
- }
58
- take() {
59
- const commands = this.pending;
60
- this.pending = [];
61
- return commands;
62
- }
63
- async inspect(rawUrl) {
64
- const normalized = normalizeBrowserUrl(rawUrl);
65
- if (normalized.kind !== "ok")
66
- throw new BrowserAutomationError("INVALID_URL", "invalid browser url");
67
- const controller = new AbortController();
68
- const timer = setTimeout(() => controller.abort(), 12_000);
69
54
  try {
70
- const response = await this.fetchImpl(normalized.url, {
71
- redirect: "follow",
72
- signal: controller.signal,
73
- headers: { accept: "text/html,application/xhtml+xml" },
74
- });
75
- const contentType = response.headers.get("content-type") ?? "";
76
- if (!contentType.includes("html")) {
77
- throw new BrowserAutomationError("NOT_HTML", "the browser page did not return an HTML document");
78
- }
79
- const html = await response.text();
80
- if (html.length > 3_000_000) {
81
- throw new BrowserAutomationError("PAGE_TOO_LARGE", "page exceeds 3MB");
82
- }
83
- return parseBrowserPage(html, response.url || normalized.url);
55
+ const page = await this.page();
56
+ await page.goto(normalized.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
57
+ const snapshot = await this.snapshot({ screenshot: false });
58
+ return { ok: true, page: snapshot };
84
59
  }
85
60
  catch (error) {
86
- if (error instanceof BrowserAutomationError)
87
- throw error;
88
- throw new BrowserAutomationError("FETCH_FAILED", "could not load the page", {
89
- cause: error,
90
- });
61
+ return { ok: false, code: "NAVIGATION_FAILED", error: errorMessage(error) };
91
62
  }
92
- finally {
93
- clearTimeout(timer);
63
+ }
64
+ async reload() {
65
+ const page = await this.page();
66
+ await page.reload({ waitUntil: "domcontentloaded", timeout: 30_000 });
67
+ return this.snapshot({ screenshot: false });
68
+ }
69
+ async goBack() {
70
+ const page = await this.page();
71
+ await page.goBack({ waitUntil: "domcontentloaded", timeout: 30_000 });
72
+ return this.snapshot({ screenshot: false });
73
+ }
74
+ async goForward() {
75
+ const page = await this.page();
76
+ await page.goForward({ waitUntil: "domcontentloaded", timeout: 30_000 });
77
+ return this.snapshot({ screenshot: false });
78
+ }
79
+ async snapshot(options) {
80
+ const page = await this.page();
81
+ const dialog = this.dialogFor(page);
82
+ if (dialog) {
83
+ return {
84
+ url: page.url(),
85
+ title: "浏览器弹窗",
86
+ tabs: await this.tabSummaries(),
87
+ dialog,
88
+ ariaSnapshot: "",
89
+ ...(options?.screenshot === false
90
+ ? {}
91
+ : { screenshot: await this.captureScreenshot(page) }),
92
+ };
94
93
  }
94
+ const [title, aria] = await Promise.all([
95
+ page.title(),
96
+ page.ariaSnapshot({ mode: "ai", timeout: 10_000 }),
97
+ ]);
98
+ const download = await this.nextDownloadResult();
99
+ const screenshot = options?.screenshot === false
100
+ ? undefined
101
+ : await this.captureScreenshot(page);
102
+ return {
103
+ url: page.url(),
104
+ title,
105
+ tabs: await this.tabSummaries(),
106
+ dialog,
107
+ ariaSnapshot: truncateText(aria, MAX_SNAPSHOT_CHARS),
108
+ ...(screenshot ? { screenshot } : {}),
109
+ ...(download ? { download } : {}),
110
+ };
111
+ }
112
+ /**
113
+ * Lightweight state for iframe-driven UI. Avoids aria capture and PNG
114
+ * encoding on every panel poll.
115
+ */
116
+ async state() {
117
+ if (!this.browserPromise && !this.disposed)
118
+ return emptyBrowserState();
119
+ const page = await this.page();
120
+ const state = {
121
+ running: true,
122
+ url: page.url(),
123
+ title: await page.title().catch(() => ""),
124
+ tabs: await this.tabSummaries(),
125
+ dialog: this.dialogFor(page),
126
+ ariaSnapshot: "",
127
+ };
128
+ return state;
95
129
  }
96
130
  async search(rawQuery, rawUrl) {
97
131
  const query = rawQuery.trim();
98
132
  if (!query)
99
133
  throw new BrowserAutomationError("EMPTY_QUERY", "search query is empty");
100
- let target = rawUrl?.trim() ? rawUrl.trim() : "https://m.baidu.com/s?wd=";
134
+ const target = rawUrl?.trim() || DEFAULT_SEARCH_ENGINE_URL;
101
135
  const normalized = normalizeBrowserUrl(target);
102
136
  if (normalized.kind !== "ok") {
103
137
  throw new BrowserAutomationError("INVALID_URL", "search engine url must use http(s)");
104
138
  }
105
- const url = new URL(normalized.url);
106
- url.searchParams.set(url.searchParams.has("q") ? "q" : "wd", query);
107
- const result = await this.navigate(url.href, { includeSnapshot: true });
108
- if (!result.ok)
109
- throw new BrowserAutomationError("INVALID_URL", result.error);
110
- return result.command.kind === "navigate" && result.command.snapshot
111
- ? result.command.snapshot
112
- : await this.inspect(url.href);
113
- }
114
- async act(rawUrl, request) {
115
- const snapshot = await this.inspect(rawUrl);
116
- const element = selectBrowserElement(snapshot, request);
117
- if (!element) {
118
- throw new BrowserAutomationError("ELEMENT_NOT_FOUND", "selector did not match a clickable element or form field");
139
+ const page = await this.page();
140
+ await page.bringToFront();
141
+ await page.goto(normalized.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
142
+ const input = await findSearchInput(page);
143
+ if (!input) {
144
+ throw new BrowserAutomationError("SEARCH_INPUT_NOT_FOUND", "the search page did not expose a recognizable search input");
145
+ }
146
+ await input.fill("", { timeout: DEFAULT_ACTION_TIMEOUT_MS });
147
+ await input.pressSequentially(query, {
148
+ delay: SEARCH_TYPING_DELAY_MS,
149
+ timeout: DEFAULT_ACTION_TIMEOUT_MS,
150
+ });
151
+ await Promise.all([
152
+ page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 30_000 }),
153
+ submitSearchForm(page, input),
154
+ ]);
155
+ return this.snapshot({ screenshot: false });
156
+ }
157
+ async act(request) {
158
+ const page = await this.page();
159
+ const ref = request.ref.trim();
160
+ if (!/^(f\d+)?e\d+$/.test(ref)) {
161
+ throw new BrowserAutomationError("INVALID_REF", "element ref must come from a fresh browser snapshot");
119
162
  }
120
- if (request.type === "fill") {
121
- if (element.tag !== "input" && element.tag !== "textarea") {
122
- throw new BrowserAutomationError("NOT_FILLABLE", "matched element is not fillable");
163
+ const locator = page.locator(`aria-ref=${ref}`);
164
+ try {
165
+ if (request.type === "click") {
166
+ await locator.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
167
+ }
168
+ else if (request.type === "fill") {
169
+ await locator.fill(request.value, { timeout: DEFAULT_ACTION_TIMEOUT_MS });
170
+ }
171
+ else if (request.type === "press") {
172
+ await locator.press(request.key, { timeout: DEFAULT_ACTION_TIMEOUT_MS });
173
+ }
174
+ else {
175
+ await locator.hover({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
123
176
  }
124
- const submitted = this.submitForm(snapshot, element, request.value);
125
- const submittedSnapshot = await this.inspect(submitted.url);
126
- const navigation = await this.navigate(submitted.url, { includeSnapshot: true });
127
- if (!navigation.ok)
128
- throw new BrowserAutomationError("INVALID_URL", navigation.error);
129
- return {
130
- ...submittedSnapshot,
131
- action: { type: "fill", selector: request.selector, value: request.value, url: submitted.url },
132
- };
133
177
  }
134
- const formSubmit = element.clickable && element.formIndex !== undefined && !element.href
135
- ? this.submitForm(snapshot, element)
136
- : undefined;
137
- const targetUrl = formSubmit?.url ?? element.href;
138
- if (targetUrl === undefined) {
139
- throw new BrowserAutomationError("NOT_CLICKABLE", "matched element has no safe click target");
178
+ catch (error) {
179
+ if (error instanceof Error && /ref .* not found/i.test(error.message)) {
180
+ throw new BrowserAutomationError("STALE_REF", "element ref is stale; take a fresh browser snapshot", { cause: error });
181
+ }
182
+ throw new BrowserAutomationError("ACTION_FAILED", errorMessage(error), { cause: error });
140
183
  }
141
- const navigation = await this.navigate(targetUrl, { includeSnapshot: true });
142
- if (!navigation.ok)
143
- throw new BrowserAutomationError("INVALID_URL", navigation.error);
144
- return {
145
- ...snapshot,
146
- action: {
147
- type: "click",
148
- selector: request.selector,
149
- url: targetUrl,
150
- },
151
- };
184
+ await page.waitForLoadState("domcontentloaded", { timeout: 3_000 }).catch(() => undefined);
185
+ return { action: request.type, ref, page: await this.snapshot({ screenshot: false }) };
186
+ }
187
+ async scroll(input) {
188
+ const page = await this.page();
189
+ const amount = clamp(input.amount ?? 600, 50, 3000);
190
+ await page.mouse.wheel(0, input.direction === "down" ? amount : -amount);
191
+ await page.waitForTimeout(150);
192
+ return this.snapshot({ screenshot: false });
193
+ }
194
+ async pressKey(key) {
195
+ const page = await this.page();
196
+ await page.keyboard.press(key);
197
+ await page.waitForLoadState("domcontentloaded", { timeout: 3_000 }).catch(() => undefined);
198
+ return this.snapshot({ screenshot: false });
199
+ }
200
+ async selectTab(tabId) {
201
+ const page = this.tabs.get(tabId);
202
+ if (!page)
203
+ throw new BrowserAutomationError("TAB_NOT_FOUND", `tab ${tabId} does not exist`);
204
+ this.activePage = page;
205
+ await page.bringToFront();
206
+ return this.snapshot({ screenshot: false });
207
+ }
208
+ async newTab(rawUrl) {
209
+ const context = await this.context();
210
+ const page = await context.newPage();
211
+ this.trackPage(page);
212
+ this.activePage = page;
213
+ if (rawUrl) {
214
+ const result = await this.navigate(rawUrl);
215
+ if (!result.ok)
216
+ throw new BrowserAutomationError(result.code, result.error);
217
+ return result.page;
218
+ }
219
+ return this.snapshot({ screenshot: false });
220
+ }
221
+ async closeTab(tabId) {
222
+ const page = tabId === undefined ? await this.page() : this.tabs.get(tabId);
223
+ if (!page)
224
+ throw new BrowserAutomationError("TAB_NOT_FOUND", `tab ${tabId} does not exist`);
225
+ this.tabs.delete(tabIdForValue(this.tabs, page) ?? 0);
226
+ await page.close();
227
+ if (this.activePage === page)
228
+ this.activePage = undefined;
229
+ return this.snapshot({ screenshot: false });
230
+ }
231
+ async handleDialog(action, promptText) {
232
+ const page = await this.page();
233
+ const index = this.dialogQueue.findIndex((entry) => entry.page === page);
234
+ const entry = index >= 0 ? this.dialogQueue[index] : undefined;
235
+ if (!entry)
236
+ throw new BrowserAutomationError("NO_DIALOG", "the active page has no pending dialog");
237
+ this.dialogQueue.splice(index, 1);
238
+ if (action === "accept")
239
+ await entry.dialog.accept(promptText);
240
+ else
241
+ await entry.dialog.dismiss();
242
+ return this.snapshot({ screenshot: false });
243
+ }
244
+ async dispatchPointer(input) {
245
+ const page = await this.page();
246
+ if (input.type === "mousemove")
247
+ await page.mouse.move(input.x, input.y);
248
+ else if (input.type === "wheel")
249
+ await page.mouse.wheel(input.deltaX, input.deltaY);
250
+ else if (input.type === "mousedown")
251
+ await page.mouse.down({ button: input.button ?? "left" });
252
+ else if (input.type === "mouseup")
253
+ await page.mouse.up({ button: input.button ?? "left" });
254
+ return { ok: true };
255
+ }
256
+ async dispatchKeyboard(input) {
257
+ const page = await this.page();
258
+ if (input.type === "keydown")
259
+ await page.keyboard.down(input.key);
260
+ else
261
+ await page.keyboard.up(input.key);
262
+ return { ok: true };
263
+ }
264
+ async inputText(text) {
265
+ const page = await this.page();
266
+ await page.keyboard.type(text);
267
+ return { ok: true };
268
+ }
269
+ async screenshot() {
270
+ return this.snapshot({ screenshot: true });
271
+ }
272
+ async focus() {
273
+ const page = await this.page();
274
+ await page.bringToFront();
275
+ return { ok: true };
276
+ }
277
+ async waitFor(ms) {
278
+ const page = await this.page();
279
+ await page.waitForTimeout(clamp(ms, 0, 10_000));
280
+ return this.snapshot({ screenshot: false });
281
+ }
282
+ async dispose() {
283
+ if (this.disposed)
284
+ return;
285
+ this.disposed = true;
286
+ const pending = this.browserPromise;
287
+ this.browserPromise = undefined;
288
+ this.tabs.clear();
289
+ this.activePage = undefined;
290
+ if (!pending)
291
+ return;
292
+ const context = await pending.catch(() => undefined);
293
+ await context?.close().catch(() => undefined);
152
294
  }
153
- submitForm(snapshot, target, valueOverride) {
154
- if (target.formIndex === undefined) {
155
- throw new BrowserAutomationError("FORM_NOT_FOUND", "matched field is not inside a form");
295
+ async page() {
296
+ const context = await this.context();
297
+ if (this.activePage && !this.activePage.isClosed())
298
+ return this.activePage;
299
+ const existing = context.pages().find((page) => this.tabs.has(tabIdForValue(this.tabs, page) ?? -1));
300
+ if (existing) {
301
+ this.activePage = existing;
302
+ return existing;
156
303
  }
157
- if ((target.formMethod ?? "get") !== "get") {
158
- throw new BrowserAutomationError("FORM_METHOD_NOT_SUPPORTED", "only GET forms can be automated without browser scripts");
304
+ const page = await context.newPage();
305
+ this.trackPage(page);
306
+ this.activePage = page;
307
+ return page;
308
+ }
309
+ async context() {
310
+ if (this.disposed)
311
+ throw new BrowserAutomationError("BROWSER_CLOSED", "browser service is disposed");
312
+ this.browserPromise ??= (async () => {
313
+ await Promise.all([
314
+ mkdir(this.options.profileDir, { recursive: true }),
315
+ mkdir(this.options.downloadsDir, { recursive: true }),
316
+ mkdir(this.options.screenshotsDir, { recursive: true }),
317
+ ]);
318
+ const browser = await this.launchBrowser();
319
+ this.wireContext(browser);
320
+ const page = browser.pages()[0] ?? await browser.newPage();
321
+ this.trackPage(page);
322
+ this.activePage = page;
323
+ return browser;
324
+ })();
325
+ try {
326
+ return await this.browserPromise;
159
327
  }
160
- const url = new URL(target.formAction ?? snapshot.url);
161
- for (const element of snapshot.elements) {
162
- if (element.formIndex !== target.formIndex || !element.name)
163
- continue;
164
- if (element.tag === "button")
165
- continue;
166
- if (element.tag === "input" && ["submit", "button", "reset", "image"].includes(element.type ?? "")) {
167
- continue;
328
+ catch (error) {
329
+ this.browserPromise = undefined;
330
+ throw new BrowserAutomationError("BROWSER_START_FAILED", errorMessage(error), {
331
+ cause: error,
332
+ });
333
+ }
334
+ }
335
+ wireContext(context) {
336
+ context.on("page", (page) => {
337
+ this.trackPage(page);
338
+ if (!this.activePage || this.activePage.isClosed())
339
+ this.activePage = page;
340
+ });
341
+ context.on("close", () => {
342
+ if (this.browserPromise) {
343
+ void this.browserPromise.then((current) => {
344
+ if (current === context) {
345
+ this.browserPromise = undefined;
346
+ this.activePage = undefined;
347
+ this.tabs.clear();
348
+ }
349
+ });
168
350
  }
169
- const value = element.selector === target.selector && valueOverride !== undefined
170
- ? valueOverride
171
- : element.value ?? "";
172
- url.searchParams.set(element.name, value);
351
+ });
352
+ context.on("dialog", (dialog) => {
353
+ const page = dialog.page();
354
+ if (page)
355
+ this.dialogQueue.push({ page, dialog });
356
+ });
357
+ context.on("download", (download) => {
358
+ this.downloadQueue.push({ download, consumed: false });
359
+ });
360
+ }
361
+ async launchBrowser() {
362
+ const candidates = chromiumLaunchCandidates();
363
+ const launchOptions = chromiumLaunchOptions(this.options.headless);
364
+ let lastError;
365
+ for (const candidate of candidates) {
366
+ try {
367
+ return await chromium.launchPersistentContext(this.options.profileDir, { ...launchOptions, ...candidate });
368
+ }
369
+ catch (error) {
370
+ lastError = error;
371
+ }
372
+ }
373
+ if (candidates.length === 0) {
374
+ return await chromium.launchPersistentContext(this.options.profileDir, launchOptions);
173
375
  }
174
- return { url: url.href };
376
+ throw lastError instanceof Error
377
+ ? lastError
378
+ : new Error("no Chromium executable was available");
175
379
  }
176
- nextId() {
177
- this.sequence += 1;
178
- return `browser-${Date.now().toString(36)}-${this.sequence}`;
380
+ trackPage(page) {
381
+ for (const [id, existing] of this.tabs) {
382
+ if (existing === page)
383
+ return id;
384
+ }
385
+ const id = this.nextTabId;
386
+ this.nextTabId += 1;
387
+ this.tabs.set(id, page);
388
+ page.on("close", () => {
389
+ this.tabs.delete(id);
390
+ if (this.activePage === page)
391
+ this.activePage = undefined;
392
+ });
393
+ return id;
179
394
  }
180
- }
181
- export class BrowserAutomationError extends Error {
182
- code;
183
- constructor(code, message, options) {
184
- super(message, options);
185
- this.code = code;
186
- this.name = "BrowserAutomationError";
395
+ dialogFor(page) {
396
+ const index = this.dialogQueue.findIndex((entry) => entry.page === page);
397
+ const dialog = index >= 0 ? this.dialogQueue[index]?.dialog : undefined;
398
+ if (!dialog)
399
+ return null;
400
+ return {
401
+ type: dialog.type(),
402
+ message: dialog.message(),
403
+ ...(dialog.defaultValue() ? { defaultText: dialog.defaultValue() } : {}),
404
+ };
187
405
  }
188
- }
189
- export function rewriteBaiduSearchUrlForSnapshot(rawUrl) {
190
- try {
191
- const url = new URL(rawUrl);
192
- if (url.protocol !== "https:" || url.hostname !== "www.baidu.com")
193
- return rawUrl;
194
- if (url.pathname.replace(/\/+$/, "") !== "/s")
195
- return rawUrl;
196
- const query = url.searchParams.get("wd") ?? url.searchParams.get("word");
197
- if (query === null)
198
- return rawUrl;
199
- const mobile = new URL("https://m.baidu.com/s");
200
- mobile.searchParams.set("wd", query);
201
- return mobile.href;
202
- }
203
- catch {
204
- return rawUrl;
406
+ async tabSummaries() {
407
+ return Promise.all([...this.tabs.entries()].map(async ([id, page]) => ({
408
+ id,
409
+ url: page.url(),
410
+ title: page.isClosed() ? "" : await page.title().catch(() => ""),
411
+ active: !page.isClosed() && page === this.activePage,
412
+ })));
205
413
  }
206
- }
207
- const SKIP_TAGS = new Set(["script", "style", "template", "noscript"]);
208
- const INTERACTIVE_TAGS = new Set(["a", "button", "input", "select", "textarea"]);
209
- export function parseBrowserPage(html, baseUrl) {
210
- const state = { snapshot: { url: baseUrl, title: "", elements: [] }, nextFormIndex: 0 };
211
- const tokenizer = /<!--[\s\S]*?-->|<\/?([a-zA-Z][a-zA-Z0-9:-]*)([^>]*)>/g;
212
- const openTags = [];
213
- let cursor = 0;
214
- let match;
215
- while ((match = tokenizer.exec(html)) !== null) {
216
- const text = decodeEntities(html.slice(cursor, match.index)).replace(/\s+/g, " ").trim();
217
- const parent = openTags[openTags.length - 1];
218
- if (parent && !SKIP_TAGS.has(parent.tag))
219
- appendText(parent, text);
220
- cursor = match.index + match[0].length;
221
- if (match[0].startsWith("<!--"))
222
- continue;
223
- const tag = match[1]?.toLowerCase() ?? "";
224
- const attrs = parseAttributes(match[2] ?? "");
225
- if (match[0].startsWith("</")) {
226
- const index = findOpenTag(openTags, tag);
227
- if (index >= 0) {
228
- const closed = openTags[index];
229
- openTags.length = index;
230
- closeElement(state, closed, baseUrl);
231
- }
232
- continue;
233
- }
234
- const parentForForm = openTags[openTags.length - 1];
235
- let form = parentForForm?.form;
236
- if (tag === "form" && !form) {
237
- form = {
238
- index: state.nextFormIndex,
239
- action: attrs.action,
240
- method: attrs.method?.toLowerCase(),
414
+ async captureScreenshot(page) {
415
+ const buffer = await page.screenshot({
416
+ type: "png",
417
+ fullPage: false,
418
+ timeout: 15_000,
419
+ });
420
+ if (buffer.byteLength > MAX_SCREENSHOT_BYTES)
421
+ return undefined;
422
+ return `data:image/png;base64,${buffer.toString("base64")}`;
423
+ }
424
+ async nextDownloadResult() {
425
+ const index = this.downloadQueue.findIndex((entry) => !entry.consumed);
426
+ if (index < 0)
427
+ return undefined;
428
+ const entry = this.downloadQueue[index];
429
+ if (!entry)
430
+ return undefined;
431
+ entry.consumed = true;
432
+ const download = entry.download;
433
+ const target = path.join(this.options.downloadsDir, `${randomUUID()}-${sanitizeFilename(download.suggestedFilename())}`);
434
+ try {
435
+ await download.saveAs(target);
436
+ this.downloadQueue.splice(index, 1);
437
+ return {
438
+ url: download.url(),
439
+ suggestedFilename: download.suggestedFilename(),
440
+ path: target,
241
441
  };
242
- state.nextFormIndex += 1;
243
- }
244
- const element = { tag, attrs, text: "", form };
245
- if (/\/>$/.test(match[0]) || isVoidTag(tag)) {
246
- closeElement(state, element, baseUrl);
247
442
  }
248
- else {
249
- openTags.push(element);
443
+ catch (error) {
444
+ this.downloadQueue.splice(index, 1);
445
+ throw error;
250
446
  }
251
447
  }
252
- if (state.snapshot.elements.length > 300)
253
- state.snapshot.elements.length = 300;
254
- return state.snapshot;
255
448
  }
256
- function parseAttributes(raw) {
257
- const attrs = {};
258
- const pattern = /([:@a-zA-Z_#][^\s"'<>`=]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
259
- let match;
260
- while ((match = pattern.exec(raw)) !== null) {
261
- attrs[match[1]?.toLowerCase() ?? ""] = decodeEntities(match[2] ?? match[3] ?? match[4] ?? "");
262
- }
263
- return attrs;
449
+ function emptyBrowserState() {
450
+ return {
451
+ running: false,
452
+ url: "about:blank",
453
+ title: "",
454
+ tabs: [],
455
+ dialog: null,
456
+ ariaSnapshot: "",
457
+ };
264
458
  }
265
- function isVoidTag(tag) {
266
- return tag === "input" || tag === "img" || tag === "br" || tag === "hr" || tag === "meta" || tag === "link";
459
+ function chromiumLaunchOptions(headless) {
460
+ return {
461
+ headless: headless ?? false,
462
+ viewport: undefined,
463
+ acceptDownloads: true,
464
+ locale: "zh-CN",
465
+ timezoneId: "Asia/Shanghai",
466
+ args: ["--start-maximized", "--disable-blink-features=AutomationControlled"],
467
+ ignoreDefaultArgs: ["--enable-automation"],
468
+ };
267
469
  }
268
- function findOpenTag(stack, tag) {
269
- for (let index = stack.length - 1; index >= 0; index -= 1) {
270
- if (stack[index]?.tag === tag)
271
- return index;
470
+ async function findSearchInput(page) {
471
+ for (const selector of SEARCH_INPUT_SELECTORS) {
472
+ const locator = page.locator(selector).first();
473
+ if (await locator.isVisible().catch(() => false))
474
+ return locator;
272
475
  }
273
- return -1;
274
- }
275
- function appendText(target, text) {
276
- target.text = target.text ? `${target.text} ${text}` : text;
476
+ return undefined;
277
477
  }
278
- function closeElement(state, element, baseUrl) {
279
- const { tag, attrs, text } = element;
280
- if (tag === "title") {
281
- state.snapshot.title = text.slice(0, 300);
478
+ async function submitSearchForm(page, input) {
479
+ for (const selector of SEARCH_SUBMIT_SELECTORS) {
480
+ const locator = page.locator(selector).first();
481
+ if (!await locator.isVisible().catch(() => false))
482
+ continue;
483
+ await locator.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
282
484
  return;
283
485
  }
284
- if (!INTERACTIVE_TAGS.has(tag))
285
- return;
286
- let href;
287
- if (tag === "a" && attrs.href && !/^(javascript|mailto|tel):/i.test(attrs.href.trim())) {
288
- try {
289
- href = new URL(attrs.href, baseUrl).href;
290
- }
291
- catch {
292
- href = undefined;
293
- }
486
+ await input.press("Enter", { timeout: DEFAULT_ACTION_TIMEOUT_MS });
487
+ }
488
+ export class BrowserAutomationError extends Error {
489
+ code;
490
+ constructor(code, message, options) {
491
+ super(message, options);
492
+ this.code = code;
493
+ this.name = "BrowserAutomationError";
294
494
  }
295
- const id = attrs.id;
296
- const name = attrs.name;
297
- const selector = buildSelector(tag, id, name, attrs.class, state.snapshot.elements.length);
298
- const rawText = text || attrs.value || attrs.placeholder || attrs["aria-label"] || attrs.title || "";
299
- state.snapshot.elements.push({
300
- tag,
301
- ...(id ? { id } : {}),
302
- text: rawText.replace(/\s+/g, " ").trim().slice(0, 240),
303
- ...(href ? { href } : {}),
304
- ...(name ? { name } : {}),
305
- ...(attrs.type ? { type: attrs.type } : {}),
306
- ...(attrs.value ? { value: attrs.value } : {}),
307
- ...(attrs.placeholder ? { placeholder: attrs.placeholder } : {}),
308
- ...(element.form ? { formIndex: element.form.index } : {}),
309
- ...(element.form ? {
310
- formAction: element.form.action === undefined
311
- ? baseUrl
312
- : new URL(element.form.action, baseUrl).href,
313
- formMethod: element.form.method ?? "get",
314
- } : {}),
315
- selector,
316
- clickable: tag === "a" || tag === "button" || (tag === "input" && ["submit", "button", "radio", "checkbox"].includes(attrs.type ?? "")),
317
- });
318
495
  }
319
- function buildSelector(tag, id, name, className, index) {
320
- if (id)
321
- return `#${escapeSelectorValue(id)}`;
322
- if (name)
323
- return `${tag}[name="${escapeSelectorValue(name)}"]`;
324
- if (className?.trim()) {
325
- const firstClass = className.trim().split(/\s+/)[0];
326
- if (firstClass)
327
- return `${tag}.${escapeSelectorValue(firstClass)}`;
328
- }
329
- return `${tag}:nth-of-type(${index + 1})`;
496
+ export function chromiumLaunchCandidates(env = process.env, platform = process.platform) {
497
+ const executablePath = env.LOOP_BROWSER_CHROMIUM_PATH?.trim();
498
+ if (executablePath)
499
+ return [{ executablePath }];
500
+ if (platform === "win32")
501
+ return [{ channel: "chrome" }, { channel: "msedge" }];
502
+ if (platform === "darwin")
503
+ return [{ channel: "chrome" }];
504
+ return [];
330
505
  }
331
- function escapeSelectorValue(value) {
332
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
506
+ function tabIdForValue(map, page) {
507
+ for (const [id, value] of map)
508
+ if (value === page)
509
+ return id;
510
+ return undefined;
333
511
  }
334
- export function selectBrowserElement(snapshot, request) {
335
- const selector = request.selector.trim();
336
- if (!selector)
337
- return undefined;
338
- return snapshot.elements.find((element) => element.selector === selector)
339
- ?? snapshot.elements.find((element) => element.id === selector.slice(1))
340
- ?? snapshot.elements.find((element) => element.name === selector)
341
- ?? snapshot.elements.find((element) => element.text.toLowerCase().includes(selector.toLowerCase()) && selector.length >= 2);
512
+ function truncateText(value, max) {
513
+ return value.length <= max ? value : `${value.slice(0, max)}\n... [snapshot truncated]`;
342
514
  }
343
- const ENTITY_MAP = {
344
- amp: "&",
345
- lt: "<",
346
- gt: ">",
347
- quot: "\"",
348
- apos: "'",
349
- nbsp: " ",
350
- };
351
- function decodeEntities(value) {
352
- return value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, entity) => {
353
- if (entity.startsWith("#x") || entity.startsWith("#X")) {
354
- const code = Number.parseInt(entity.slice(2), 16);
355
- return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
356
- }
357
- if (entity.startsWith("#")) {
358
- const code = Number.parseInt(entity.slice(1), 10);
359
- return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
360
- }
361
- return ENTITY_MAP[entity.toLowerCase()] ?? whole;
362
- });
515
+ function clamp(value, min, max) {
516
+ return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
517
+ }
518
+ function sanitizeFilename(value) {
519
+ return value.replace(/[\\/:*?"<>|]/g, "_").slice(0, 180) || "download";
520
+ }
521
+ function errorMessage(error) {
522
+ return error instanceof Error ? error.message : String(error);
363
523
  }