@tea-agent/loop-agent 0.44.0-next.12 → 0.44.0-next.14

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 (188) hide show
  1. package/CHANGELOG.md +27 -2
  2. package/README.md +12 -12
  3. package/dist/adapters/loop-agent.js +34 -0
  4. package/dist/build-stamp.json +3 -3
  5. package/dist/cli/command-definitions.js +5 -5
  6. package/dist/cli/program.js +1 -4
  7. package/dist/commands/init-legacy-audit.js +330 -0
  8. package/dist/commands/init-upgrade.js +644 -104
  9. package/dist/commands/init.js +686 -330
  10. package/dist/commands/operator-guide.js +99 -0
  11. package/dist/commands/operator.js +16 -3
  12. package/dist/executors/dag-pi-executor.js +11 -6
  13. package/dist/executors/pi-sdk-executor.js +3 -1
  14. package/dist/executors/pi-writer-tool-policy.js +60 -4
  15. package/dist/executors/playwright-cli-launcher.js +31 -45
  16. package/dist/executors/shell-write-guard.js +24 -67
  17. package/dist/governance/exec-plans.js +10 -1
  18. package/dist/governance/harness.js +2 -7
  19. package/dist/governance/manifest-types.js +28 -9
  20. package/dist/governance/project-capability-scanner.js +263 -0
  21. package/dist/governance/project-config.js +96 -0
  22. package/dist/governance/resource-catalog.js +151 -0
  23. package/dist/infrastructure/console/app-data.js +6 -2
  24. package/dist/shared/operator/capabilities.js +2 -2
  25. package/dist/shared/package-metadata.js +23 -7
  26. package/dist/shared/pi-context-pressure/extension.js +23 -0
  27. package/dist/shared/pi-context-pressure/index.js +1 -0
  28. package/dist/shared/pi-context-pressure/telemetry.js +4 -0
  29. package/dist/shared/pi-context-pressure/tool-result-pruner.js +98 -0
  30. package/dist/shared/update/npm-client.js +49 -1
  31. package/dist/task/config-types.js +4 -2
  32. package/dist/task/source-prepare/build-draft.js +1 -0
  33. package/dist/task/source-prepare/path-policy.js +17 -7
  34. package/dist/worker/cli.js +3 -5
  35. package/dist/worker/console/chat/browser-automation.js +455 -41
  36. package/dist/worker/console/chat/browser-content.js +74 -0
  37. package/dist/worker/console/chat/browser-diagnostics.js +250 -0
  38. package/dist/worker/console/chat/browser-evaluation.js +58 -0
  39. package/dist/worker/console/chat/browser-operation-queue.js +19 -0
  40. package/dist/worker/console/chat/browser-pick.js +113 -0
  41. package/dist/worker/console/chat/browser-routes.js +54 -5
  42. package/dist/worker/console/chat/browser-screenshot-result.js +47 -0
  43. package/dist/worker/console/chat/browser-storage.js +89 -0
  44. package/dist/worker/console/chat/chat-event-store.js +7 -6
  45. package/dist/worker/console/chat/context-insights.js +84 -19
  46. package/dist/worker/console/chat/pet/pet-affinity.js +169 -0
  47. package/dist/worker/console/chat/pet/pet-persist.js +109 -0
  48. package/dist/worker/console/chat/pet/pet-registry.js +142 -0
  49. package/dist/worker/console/chat/pet/pet-remarks.js +48 -0
  50. package/dist/worker/console/chat/pet/pet-routes.js +125 -0
  51. package/dist/worker/console/chat/pet/pet-service.js +262 -0
  52. package/dist/worker/console/chat/pet/pet-state.js +99 -0
  53. package/dist/worker/console/chat/pi-mode-loop-isolation.js +17 -2
  54. package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +125 -174
  55. package/dist/worker/console/chat/pi-runtime.js +8 -3
  56. package/dist/worker/console/chat/repo-browser.js +80 -2
  57. package/dist/worker/console/chat/repo-preview-kind.js +155 -0
  58. package/dist/worker/console/chat/routes.js +43 -7
  59. package/dist/worker/console/chat/scheduled-goal-request.js +14 -6
  60. package/dist/worker/console/chat/session-mode.js +1 -1
  61. package/dist/worker/console/chat/session-store.js +24 -5
  62. package/dist/worker/console/chat/tools.js +12 -0
  63. package/dist/worker/console/console-shutdown.js +34 -0
  64. package/dist/worker/console/server.js +2 -1
  65. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-r1NAPU_5.js → abnfDiagram-N423BO3Z-DASc69AM.js} +1 -1
  66. package/dist/worker/console/static/assets/{arc-dT5T4sVL.js → arc-h09p-LAc.js} +1 -1
  67. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CP_cOq46.js → architectureDiagram-T3A2C74G-CZ3dtLkh.js} +1 -1
  68. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-sToEZxEf.js → blockDiagram-VBNYF7ZC-DMrRhMXG.js} +1 -1
  69. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DSxwwi2m.js → c4Diagram-5PPSVZJV-DQxisHV6.js} +1 -1
  70. package/dist/worker/console/static/assets/channel-D1zgKZfg.js +1 -0
  71. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-B9fh0IkF.js → chunk-2GRJ4B5K-CefVN9zh.js} +1 -1
  72. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CAUNl-A6.js → chunk-2Q5K7J3B-DmR1Koq2.js} +1 -1
  73. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BomeqbNX.js → chunk-5RXB4S5H-NlzrSW7L.js} +1 -1
  74. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BNAuTOLE.js → chunk-5VM5RSS4-DfelJv-0.js} +1 -1
  75. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DfzjRD5y.js → chunk-6Q2QTUOP-BK6RFy58.js} +1 -1
  76. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C3uE-yc2.js → chunk-GF5L2VYU-BAeh9lz4.js} +1 -1
  77. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-C3sQMMYr.js → chunk-JWPE2WC7-ChPS5WzG.js} +1 -1
  78. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D_AtEYQL.js → chunk-KBJHAD2P-BoVe5IM6.js} +1 -1
  79. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DW4TwhKv.js → chunk-RYQCIY6F-C4q_0Lw9.js} +1 -1
  80. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CTQDmJcp.js → chunk-XXDRQBXY-Bb9TvjeY.js} +1 -1
  81. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CXNNsY2M.js +1 -0
  82. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CXNNsY2M.js +1 -0
  83. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-iMqPuBsL.js → cose-bilkent-JH36ORCC-DmNnL8Ff.js} +1 -1
  84. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DktIuDWs.js → cynefin-VYW2F7L2-Contvx58.js} +1 -1
  85. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BgVnjyEs.js → cynefinDiagram-MW4NZA55-Cigq7cjk.js} +1 -1
  86. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CR25E4bu.js → dagre-VZM6K2ZE-dEbC0nxb.js} +1 -1
  87. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CPM2h5HY.js → diagram-7IWD3JNH-CzIztESV.js} +1 -1
  88. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DqF0x2g1.js → diagram-B4RE2ZJO-Bo14zbaY.js} +1 -1
  89. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-G8BYp2eb.js → diagram-LBJQPF4R-C4xUyil6.js} +1 -1
  90. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BbC4kT_D.js → diagram-Q27KOJAE-CaBY_uYa.js} +1 -1
  91. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CKZwZ-G5.js → diagram-UB23O5K3-ClUXUeiu.js} +1 -1
  92. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-kJDmheia.js → ebnfDiagram-BXEA7PRR-BbGCgjC6.js} +1 -1
  93. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DFTVHyxB.js → erDiagram-JOGREHBK-Dr-AcwSl.js} +1 -1
  94. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BqdN5_os.js → flowDiagram-UKHOOZJN-BG8Gcfyc.js} +1 -1
  95. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-SYDjVakh.js → ganttDiagram-PKOTCBZU-CFVaPs5r.js} +1 -1
  96. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bf8bNpGA.js → gitGraphDiagram-DS77QQ5N-ChJ8pm-O.js} +1 -1
  97. package/dist/worker/console/static/assets/index-B_5GhVIO.js +488 -0
  98. package/dist/worker/console/static/assets/index-C7YMBluX.css +1 -0
  99. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DIeAmJ45.js → infoDiagram-6WML65LV-C5J9-uCZ.js} +1 -1
  100. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-oikqUAlo.js → ishikawaDiagram-WSZJBQD7-BcfjqzDv.js} +1 -1
  101. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-mKEF_xYT.js → journeyDiagram-NVQOT4AX-BzzCO-rD.js} +1 -1
  102. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BdWJHPnN.js → kanban-definition-27J2QSJJ-NCHUZ46h.js} +1 -1
  103. package/dist/worker/console/static/assets/{linear-DGRlieMH.js → linear-BKZlWCUU.js} +1 -1
  104. package/dist/worker/console/static/assets/{mermaid.core-4P9yPC9c.js → mermaid.core-DbTaQjFE.js} +5 -5
  105. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-5PTSkos6.js → mindmap-definition-FAOFIHXS-CroYl5xQ.js} +1 -1
  106. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DONIFpG5.js → pegDiagram-VL7TDLO6-DgMBx-9k.js} +1 -1
  107. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-D_Z4S70M.js → pieDiagram-7S7Q4E2Y-CyWDmbq1.js} +1 -1
  108. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CAsjh8iY.js → quadrantDiagram-CIZ2JOQS-BgGEOqSP.js} +1 -1
  109. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Cx4Dd6ac.js → railroadDiagram-AXF67PYL-BMZ7oeeV.js} +1 -1
  110. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-cQb7mwyH.js → requirementDiagram-LRYGKXZP-DxqyGhcc.js} +1 -1
  111. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Cd-DhUdf.js → sankeyDiagram-W5VNT64P-DnSvNCG8.js} +1 -1
  112. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-AlKYRgIk.js → sequenceDiagram-SI44F4Z6-BiZh6f-R.js} +1 -1
  113. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CUy_ET4e.js → sizeCapture-X5ZJPWSS-BKXSI8WB.js} +1 -1
  114. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-CTuVe6Sa.js → stateDiagram-OKZ733FA-CfSgoYFm.js} +1 -1
  115. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DgHi3vjb.js +1 -0
  116. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-ChFLJb-v.js → swimlanes-SLNWSIFB-CyGADcHe.js} +2 -2
  117. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bgvdt1Y0.js +8 -0
  118. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DZZeiAPr.js → timeline-definition-Z64GVDOM-HmPaSPOK.js} +1 -1
  119. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-t4O91cZU.js → vennDiagram-T6HMQDX7-VA2VBbez.js} +1 -1
  120. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CV5qGHZu.js → wardleyDiagram-T6FBY63Y-BxjiBPZa.js} +1 -1
  121. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DyXbytUI.js → xychartDiagram-ELKLHX3M--hUY-qFj.js} +1 -1
  122. package/dist/worker/console/static/index.html +2 -2
  123. package/dist/worker/console/static/pets/ember/pet.json +167 -0
  124. package/dist/worker/console/static/pets/ember/spritesheet.png +0 -0
  125. package/dist/worker/console/static-src/operator-chat/details-rail-surfaces.js +4 -3
  126. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +57 -9
  127. package/dist/worker/console/static-src/operator-chat/use-stat-dialog.js +95 -0
  128. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +6 -0
  129. package/dist/worker/console/workspace-context.js +15 -0
  130. package/dist/worker/console/workspace-initialization.js +25 -16
  131. package/dist/worker/delivery/package.js +4 -1
  132. package/dist/worker/materialize/harness-task-lineage.js +4 -2
  133. package/dist/worker/observe/static/console-theme.css +33 -29
  134. package/dist/worker/observe/static/operator-chrome.js +1 -42
  135. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +3 -1
  136. package/dist/workflows/dag/backend-test-layout.js +27 -7
  137. package/dist/workflows/dag/backend-test-markdown-workflow.js +3 -1
  138. package/dist/workflows/dag/backend-test-pytest-collection.js +4 -1
  139. package/dist/workflows/dag/final-verification.js +45 -10
  140. package/dist/workflows/dag/frontend-test-case-checklist.js +12 -3
  141. package/dist/workflows/dag/frontend-test-environment-probe.js +0 -2
  142. package/dist/workflows/dag/frontend-test-layout.js +41 -12
  143. package/dist/workflows/dag/hybrid/sources.js +35 -16
  144. package/dist/workflows/dag/hybrid/templates/backend-test.js +70 -55
  145. package/dist/workflows/dag/hybrid/templates/frontend-test.js +62 -41
  146. package/dist/workflows/dag/hybrid/templates/kg-bootstrap.js +6 -6
  147. package/dist/workflows/dag/hybrid/templates/knowledge-sync.js +5 -5
  148. package/dist/workflows/dag/skill-instructions.js +102 -32
  149. package/dist/workflows/dag/skill-snapshot.js +3 -0
  150. package/dist/workflows/dag/skills.js +7 -7
  151. package/dist/workflows/dag/test-artifact-namespace.js +73 -0
  152. package/dist/workflows/dag/validate.js +24 -5
  153. package/dist/workflows/dag/workspace-checkpoint.js +13 -1
  154. package/docs/architecture/runtime-boundaries.md +28 -22
  155. package/docs/init-surface.manifest.json +43 -295
  156. package/docs/templates/README.md +4 -4
  157. package/docs/templates/agent-dag.base.json +9 -9
  158. package/docs/templates/agent-dag.final-verification.json +6 -6
  159. package/docs/templates/agent-dag.supervised-implementation.json +12 -12
  160. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +3 -3
  161. package/docs/templates/backend-test-dag.json +708 -708
  162. package/docs/templates/backend-test-dag.review-cases.prompt.md +3 -3
  163. package/docs/templates/frontend-implementation-dag.json +2 -2
  164. package/docs/templates/frontend-test-case-checklist.md +2 -2
  165. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +7 -7
  166. package/docs/templates/frontend-test-dag.json +48 -39
  167. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +2 -2
  168. package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
  169. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  170. package/docs/templates/hybrid-dag.json +9 -9
  171. package/docs/templates/knowledge-sync-dag.json +8 -8
  172. package/package.json +12 -3
  173. package/resource-catalog.v1.json +1973 -0
  174. package/resources/agent-bridge/loop-agent/SKILL.md +40 -0
  175. package/resources/init-history/legacy-surface.v1.json +33 -0
  176. package/skills/fe-test-ui-scout/SKILL.md +4 -4
  177. package/skills/fe-test-ui-scout/references/ledger-schema.md +1 -1
  178. package/skills/init-update/SKILL.md +34 -0
  179. package/skills/init-update/agents/openai.yaml +6 -0
  180. package/skills/playwright-cli/SKILL.md +1 -1
  181. package/skills/playwright-cli-case-generator/SKILL.md +4 -4
  182. package/dist/worker/console/static/assets/channel-CyBY_yGk.js +0 -1
  183. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +0 -1
  184. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +0 -1
  185. package/dist/worker/console/static/assets/index-B28Onmfy.css +0 -1
  186. package/dist/worker/console/static/assets/index-CTMZsDmh.js +0 -486
  187. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +0 -1
  188. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CJcioYGH.js +0 -8
@@ -5,40 +5,78 @@
5
5
  * isolated from the user's normal browser without contaminating the repo.
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
8
- import { mkdir } from "node:fs/promises";
8
+ import { constants } from "node:fs";
9
+ import { mkdir, open, writeFile } from "node:fs/promises";
9
10
  import path from "node:path";
10
11
  import { chromium, } from "playwright-core";
12
+ import { pickBrowserElement } from "./browser-pick.js";
13
+ import { browserHtmlToMarkdown, captureBrowserHtml } from "./browser-content.js";
14
+ import { exportBrowserStorage, mergeBrowserStorage, readBrowserStorage } from "./browser-storage.js";
15
+ import { evaluateBrowserExpression, waitForBrowserOperation } from "./browser-evaluation.js";
16
+ import { BrowserOperationQueue } from "./browser-operation-queue.js";
11
17
  import { normalizeBrowserUrl } from "./browser-policy.js";
18
+ import { BrowserDiagnostics } from "./browser-diagnostics.js";
12
19
  export const BROWSER_VIEWPORT_WIDTH = 1280;
13
20
  export const BROWSER_VIEWPORT_HEIGHT = 900;
14
21
  const SEARCH_INPUT_SELECTORS = [
15
22
  "input[name='wd']",
16
23
  "input[name='q']",
17
24
  "input[name='query']",
25
+ "input[name='search']",
18
26
  "input[type='search']",
27
+ "[role='searchbox']",
28
+ "textarea[name='wd']",
29
+ "textarea[name='q']",
30
+ "textarea[name='query']",
31
+ "textarea[type='search']",
19
32
  "input[type='text']",
33
+ "textarea",
20
34
  ];
21
35
  const SEARCH_SUBMIT_SELECTORS = [
22
36
  "button[type='submit']",
23
37
  "input[type='submit']",
24
- "button:not([type])",
25
38
  ];
26
- const DEFAULT_SEARCH_ENGINE_URL = "https://www.baidu.com/";
39
+ export const DEFAULT_SEARCH_ENGINE_URL = "https://www.bing.com/";
27
40
  const SEARCH_TYPING_DELAY_MS = 45;
28
41
  const DEFAULT_ACTION_TIMEOUT_MS = 15_000;
42
+ const NAVIGATION_SETTLE_WINDOW_MS = 400;
43
+ const NAVIGATION_SETTLE_CAP_MS = 2_000;
44
+ const SEARCH_RESULT_TIMEOUT_MS = 15_000;
29
45
  const MAX_SNAPSHOT_CHARS = 24_000;
30
46
  const MAX_SCREENSHOT_BYTES = 4 * 1024 * 1024;
31
47
  export class BrowserAutomationService {
32
48
  options;
49
+ operations = new BrowserOperationQueue();
50
+ snapshots = new WeakMap();
51
+ recovering = new WeakSet();
33
52
  browserPromise;
34
53
  activePage;
54
+ diagnostics = new BrowserDiagnostics();
35
55
  tabs = new Map();
36
56
  nextTabId = 1;
37
57
  disposed = false;
38
58
  dialogQueue = [];
39
59
  downloadQueue = [];
60
+ mirrorPage;
61
+ mirrorSession;
62
+ mirrorSeq = 0;
63
+ mirrorFrame;
40
64
  constructor(options) {
41
65
  this.options = options;
66
+ // The visible window stays the default so human picking works out of the
67
+ // box. LOOP_BROWSER_HEADLESS=1 (or "true"/"on") switches the browser to a
68
+ // background (headless) instance that never opens a window.
69
+ if (this.options.headless === undefined) {
70
+ this.options.headless = browserHeadlessEnabled();
71
+ }
72
+ }
73
+ /** Runtime entrypoint: tools and HTTP must enter once, binding the target before queuing. */
74
+ run(input, operation) {
75
+ const page = input.tabId === undefined ? this.activePage : this.tabs.get(input.tabId);
76
+ if (input.tabId !== undefined && (!page || page.isClosed())) {
77
+ return Promise.reject(new BrowserAutomationError("TAB_NOT_FOUND", `tab ${input.tabId} does not exist`));
78
+ }
79
+ return this.operations.run({ page }, input.signal, operation);
42
80
  }
43
81
  async navigate(rawUrl) {
44
82
  const normalized = normalizeBrowserUrl(rawUrl);
@@ -54,6 +92,7 @@ export class BrowserAutomationService {
54
92
  try {
55
93
  const page = await this.page();
56
94
  await page.goto(normalized.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
95
+ await settleNavigation(page);
57
96
  const snapshot = await this.snapshot({ screenshot: false });
58
97
  return { ok: true, page: snapshot };
59
98
  }
@@ -64,23 +103,31 @@ export class BrowserAutomationService {
64
103
  async reload() {
65
104
  const page = await this.page();
66
105
  await page.reload({ waitUntil: "domcontentloaded", timeout: 30_000 });
106
+ await settleNavigation(page);
67
107
  return this.snapshot({ screenshot: false });
68
108
  }
69
109
  async goBack() {
70
110
  const page = await this.page();
71
111
  await page.goBack({ waitUntil: "domcontentloaded", timeout: 30_000 });
112
+ await settleNavigation(page);
72
113
  return this.snapshot({ screenshot: false });
73
114
  }
74
115
  async goForward() {
75
116
  const page = await this.page();
76
117
  await page.goForward({ waitUntil: "domcontentloaded", timeout: 30_000 });
118
+ await settleNavigation(page);
77
119
  return this.snapshot({ screenshot: false });
78
120
  }
79
121
  async snapshot(options) {
80
- const page = await this.page();
122
+ const page = options?.tabId === undefined ? await this.page() : this.tabs.get(options.tabId);
123
+ if (!page || page.isClosed())
124
+ throw new BrowserAutomationError("TAB_NOT_FOUND", "requested tab is unavailable");
125
+ if (this.recovering.has(page))
126
+ throw new BrowserAutomationError("PAGE_BUSY", "page script may still be running; wait or explicitly close this tab");
81
127
  const dialog = this.dialogFor(page);
82
128
  if (dialog) {
83
129
  return {
130
+ tabId: this.trackPage(page),
84
131
  url: page.url(),
85
132
  title: "浏览器弹窗",
86
133
  tabs: await this.tabSummaries(),
@@ -88,7 +135,7 @@ export class BrowserAutomationService {
88
135
  ariaSnapshot: "",
89
136
  ...(options?.screenshot === false
90
137
  ? {}
91
- : { screenshot: await this.captureScreenshot(page) }),
138
+ : await this.captureScreenshot(page, options?.fullPage)),
92
139
  };
93
140
  }
94
141
  const [title, aria] = await Promise.all([
@@ -97,15 +144,19 @@ export class BrowserAutomationService {
97
144
  ]);
98
145
  const download = await this.nextDownloadResult();
99
146
  const screenshot = options?.screenshot === false
100
- ? undefined
101
- : await this.captureScreenshot(page);
147
+ ? {}
148
+ : await this.captureScreenshot(page, options?.fullPage);
149
+ const snapshotId = randomUUID();
150
+ this.snapshots.set(page, snapshotId);
102
151
  return {
152
+ tabId: this.trackPage(page),
153
+ snapshotId,
103
154
  url: page.url(),
104
155
  title,
105
156
  tabs: await this.tabSummaries(),
106
157
  dialog,
107
158
  ariaSnapshot: truncateText(aria, MAX_SNAPSHOT_CHARS),
108
- ...(screenshot ? { screenshot } : {}),
159
+ ...screenshot,
109
160
  ...(download ? { download } : {}),
110
161
  };
111
162
  }
@@ -115,18 +166,53 @@ export class BrowserAutomationService {
115
166
  */
116
167
  async state() {
117
168
  if (!this.browserPromise && !this.disposed)
118
- return emptyBrowserState();
119
- const page = await this.page();
169
+ return { ...emptyBrowserState(), headless: this.options.headless === true };
170
+ // Polling never starts a context, changes selection, or waits on a quarantined page.
171
+ const page = this.operations.current?.target.page ?? this.activePage;
172
+ if (!page || page.isClosed())
173
+ return { ...emptyBrowserState(), running: !!this.browserPromise, headless: this.options.headless === true, tabs: await this.tabSummaries() };
120
174
  const state = {
121
175
  running: true,
176
+ headless: this.options.headless === true,
177
+ tabId: this.trackPage(page),
122
178
  url: page.url(),
123
- title: await page.title().catch(() => ""),
179
+ title: this.recovering.has(page) ? "Page busy — script may still be running" : await page.title().catch(() => ""),
124
180
  tabs: await this.tabSummaries(),
125
181
  dialog: this.dialogFor(page),
126
182
  ariaSnapshot: "",
127
183
  };
128
184
  return state;
129
185
  }
186
+ /**
187
+ * Passive screencast frame of the active page for the panel mirror view.
188
+ * Like state(), polling never launches the browser and never touches the
189
+ * operation queue, so a stuck page cannot block the panel.
190
+ */
191
+ async panelFrame(since = 0) {
192
+ if (!this.browserPromise || this.disposed) {
193
+ await this.stopMirror().catch(() => undefined);
194
+ return { running: false, changed: false, seq: this.mirrorSeq };
195
+ }
196
+ const page = this.operations.current?.target.page ?? this.activePage;
197
+ if (!page || page.isClosed()) {
198
+ return { running: true, changed: false, seq: this.mirrorSeq };
199
+ }
200
+ if (page !== this.mirrorPage || !this.mirrorSession) {
201
+ await this.startMirror(page);
202
+ }
203
+ const frame = this.mirrorFrame;
204
+ if (!frame || this.mirrorSeq <= since) {
205
+ return { running: true, changed: false, seq: this.mirrorSeq };
206
+ }
207
+ return {
208
+ running: true,
209
+ changed: true,
210
+ seq: this.mirrorSeq,
211
+ url: page.url(),
212
+ title: await page.title().catch(() => ""),
213
+ image: `data:image/jpeg;base64,${frame.data}`,
214
+ };
215
+ }
130
216
  async search(rawQuery, rawUrl) {
131
217
  const query = rawQuery.trim();
132
218
  if (!query)
@@ -139,6 +225,7 @@ export class BrowserAutomationService {
139
225
  const page = await this.page();
140
226
  await page.bringToFront();
141
227
  await page.goto(normalized.url, { waitUntil: "domcontentloaded", timeout: 30_000 });
228
+ await settleNavigation(page);
142
229
  const input = await findSearchInput(page);
143
230
  if (!input) {
144
231
  throw new BrowserAutomationError("SEARCH_INPUT_NOT_FOUND", "the search page did not expose a recognizable search input");
@@ -148,14 +235,22 @@ export class BrowserAutomationService {
148
235
  delay: SEARCH_TYPING_DELAY_MS,
149
236
  timeout: DEFAULT_ACTION_TIMEOUT_MS,
150
237
  });
151
- await Promise.all([
152
- page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 30_000 }),
153
- submitSearchForm(page, input),
238
+ // Search forms may navigate (URL change) or submit client-side (no URL
239
+ // change). Wait for whichever settles first, but never return while a
240
+ // navigation is still pending: url-commit and network-idle both race here.
241
+ const beforeSubmit = page.url();
242
+ await submitSearchForm(input);
243
+ await Promise.race([
244
+ page.waitForURL((url) => url.toString() !== beforeSubmit, { timeout: SEARCH_RESULT_TIMEOUT_MS, waitUntil: "domcontentloaded" }).catch(() => undefined),
245
+ page.waitForLoadState("networkidle", { timeout: SEARCH_RESULT_TIMEOUT_MS }).catch(() => undefined),
154
246
  ]);
247
+ await settleNavigation(page);
155
248
  return this.snapshot({ screenshot: false });
156
249
  }
157
250
  async act(request) {
251
+ validateBrowserInput(request);
158
252
  const page = await this.page();
253
+ this.checkSnapshot(page, request.snapshotId);
159
254
  const ref = request.ref.trim();
160
255
  if (!/^(f\d+)?e\d+$/.test(ref)) {
161
256
  throw new BrowserAutomationError("INVALID_REF", "element ref must come from a fresh browser snapshot");
@@ -171,6 +266,12 @@ export class BrowserAutomationService {
171
266
  else if (request.type === "press") {
172
267
  await locator.press(request.key, { timeout: DEFAULT_ACTION_TIMEOUT_MS });
173
268
  }
269
+ else if (request.type === "select") {
270
+ await locator.selectOption(request.options, { timeout: DEFAULT_ACTION_TIMEOUT_MS });
271
+ }
272
+ else if (request.type === "setChecked") {
273
+ await locator.setChecked(request.checked, { timeout: DEFAULT_ACTION_TIMEOUT_MS });
274
+ }
174
275
  else {
175
276
  await locator.hover({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
176
277
  }
@@ -184,6 +285,21 @@ export class BrowserAutomationService {
184
285
  await page.waitForLoadState("domcontentloaded", { timeout: 3_000 }).catch(() => undefined);
185
286
  return { action: request.type, ref, page: await this.snapshot({ screenshot: false }) };
186
287
  }
288
+ /** Reading diagnostics never launches a browser or consumes snapshot/download state. */
289
+ consoleMessages(query = {}) {
290
+ return this.diagnostics.console(query, this.diagnosticTabId(query.tabId));
291
+ }
292
+ networkRequests(query = {}) {
293
+ return this.diagnostics.network(query, this.diagnosticTabId(query.tabId));
294
+ }
295
+ diagnosticTabId(tabId) {
296
+ if (tabId !== undefined) {
297
+ if (!this.tabs.has(tabId))
298
+ throw new BrowserAutomationError("TAB_NOT_FOUND", `tab ${tabId} does not exist`);
299
+ return tabId;
300
+ }
301
+ return this.activePage ? tabIdForValue(this.tabs, this.activePage) ?? null : null;
302
+ }
187
303
  async scroll(input) {
188
304
  const page = await this.page();
189
305
  const amount = clamp(input.amount ?? 600, 50, 3000);
@@ -201,7 +317,7 @@ export class BrowserAutomationService {
201
317
  const page = this.tabs.get(tabId);
202
318
  if (!page)
203
319
  throw new BrowserAutomationError("TAB_NOT_FOUND", `tab ${tabId} does not exist`);
204
- this.activePage = page;
320
+ this.selectPage(page);
205
321
  await page.bringToFront();
206
322
  return this.snapshot({ screenshot: false });
207
323
  }
@@ -209,7 +325,7 @@ export class BrowserAutomationService {
209
325
  const context = await this.context();
210
326
  const page = await context.newPage();
211
327
  this.trackPage(page);
212
- this.activePage = page;
328
+ this.selectPage(page);
213
329
  if (rawUrl) {
214
330
  const result = await this.navigate(rawUrl);
215
331
  if (!result.ok)
@@ -222,10 +338,12 @@ export class BrowserAutomationService {
222
338
  const page = tabId === undefined ? await this.page() : this.tabs.get(tabId);
223
339
  if (!page)
224
340
  throw new BrowserAutomationError("TAB_NOT_FOUND", `tab ${tabId} does not exist`);
225
- this.tabs.delete(tabIdForValue(this.tabs, page) ?? 0);
226
341
  await page.close();
227
342
  if (this.activePage === page)
228
343
  this.activePage = undefined;
344
+ const current = this.operations.current;
345
+ if (current?.target.page === page)
346
+ current.target.page = undefined;
229
347
  return this.snapshot({ screenshot: false });
230
348
  }
231
349
  async handleDialog(action, promptText) {
@@ -266,10 +384,17 @@ export class BrowserAutomationService {
266
384
  await page.keyboard.type(text);
267
385
  return { ok: true };
268
386
  }
269
- async screenshot() {
270
- return this.snapshot({ screenshot: true });
387
+ async screenshot(options) {
388
+ return this.snapshot({ ...options, screenshot: true });
389
+ }
390
+ /** True when this service runs a visible browser instead of the background default. */
391
+ get visibleBrowser() {
392
+ return this.options.headless === false;
271
393
  }
272
394
  async focus() {
395
+ if (!this.visibleBrowser) {
396
+ throw new BrowserAutomationError("HEADLESS_NO_WINDOW", "浏览器正在后台运行(LOOP_BROWSER_HEADLESS=1),没有可聚焦的窗口;取消该设置并重启 Console 可恢复可见窗口,或在右侧面板预览当前页面");
397
+ }
273
398
  const page = await this.page();
274
399
  await page.bringToFront();
275
400
  return { ok: true };
@@ -279,12 +404,136 @@ export class BrowserAutomationService {
279
404
  await page.waitForTimeout(clamp(ms, 0, 10_000));
280
405
  return this.snapshot({ screenshot: false });
281
406
  }
407
+ async pick(input) {
408
+ if (this.options.headless === true)
409
+ throw new BrowserAutomationError("PICK_UNAVAILABLE", "human picking requires the visible external browser; unset LOOP_BROWSER_HEADLESS (or set it to 0) and restart the console to launch one");
410
+ const page = await this.page();
411
+ await page.bringToFront();
412
+ const element = await pickBrowserElement(page, input.message, clamp(input.timeoutMs ?? 30_000, 1, 60_000), this.operations.current?.signal).catch((error) => {
413
+ if (error instanceof Error && "code" in error && error.code === "PAGE_BUSY")
414
+ this.recovering.add(page);
415
+ throw error;
416
+ });
417
+ try {
418
+ const snapshot = await this.snapshot({ screenshot: false });
419
+ for (const match of snapshot.ariaSnapshot.matchAll(/\[ref=((?:f\d+)?e\d+)\]/g)) {
420
+ const ref = match[1];
421
+ if (!ref)
422
+ continue;
423
+ const same = await page.locator(`aria-ref=${ref}`).evaluate((candidate, selected) => candidate === selected, element).catch(() => false);
424
+ if (same)
425
+ return { ref, tabId: snapshot.tabId, snapshotId: snapshot.snapshotId, page: snapshot };
426
+ }
427
+ throw new BrowserAutomationError("PICK_UNSUPPORTED", "selected element has no current ARIA ref (including unsupported frames); select an accessible control or inspect with eval");
428
+ }
429
+ finally {
430
+ await element.dispose();
431
+ }
432
+ }
433
+ async find(input) {
434
+ if (!input.text.trim())
435
+ throw new BrowserAutomationError("EMPTY_QUERY", "find requires visible text");
436
+ const page = await this.page();
437
+ const snapshot = await this.snapshot({ screenshot: false });
438
+ const candidates = [];
439
+ const limit = clamp(input.limit ?? 20, 1, 100);
440
+ let matchCount = 0;
441
+ for (const line of snapshot.ariaSnapshot.split("\n")) {
442
+ const match = /^\s*- (\w+)(?: "(.*?)")?[^\n]*?\[ref=((?:f\d+)?e\d+)\]/.exec(line);
443
+ if (!match?.[1] || !match[3])
444
+ continue;
445
+ const role = match[1];
446
+ const name = match[2] ?? "";
447
+ if (input.role && role !== input.role)
448
+ continue;
449
+ if (!name.toLocaleLowerCase().includes(input.text.toLocaleLowerCase()))
450
+ continue;
451
+ const locator = page.locator(`aria-ref=${match[3]}`);
452
+ if (!await locator.isVisible())
453
+ continue;
454
+ matchCount += 1;
455
+ if (candidates.length >= limit)
456
+ continue;
457
+ const context = await locator.evaluate((element) => (element.closest('tr,[role="row"],li,fieldset,section,[role="dialog"]')?.textContent ?? element.parentElement?.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 500));
458
+ candidates.push({ ref: match[3], role, name, context });
459
+ }
460
+ return { tabId: snapshot.tabId, snapshotId: snapshot.snapshotId, url: snapshot.url, candidates, matchCount, truncated: matchCount > candidates.length || snapshot.ariaSnapshot.includes("[snapshot truncated]") };
461
+ }
462
+ async content(input = {}) {
463
+ const page = await this.page();
464
+ if (input.ref)
465
+ this.checkSnapshot(page, input.snapshotId);
466
+ if (input.ref && !/^(f\d+)?e\d+$/.test(input.ref))
467
+ throw new BrowserAutomationError("INVALID_REF", "content region requires a current ref");
468
+ const html = await captureBrowserHtml(page.locator(input.ref ? `aria-ref=${input.ref}` : "html"));
469
+ return { tabId: this.trackPage(page), url: page.url(), ...browserHtmlToMarkdown(html, page.url(), input.mode) };
470
+ }
471
+ async cookies(includeValues = false) {
472
+ const context = await this.context();
473
+ const cookies = await context.cookies();
474
+ return { cookies: cookies.map(({ value, ...cookie }) => includeValues ? { ...cookie, value } : cookie), valuesIncluded: includeValues };
475
+ }
476
+ async exportStorage() {
477
+ return exportBrowserStorage(await this.context(), path.join(this.options.profileDir, "..", "storage"));
478
+ }
479
+ async importStorage(filename, mode) {
480
+ if (mode !== "merge")
481
+ throw new BrowserAutomationError("INVALID_STORAGE_MODE", "explicit merge mode is required; existing entries not listed in the file are preserved");
482
+ const state = await readBrowserStorage(filename, path.join(this.options.profileDir, "..", "storage"));
483
+ for (const page of this.tabs.values()) {
484
+ if (this.recovering.has(page))
485
+ throw new BrowserAutomationError("PAGE_BUSY", "wait for page scripts before importing login state");
486
+ this.snapshots.delete(page);
487
+ }
488
+ return mergeBrowserStorage(await this.context(), state);
489
+ }
490
+ async evaluate(expression, options = {}) {
491
+ if (!expression.trim() || expression.length > 100_000)
492
+ throw new BrowserAutomationError("INVALID_EXPRESSION", "provide a nonempty expression up to 100000 characters; use an async IIFE for statements");
493
+ const page = await this.page();
494
+ this.snapshots.delete(page);
495
+ this.recovering.add(page);
496
+ const pending = evaluateBrowserExpression(page, expression).finally(() => {
497
+ this.recovering.delete(page);
498
+ this.snapshots.delete(page);
499
+ });
500
+ return waitForBrowserOperation(pending, clamp(options.timeoutMs ?? 15_000, 1, 60_000), this.operations.current?.signal);
501
+ }
502
+ async waitUntil(input) {
503
+ if ([input.text, input.url, input.ref].filter((value) => value !== undefined).length !== 1) {
504
+ throw new BrowserAutomationError("INVALID_WAIT", "specify exactly one of text, url or ref");
505
+ }
506
+ const page = await this.page();
507
+ const timeout = clamp(input.timeoutMs ?? 15_000, 1, 60_000);
508
+ try {
509
+ if (input.url !== undefined)
510
+ await page.waitForURL(input.url, { timeout, waitUntil: "domcontentloaded" });
511
+ else if (input.text !== undefined)
512
+ await page.getByText(input.text, { exact: true }).first().waitFor({ state: input.state ?? "visible", timeout });
513
+ else {
514
+ this.checkSnapshot(page, input.snapshotId);
515
+ if (!/^(f\d+)?e\d+$/.test(input.ref ?? ""))
516
+ throw new BrowserAutomationError("INVALID_REF", "invalid wait ref");
517
+ await page.locator(`aria-ref=${input.ref}`).waitFor({ state: input.state ?? "visible", timeout });
518
+ }
519
+ }
520
+ catch (cause) {
521
+ if (cause instanceof BrowserAutomationError)
522
+ throw cause;
523
+ throw new BrowserAutomationError("WAIT_TIMEOUT", errorMessage(cause), { cause });
524
+ }
525
+ return this.snapshot({ screenshot: false });
526
+ }
282
527
  async dispose() {
283
528
  if (this.disposed)
284
529
  return;
285
530
  this.disposed = true;
531
+ await this.stopMirror().catch(() => undefined);
286
532
  const pending = this.browserPromise;
287
533
  this.browserPromise = undefined;
534
+ this.diagnostics.reset();
535
+ this.dialogQueue.length = 0;
536
+ this.downloadQueue.length = 0;
288
537
  this.tabs.clear();
289
538
  this.activePage = undefined;
290
539
  if (!pending)
@@ -292,18 +541,44 @@ export class BrowserAutomationService {
292
541
  const context = await pending.catch(() => undefined);
293
542
  await context?.close().catch(() => undefined);
294
543
  }
544
+ checkSnapshot(page, snapshotId) {
545
+ if (this.operations.current && !snapshotId)
546
+ throw new BrowserAutomationError("INVALID_REF", "snapshotId is required for ref operations");
547
+ if (snapshotId !== undefined && this.snapshots.get(page) !== snapshotId) {
548
+ throw new BrowserAutomationError("STALE_REF", "snapshot is stale or belongs to another tab; take a fresh snapshot");
549
+ }
550
+ }
551
+ /** Updates the operation-local page as well as the UI's last-selected page. */
552
+ selectPage(page) {
553
+ this.activePage = page;
554
+ const current = this.operations.current;
555
+ if (current)
556
+ current.target.page = page;
557
+ }
295
558
  async page() {
559
+ const bound = this.operations.current?.target.page;
560
+ if (bound) {
561
+ if (bound.isClosed())
562
+ throw new BrowserAutomationError("TAB_NOT_FOUND", "bound tab was closed; select a current tab");
563
+ if (this.recovering.has(bound))
564
+ throw new BrowserAutomationError("PAGE_BUSY", "page script may still be running; wait or explicitly close this tab");
565
+ return bound;
566
+ }
296
567
  const context = await this.context();
297
- if (this.activePage && !this.activePage.isClosed())
568
+ if (this.activePage && !this.activePage.isClosed()) {
569
+ if (this.recovering.has(this.activePage))
570
+ throw new BrowserAutomationError("PAGE_BUSY", "page script may still be running; wait or explicitly close this tab");
571
+ this.selectPage(this.activePage);
298
572
  return this.activePage;
573
+ }
299
574
  const existing = context.pages().find((page) => this.tabs.has(tabIdForValue(this.tabs, page) ?? -1));
300
575
  if (existing) {
301
- this.activePage = existing;
576
+ this.selectPage(existing);
302
577
  return existing;
303
578
  }
304
579
  const page = await context.newPage();
305
580
  this.trackPage(page);
306
- this.activePage = page;
581
+ this.selectPage(page);
307
582
  return page;
308
583
  }
309
584
  async context() {
@@ -316,6 +591,10 @@ export class BrowserAutomationService {
316
591
  mkdir(this.options.screenshotsDir, { recursive: true }),
317
592
  ]);
318
593
  const browser = await this.launchBrowser();
594
+ if (this.disposed) {
595
+ await browser.close();
596
+ throw new BrowserAutomationError("BROWSER_CLOSED", "browser service is disposed");
597
+ }
319
598
  this.wireContext(browser);
320
599
  const page = browser.pages()[0] ?? await browser.newPage();
321
600
  this.trackPage(page);
@@ -333,6 +612,9 @@ export class BrowserAutomationService {
333
612
  }
334
613
  }
335
614
  wireContext(context) {
615
+ this.diagnostics.attachContext(context, (page) => this.trackPage(page));
616
+ for (const page of context.pages())
617
+ this.trackPage(page);
336
618
  context.on("page", (page) => {
337
619
  this.trackPage(page);
338
620
  if (!this.activePage || this.activePage.isClosed())
@@ -344,7 +626,13 @@ export class BrowserAutomationService {
344
626
  if (current === context) {
345
627
  this.browserPromise = undefined;
346
628
  this.activePage = undefined;
629
+ this.diagnostics.reset();
630
+ this.dialogQueue.length = 0;
631
+ this.downloadQueue.length = 0;
347
632
  this.tabs.clear();
633
+ this.mirrorPage = undefined;
634
+ this.mirrorSession = undefined;
635
+ this.mirrorFrame = undefined;
348
636
  }
349
637
  });
350
638
  }
@@ -385,13 +673,60 @@ export class BrowserAutomationService {
385
673
  const id = this.nextTabId;
386
674
  this.nextTabId += 1;
387
675
  this.tabs.set(id, page);
676
+ this.diagnostics.trackPage(page, id);
677
+ page.on("framenavigated", () => this.snapshots.delete(page));
388
678
  page.on("close", () => {
679
+ this.diagnostics.removePage(id);
389
680
  this.tabs.delete(id);
390
681
  if (this.activePage === page)
391
682
  this.activePage = undefined;
683
+ if (this.mirrorPage === page) {
684
+ this.mirrorPage = undefined;
685
+ this.mirrorSession = undefined;
686
+ this.mirrorFrame = undefined;
687
+ }
392
688
  });
393
689
  return id;
394
690
  }
691
+ /** Attaches a CDP screencast to the page, replacing any previous session. */
692
+ async startMirror(page) {
693
+ await this.stopMirror();
694
+ this.mirrorFrame = undefined;
695
+ try {
696
+ const session = await page.context().newCDPSession(page);
697
+ session.on("Page.screencastFrame", (frame) => {
698
+ this.mirrorSeq += 1;
699
+ this.mirrorFrame = { data: frame.data };
700
+ void session.send("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => undefined);
701
+ });
702
+ await session.send("Page.startScreencast", {
703
+ format: "jpeg",
704
+ quality: 85,
705
+ maxWidth: BROWSER_VIEWPORT_WIDTH,
706
+ maxHeight: BROWSER_VIEWPORT_HEIGHT,
707
+ everyNthFrame: 1,
708
+ });
709
+ this.mirrorSession = session;
710
+ this.mirrorPage = page;
711
+ }
712
+ catch {
713
+ await this.stopMirror().catch(() => undefined);
714
+ }
715
+ }
716
+ async stopMirror() {
717
+ const session = this.mirrorSession;
718
+ this.mirrorSession = undefined;
719
+ this.mirrorPage = undefined;
720
+ if (!session)
721
+ return;
722
+ try {
723
+ await session.send("Page.stopScreencast");
724
+ }
725
+ catch {
726
+ // The session may already be gone with its page or context.
727
+ }
728
+ await session.detach().catch(() => undefined);
729
+ }
395
730
  dialogFor(page) {
396
731
  const index = this.dialogQueue.findIndex((entry) => entry.page === page);
397
732
  const dialog = index >= 0 ? this.dialogQueue[index]?.dialog : undefined;
@@ -407,19 +742,34 @@ export class BrowserAutomationService {
407
742
  return Promise.all([...this.tabs.entries()].map(async ([id, page]) => ({
408
743
  id,
409
744
  url: page.url(),
410
- title: page.isClosed() ? "" : await page.title().catch(() => ""),
745
+ title: page.isClosed() || this.recovering.has(page) ? "" : await page.title().catch(() => ""),
411
746
  active: !page.isClosed() && page === this.activePage,
412
747
  })));
413
748
  }
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")}`;
749
+ async readScreenshot(filename) {
750
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/.test(filename)) {
751
+ throw new BrowserAutomationError("SCREENSHOT_NOT_FOUND", "invalid screenshot identity");
752
+ }
753
+ const handle = await open(path.join(this.options.screenshotsDir, filename), constants.O_RDONLY | constants.O_NOFOLLOW);
754
+ try {
755
+ const stat = await handle.stat();
756
+ if (!stat.isFile() || stat.size > 32 * 1024 * 1024)
757
+ throw new BrowserAutomationError("SCREENSHOT_TOO_LARGE", "screenshot exceeds the 32 MiB preview limit");
758
+ return await handle.readFile();
759
+ }
760
+ finally {
761
+ await handle.close();
762
+ }
763
+ }
764
+ async captureScreenshot(page, fullPage = false) {
765
+ const buffer = await page.screenshot({ type: "png", fullPage, timeout: 15_000 });
766
+ const screenshotPath = path.join(this.options.screenshotsDir, `${randomUUID()}.png`);
767
+ await writeFile(screenshotPath, buffer, { mode: 0o600, flag: "wx" });
768
+ const screenshotUrl = this.options.workspaceId ? `/api/operator/v1/chat/browser/screenshots/${path.basename(screenshotPath)}?workspaceId=${encodeURIComponent(this.options.workspaceId)}` : undefined;
769
+ if (buffer.byteLength > MAX_SCREENSHOT_BYTES) {
770
+ return { screenshotPath, screenshotUrl, screenshotError: "SCREENSHOT_TOO_LARGE: image exceeds 4 MiB; evidence saved, request a viewport screenshot" };
771
+ }
772
+ return { screenshotPath, screenshotUrl, screenshot: `data:image/png;base64,${buffer.toString("base64")}` };
423
773
  }
424
774
  async nextDownloadResult() {
425
775
  const index = this.downloadQueue.findIndex((entry) => !entry.consumed);
@@ -446,6 +796,25 @@ export class BrowserAutomationService {
446
796
  }
447
797
  }
448
798
  }
799
+ function validateBrowserInput(request) {
800
+ if (request.type === "setChecked" && typeof request.checked !== "boolean") {
801
+ throw new BrowserAutomationError("INVALID_ACTION", "setChecked requires an explicit checked boolean");
802
+ }
803
+ if (request.type !== "select")
804
+ return;
805
+ if (!Array.isArray(request.options) || request.options.length > 100 || !request.options.every(validSelectOption)) {
806
+ throw new BrowserAutomationError("INVALID_ACTION", "select requires up to 100 options, each with exactly one value, label or non-negative index");
807
+ }
808
+ }
809
+ function validSelectOption(option) {
810
+ if (!option || typeof option !== "object" || Object.keys(option).length !== 1)
811
+ return false;
812
+ if ("value" in option)
813
+ return typeof option.value === "string";
814
+ if ("label" in option)
815
+ return typeof option.label === "string";
816
+ return "index" in option && Number.isSafeInteger(option.index) && option.index >= 0;
817
+ }
449
818
  function emptyBrowserState() {
450
819
  return {
451
820
  running: false,
@@ -456,17 +825,55 @@ function emptyBrowserState() {
456
825
  ariaSnapshot: "",
457
826
  };
458
827
  }
459
- function chromiumLaunchOptions(headless) {
828
+ /** Linux GitHub runners (and similar CI) have no usable Chromium sandbox. */
829
+ export function chromiumSandboxEnabled(env = process.env) {
830
+ return env.GITHUB_ACTIONS !== "true" && env.CI !== "true";
831
+ }
832
+ export function chromiumLaunchOptions(headless, env = process.env) {
460
833
  return {
461
834
  headless: headless ?? false,
462
835
  viewport: undefined,
463
836
  acceptDownloads: true,
464
837
  locale: "zh-CN",
465
838
  timezoneId: "Asia/Shanghai",
466
- args: ["--start-maximized", "--disable-blink-features=AutomationControlled"],
839
+ // Desktop: drop Playwright's implicit --no-sandbox so Chrome does not show
840
+ // the "unsupported command-line flag" infobar. CI Linux has no usable
841
+ // user-namespace sandbox, so keep Playwright's --no-sandbox there.
842
+ chromiumSandbox: chromiumSandboxEnabled(env),
843
+ args: ["--disable-blink-features=AutomationControlled"],
467
844
  ignoreDefaultArgs: ["--enable-automation"],
468
845
  };
469
846
  }
847
+ /** LOOP_BROWSER_HEADLESS=1/true/on runs the browser in the background (headless). */
848
+ export function browserHeadlessEnabled(env = process.env) {
849
+ const raw = env.LOOP_BROWSER_HEADLESS?.trim().toLowerCase();
850
+ return raw === "1" || raw === "true" || raw === "on";
851
+ }
852
+ /**
853
+ * Bounded settle after a committed navigation. `domcontentloaded` fires before
854
+ * late client-side redirects (for example an engine that reroutes to a regional
855
+ * host), so a trailing short stability window keeps the next operation from
856
+ * racing an in-flight navigation. The cap keeps slow or long-polling pages from
857
+ * stalling the tool.
858
+ */
859
+ async function settleNavigation(page) {
860
+ const deadline = Date.now() + NAVIGATION_SETTLE_CAP_MS;
861
+ let lastUrl = page.url();
862
+ let stableSince = Date.now();
863
+ while (Date.now() < deadline) {
864
+ await page.waitForTimeout(100).catch(() => undefined);
865
+ if (page.isClosed())
866
+ return;
867
+ const current = page.url();
868
+ if (current !== lastUrl) {
869
+ lastUrl = current;
870
+ stableSince = Date.now();
871
+ continue;
872
+ }
873
+ if (Date.now() - stableSince >= NAVIGATION_SETTLE_WINDOW_MS)
874
+ return;
875
+ }
876
+ }
470
877
  async function findSearchInput(page) {
471
878
  for (const selector of SEARCH_INPUT_SELECTORS) {
472
879
  const locator = page.locator(selector).first();
@@ -475,13 +882,20 @@ async function findSearchInput(page) {
475
882
  }
476
883
  return undefined;
477
884
  }
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 });
484
- return;
885
+ async function submitSearchForm(input) {
886
+ // Prefer a submit control INSIDE the input's owning form. A page-level
887
+ // `button:not([type])` fallback can click an unrelated chrome button (for
888
+ // example a headful search engine renders extra visible buttons), which
889
+ // looks like a submit but never navigates.
890
+ const form = input.locator("xpath=ancestor-or-self::form[1]");
891
+ if (await form.count().catch(() => 0) > 0) {
892
+ for (const selector of SEARCH_SUBMIT_SELECTORS) {
893
+ const locator = form.locator(selector).first();
894
+ if (!await locator.isVisible().catch(() => false))
895
+ continue;
896
+ await locator.click({ timeout: DEFAULT_ACTION_TIMEOUT_MS });
897
+ return;
898
+ }
485
899
  }
486
900
  await input.press("Enter", { timeout: DEFAULT_ACTION_TIMEOUT_MS });
487
901
  }