@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.
- package/CHANGELOG.md +27 -2
- package/README.md +12 -12
- package/dist/adapters/loop-agent.js +34 -0
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +5 -5
- package/dist/cli/program.js +1 -4
- package/dist/commands/init-legacy-audit.js +330 -0
- package/dist/commands/init-upgrade.js +644 -104
- package/dist/commands/init.js +686 -330
- package/dist/commands/operator-guide.js +99 -0
- package/dist/commands/operator.js +16 -3
- package/dist/executors/dag-pi-executor.js +11 -6
- package/dist/executors/pi-sdk-executor.js +3 -1
- package/dist/executors/pi-writer-tool-policy.js +60 -4
- package/dist/executors/playwright-cli-launcher.js +31 -45
- package/dist/executors/shell-write-guard.js +24 -67
- package/dist/governance/exec-plans.js +10 -1
- package/dist/governance/harness.js +2 -7
- package/dist/governance/manifest-types.js +28 -9
- package/dist/governance/project-capability-scanner.js +263 -0
- package/dist/governance/project-config.js +96 -0
- package/dist/governance/resource-catalog.js +151 -0
- package/dist/infrastructure/console/app-data.js +6 -2
- package/dist/shared/operator/capabilities.js +2 -2
- package/dist/shared/package-metadata.js +23 -7
- package/dist/shared/pi-context-pressure/extension.js +23 -0
- package/dist/shared/pi-context-pressure/index.js +1 -0
- package/dist/shared/pi-context-pressure/telemetry.js +4 -0
- package/dist/shared/pi-context-pressure/tool-result-pruner.js +98 -0
- package/dist/shared/update/npm-client.js +49 -1
- package/dist/task/config-types.js +4 -2
- package/dist/task/source-prepare/build-draft.js +1 -0
- package/dist/task/source-prepare/path-policy.js +17 -7
- package/dist/worker/cli.js +3 -5
- package/dist/worker/console/chat/browser-automation.js +455 -41
- package/dist/worker/console/chat/browser-content.js +74 -0
- package/dist/worker/console/chat/browser-diagnostics.js +250 -0
- package/dist/worker/console/chat/browser-evaluation.js +58 -0
- package/dist/worker/console/chat/browser-operation-queue.js +19 -0
- package/dist/worker/console/chat/browser-pick.js +113 -0
- package/dist/worker/console/chat/browser-routes.js +54 -5
- package/dist/worker/console/chat/browser-screenshot-result.js +47 -0
- package/dist/worker/console/chat/browser-storage.js +89 -0
- package/dist/worker/console/chat/chat-event-store.js +7 -6
- package/dist/worker/console/chat/context-insights.js +84 -19
- package/dist/worker/console/chat/pet/pet-affinity.js +169 -0
- package/dist/worker/console/chat/pet/pet-persist.js +109 -0
- package/dist/worker/console/chat/pet/pet-registry.js +142 -0
- package/dist/worker/console/chat/pet/pet-remarks.js +48 -0
- package/dist/worker/console/chat/pet/pet-routes.js +125 -0
- package/dist/worker/console/chat/pet/pet-service.js +262 -0
- package/dist/worker/console/chat/pet/pet-state.js +99 -0
- package/dist/worker/console/chat/pi-mode-loop-isolation.js +17 -2
- package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +125 -174
- package/dist/worker/console/chat/pi-runtime.js +8 -3
- package/dist/worker/console/chat/repo-browser.js +80 -2
- package/dist/worker/console/chat/repo-preview-kind.js +155 -0
- package/dist/worker/console/chat/routes.js +43 -7
- package/dist/worker/console/chat/scheduled-goal-request.js +14 -6
- package/dist/worker/console/chat/session-mode.js +1 -1
- package/dist/worker/console/chat/session-store.js +24 -5
- package/dist/worker/console/chat/tools.js +12 -0
- package/dist/worker/console/console-shutdown.js +34 -0
- package/dist/worker/console/server.js +2 -1
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-r1NAPU_5.js → abnfDiagram-N423BO3Z-DASc69AM.js} +1 -1
- package/dist/worker/console/static/assets/{arc-dT5T4sVL.js → arc-h09p-LAc.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CP_cOq46.js → architectureDiagram-T3A2C74G-CZ3dtLkh.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-sToEZxEf.js → blockDiagram-VBNYF7ZC-DMrRhMXG.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DSxwwi2m.js → c4Diagram-5PPSVZJV-DQxisHV6.js} +1 -1
- package/dist/worker/console/static/assets/channel-D1zgKZfg.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-B9fh0IkF.js → chunk-2GRJ4B5K-CefVN9zh.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CAUNl-A6.js → chunk-2Q5K7J3B-DmR1Koq2.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BomeqbNX.js → chunk-5RXB4S5H-NlzrSW7L.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BNAuTOLE.js → chunk-5VM5RSS4-DfelJv-0.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DfzjRD5y.js → chunk-6Q2QTUOP-BK6RFy58.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C3uE-yc2.js → chunk-GF5L2VYU-BAeh9lz4.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-C3sQMMYr.js → chunk-JWPE2WC7-ChPS5WzG.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D_AtEYQL.js → chunk-KBJHAD2P-BoVe5IM6.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DW4TwhKv.js → chunk-RYQCIY6F-C4q_0Lw9.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CTQDmJcp.js → chunk-XXDRQBXY-Bb9TvjeY.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-CXNNsY2M.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-CXNNsY2M.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-iMqPuBsL.js → cose-bilkent-JH36ORCC-DmNnL8Ff.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DktIuDWs.js → cynefin-VYW2F7L2-Contvx58.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BgVnjyEs.js → cynefinDiagram-MW4NZA55-Cigq7cjk.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CR25E4bu.js → dagre-VZM6K2ZE-dEbC0nxb.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CPM2h5HY.js → diagram-7IWD3JNH-CzIztESV.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DqF0x2g1.js → diagram-B4RE2ZJO-Bo14zbaY.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-G8BYp2eb.js → diagram-LBJQPF4R-C4xUyil6.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BbC4kT_D.js → diagram-Q27KOJAE-CaBY_uYa.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-CKZwZ-G5.js → diagram-UB23O5K3-ClUXUeiu.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-kJDmheia.js → ebnfDiagram-BXEA7PRR-BbGCgjC6.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DFTVHyxB.js → erDiagram-JOGREHBK-Dr-AcwSl.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BqdN5_os.js → flowDiagram-UKHOOZJN-BG8Gcfyc.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-SYDjVakh.js → ganttDiagram-PKOTCBZU-CFVaPs5r.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bf8bNpGA.js → gitGraphDiagram-DS77QQ5N-ChJ8pm-O.js} +1 -1
- package/dist/worker/console/static/assets/index-B_5GhVIO.js +488 -0
- package/dist/worker/console/static/assets/index-C7YMBluX.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DIeAmJ45.js → infoDiagram-6WML65LV-C5J9-uCZ.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-oikqUAlo.js → ishikawaDiagram-WSZJBQD7-BcfjqzDv.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-mKEF_xYT.js → journeyDiagram-NVQOT4AX-BzzCO-rD.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BdWJHPnN.js → kanban-definition-27J2QSJJ-NCHUZ46h.js} +1 -1
- package/dist/worker/console/static/assets/{linear-DGRlieMH.js → linear-BKZlWCUU.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-4P9yPC9c.js → mermaid.core-DbTaQjFE.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-5PTSkos6.js → mindmap-definition-FAOFIHXS-CroYl5xQ.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DONIFpG5.js → pegDiagram-VL7TDLO6-DgMBx-9k.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-D_Z4S70M.js → pieDiagram-7S7Q4E2Y-CyWDmbq1.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CAsjh8iY.js → quadrantDiagram-CIZ2JOQS-BgGEOqSP.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Cx4Dd6ac.js → railroadDiagram-AXF67PYL-BMZ7oeeV.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-cQb7mwyH.js → requirementDiagram-LRYGKXZP-DxqyGhcc.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Cd-DhUdf.js → sankeyDiagram-W5VNT64P-DnSvNCG8.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-AlKYRgIk.js → sequenceDiagram-SI44F4Z6-BiZh6f-R.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CUy_ET4e.js → sizeCapture-X5ZJPWSS-BKXSI8WB.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-CTuVe6Sa.js → stateDiagram-OKZ733FA-CfSgoYFm.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DgHi3vjb.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-ChFLJb-v.js → swimlanes-SLNWSIFB-CyGADcHe.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bgvdt1Y0.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DZZeiAPr.js → timeline-definition-Z64GVDOM-HmPaSPOK.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-t4O91cZU.js → vennDiagram-T6HMQDX7-VA2VBbez.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CV5qGHZu.js → wardleyDiagram-T6FBY63Y-BxjiBPZa.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DyXbytUI.js → xychartDiagram-ELKLHX3M--hUY-qFj.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static/pets/ember/pet.json +167 -0
- package/dist/worker/console/static/pets/ember/spritesheet.png +0 -0
- package/dist/worker/console/static-src/operator-chat/details-rail-surfaces.js +4 -3
- package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +57 -9
- package/dist/worker/console/static-src/operator-chat/use-stat-dialog.js +95 -0
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +6 -0
- package/dist/worker/console/workspace-context.js +15 -0
- package/dist/worker/console/workspace-initialization.js +25 -16
- package/dist/worker/delivery/package.js +4 -1
- package/dist/worker/materialize/harness-task-lineage.js +4 -2
- package/dist/worker/observe/static/console-theme.css +33 -29
- package/dist/worker/observe/static/operator-chrome.js +1 -42
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +3 -1
- package/dist/workflows/dag/backend-test-layout.js +27 -7
- package/dist/workflows/dag/backend-test-markdown-workflow.js +3 -1
- package/dist/workflows/dag/backend-test-pytest-collection.js +4 -1
- package/dist/workflows/dag/final-verification.js +45 -10
- package/dist/workflows/dag/frontend-test-case-checklist.js +12 -3
- package/dist/workflows/dag/frontend-test-environment-probe.js +0 -2
- package/dist/workflows/dag/frontend-test-layout.js +41 -12
- package/dist/workflows/dag/hybrid/sources.js +35 -16
- package/dist/workflows/dag/hybrid/templates/backend-test.js +70 -55
- package/dist/workflows/dag/hybrid/templates/frontend-test.js +62 -41
- package/dist/workflows/dag/hybrid/templates/kg-bootstrap.js +6 -6
- package/dist/workflows/dag/hybrid/templates/knowledge-sync.js +5 -5
- package/dist/workflows/dag/skill-instructions.js +102 -32
- package/dist/workflows/dag/skill-snapshot.js +3 -0
- package/dist/workflows/dag/skills.js +7 -7
- package/dist/workflows/dag/test-artifact-namespace.js +73 -0
- package/dist/workflows/dag/validate.js +24 -5
- package/dist/workflows/dag/workspace-checkpoint.js +13 -1
- package/docs/architecture/runtime-boundaries.md +28 -22
- package/docs/init-surface.manifest.json +43 -295
- package/docs/templates/README.md +4 -4
- package/docs/templates/agent-dag.base.json +9 -9
- package/docs/templates/agent-dag.final-verification.json +6 -6
- package/docs/templates/agent-dag.supervised-implementation.json +12 -12
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +3 -3
- package/docs/templates/backend-test-dag.json +708 -708
- package/docs/templates/backend-test-dag.review-cases.prompt.md +3 -3
- package/docs/templates/frontend-implementation-dag.json +2 -2
- package/docs/templates/frontend-test-case-checklist.md +2 -2
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +7 -7
- package/docs/templates/frontend-test-dag.json +48 -39
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +2 -2
- package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/hybrid-dag.json +9 -9
- package/docs/templates/knowledge-sync-dag.json +8 -8
- package/package.json +12 -3
- package/resource-catalog.v1.json +1973 -0
- package/resources/agent-bridge/loop-agent/SKILL.md +40 -0
- package/resources/init-history/legacy-surface.v1.json +33 -0
- package/skills/fe-test-ui-scout/SKILL.md +4 -4
- package/skills/fe-test-ui-scout/references/ledger-schema.md +1 -1
- package/skills/init-update/SKILL.md +34 -0
- package/skills/init-update/agents/openai.yaml +6 -0
- package/skills/playwright-cli/SKILL.md +1 -1
- package/skills/playwright-cli-case-generator/SKILL.md +4 -4
- package/dist/worker/console/static/assets/channel-CyBY_yGk.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +0 -1
- package/dist/worker/console/static/assets/index-B28Onmfy.css +0 -1
- package/dist/worker/console/static/assets/index-CTMZsDmh.js +0 -486
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +0 -1
- 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 {
|
|
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.
|
|
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
|
-
:
|
|
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
|
-
?
|
|
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
|
-
...
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
576
|
+
this.selectPage(existing);
|
|
302
577
|
return existing;
|
|
303
578
|
}
|
|
304
579
|
const page = await context.newPage();
|
|
305
580
|
this.trackPage(page);
|
|
306
|
-
this.
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
}
|