diff --git a/NOTICE b/NOTICE index 12a00168..c9aba3e7 100644 --- a/NOTICE +++ b/NOTICE @@ -1 +1,35 @@ webcmd is based on opencli (https://github.com/jackwener/opencli), Copyright 2025 jackwener, licensed under Apache-2.0. + +The browser-run QuickJS lifecycle is derived from dev-browser +(https://github.com/SawyerHood/dev-browser), Copyright Sawyer Hood, +licensed under the MIT License. + +The browser-run Playwright QuickJS client includes Playwright code from +https://github.com/microsoft/playwright, licensed under Apache-2.0. + +The browser snapshot capture, model, renderer, diff, and page-stability modules +in src/browser/snapshot/capture.ts, types.ts, render.ts, diff.ts, and +wait-for-page-stable.ts are derived from libretto-browser-tools +(https://github.com/Skyvern-AI/libretto). + +MIT License + +Copyright (c) 2026 Libretto contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index f67647bc..ba7d9027 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ On top of live browser control, WebCMD adds 3 layers of learnings. Each layer co | 3. CLI authoring | The action space is known, but the path is still too variable for one fixed sequence. | Explicitly author a reusable `webcmd ` adapter with structured output, so future agents spend tokens on the task instead of navigation. | | 4. Extend existing CLIs | The workflow is deterministic enough to stop browsing. | Extend the `webcmd ` adapter with a tailored command so the workflow runs instantly with the least amount of tokens. | +For local, multi-step browser exploration, agents can send one sandboxed +Playwright-style program to an existing CloakBrowser session: + +```bash +webcmd browser work run --file explore.js +printf 'const page = await browser.currentPage(); return await page.title();' \ + | webcmd browser work run --stdin +``` + ## Demo https://github.com/user-attachments/assets/04eceadc-d398-4303-984d-ae3197bfa664 diff --git a/bun.lock b/bun.lock index 9c78574b..713ba223 100644 --- a/bun.lock +++ b/bun.lock @@ -7,14 +7,19 @@ "dependencies": { "@mozilla/readability": "^0.6.0", "cli-table3": "^0.6.5", + "cloakbrowser": "0.4.5", "commander": "^14.0.3", "js-yaml": "^4.3.0", + "playwright-core": "1.61.1", + "quickjs-emscripten": "0.32.0", "turndown": "^7.2.2", "turndown-plugin-gfm": "^1.0.2", "undici": "^6.27.0", "ws": "^8.18.0", }, "devDependencies": { + "@emnapi/runtime": "^1.11.2", + "@google/genai": "^2.10.0", "@types/js-yaml": "^4.0.9", "@types/jsdom": "^27.0.0", "@types/node": "^25.5.2", @@ -57,7 +62,7 @@ "@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], @@ -115,6 +120,20 @@ "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@google/genai": ["@google/genai@2.13.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-GM7C8Kaomvjz05x5JEO6+l3d/pciL9LxAG9dUjJLD7nTPZ9X0Cfsf2Z7eET6UjgWyUmxXCHtYnQoQ77F9+ZIOQ=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jitl/quickjs-ffi-types": ["@jitl/quickjs-ffi-types@0.32.0", "", {}, "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg=="], + + "@jitl/quickjs-wasmfile-debug-asyncify": ["@jitl/quickjs-wasmfile-debug-asyncify@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw=="], + + "@jitl/quickjs-wasmfile-debug-sync": ["@jitl/quickjs-wasmfile-debug-sync@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q=="], + + "@jitl/quickjs-wasmfile-release-asyncify": ["@jitl/quickjs-wasmfile-release-asyncify@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q=="], + + "@jitl/quickjs-wasmfile-release-sync": ["@jitl/quickjs-wasmfile-release-sync@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], @@ -125,6 +144,24 @@ "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.11", "", { "os": "android", "cpu": "arm64" }, "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg=="], @@ -173,6 +210,8 @@ "@types/node": ["@types/node@25.9.4", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], "@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="], @@ -193,30 +232,48 @@ "@vitest/utils": ["@vitest/utils@4.1.1", "", { "dependencies": { "@vitest/pretty-format": "4.1.1", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + "cloakbrowser": ["cloakbrowser@0.4.5", "", { "dependencies": { "tar": "^7.0.0" }, "peerDependencies": { "mmdb-lib": ">=2.0.0", "playwright-core": ">=1.53.0", "puppeteer-core": ">=21.0.0", "socks-proxy-agent": ">=10.0.0" }, "optionalPeers": ["mmdb-lib", "playwright-core", "puppeteer-core", "socks-proxy-agent"], "bin": { "cloakbrowser": "dist/cli.js" } }, "sha512-FLEOoznA/d4SbUT1zi8BiMqH+xt/eCoCWeLHnEC7Wn1WBGR31QHSh93PSfS/WcovGaxQxxOQPKtF8+1IkdEp1g=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -229,14 +286,30 @@ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "gaxios": ["gaxios@7.3.0", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], + "google-auth-library": ["google-auth-library@10.9.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], @@ -245,6 +318,12 @@ "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -269,16 +348,30 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], @@ -287,16 +380,28 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + "postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="], + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "quickjs-emscripten": ["quickjs-emscripten@0.32.0", "", { "dependencies": { "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", "@jitl/quickjs-wasmfile-release-sync": "0.32.0", "quickjs-emscripten-core": "0.32.0" } }, "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA=="], + + "quickjs-emscripten-core": ["quickjs-emscripten-core@0.32.0", "", { "dependencies": { "@jitl/quickjs-ffi-types": "0.32.0" } }, "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "rolldown": ["rolldown@1.0.0-rc.11", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.11" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-x64": "1.0.0-rc.11", "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" }, "bin": "bin/cli.mjs" }, "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], @@ -313,6 +418,8 @@ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], @@ -349,6 +456,8 @@ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], @@ -363,6 +472,10 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + "@types/ws/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], "jsdom/parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 4073a61d..353ab69c 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -55,6 +55,39 @@ webcmd web fetch-browser --url https://example.com/app-shell The old `web read` command has been renamed to `web fetch-browser`. +## Local Browser Programs + +`browser run` executes one Playwright-style JavaScript program against an +existing local CloakBrowser session: + +```bash +webcmd browser work snapshot --snapshot-mode act +webcmd browser work snapshot --snapshot-mode read +webcmd browser work run --stdin --timeout 45 +webcmd browser work run --stdin --no-snapshot-diff +``` + +Use `snapshot` for explicit page inspection. `act` is the default +action-first mode, `tree` preserves fuller page structure, and `read` extracts readable article/content text. Exactly one of `--file +` or `--stdin` is required for `run`. The CLI reads files locally and +sends source—not the path—to the local daemon. `--timeout` is in seconds and +`--max-output` bounds returned results and logs. Successful runs return a +`snapshotDiff` by default; pass `--no-snapshot-diff` only when the program is +pure read-only and its result already contains the needed state. + +The program runs in a fresh QuickJS sandbox with `page`, `context`, `browser`, +and `console` globals. `page.snapshotForAI()` is not available. It can use the +supported Page/Frame/Locator methods and passively inspect request and response +events. It cannot access Node.js, the filesystem, environment variables, raw +CDP endpoints, browser launch/connect APIs, or browser-context ownership. +Screenshot bytes are written to a Webcmd-owned cache directory and returned as +a receipt. + +The public browser surface is `tabs`, `bind`, `run`, `snapshot`, and `close`. +Reusable adapters continue to use the existing `IPage` API. Playwright-style +programs are for reconnaissance and ad-hoc multi-step work; they are not pasted +into adapter modules. + ## Top-Level Commands | Command | Purpose | @@ -148,6 +181,7 @@ Register our internal `releasectl` binary as a Webcmd external CLI with a short | `~/.webcmd/` | User-level Webcmd state. | | `~/.webcmd/clis/` | Private adapters and local overrides. | | `~/.webcmd/cache/browser-network/` | Browser network capture cache. | +| `~/.webcmd/cache/browser-run/` | Host-owned browser-run screenshot artifacts. | | `~/.webcmd/external-clis.yaml` | User external CLI registry. | | `skills/` | Bundled agent skills shipped with the package. | diff --git a/package-lock.json b/package-lock.json index d54cb40f..2b8f6de5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "js-yaml": "^4.3.0", "jsdom": "^29.0.2", "playwright-core": "1.61.1", + "quickjs-emscripten": "0.32.0", "turndown": "^7.2.2", "turndown-plugin-gfm": "^1.0.2", "undici": "^6.27.0", @@ -27,6 +28,7 @@ "webcmd": "dist/src/main.js" }, "devDependencies": { + "@emnapi/core": "^1.11.2", "@emnapi/runtime": "^1.11.2", "@google/genai": "^2.10.0", "@types/js-yaml": "^4.0.9", @@ -34,6 +36,7 @@ "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", "@types/ws": "^8.5.13", + "esbuild": "^0.28.1", "tsx": "^4.19.3", "typescript": "^6.0.2", "vitest": "^4.1.0" @@ -195,6 +198,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" }, @@ -241,36 +245,51 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=20.19.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, - "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, @@ -774,6 +793,48 @@ "node": ">=18.0.0" } }, + "node_modules/@jitl/quickjs-ffi-types": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", + "license": "MIT" + }, + "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz", + "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-debug-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz", + "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-asyncify": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz", + "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, + "node_modules/@jitl/quickjs-wasmfile-release-sync": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1117,6 +1178,18 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", @@ -1128,6 +1201,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", @@ -2609,6 +2693,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2621,6 +2706,7 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -2690,6 +2776,31 @@ "node": ">=6" } }, + "node_modules/quickjs-emscripten": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz", + "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "quickjs-emscripten-core": "0.32.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/quickjs-emscripten-core": { + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", + "license": "MIT", + "dependencies": { + "@jitl/quickjs-ffi-types": "0.32.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2953,6 +3064,7 @@ "integrity": "sha512-6w9FwtT8WQqRAyTNR+Z+86kghRqpmOLjXUrBlBT6T+CQGDuIMm0VmAqaFUFBIeKDTGobE6/YSigZYLeomzBaRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -3017,6 +3129,7 @@ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", diff --git a/package.json b/package.json index 92084509..36a4a5ca 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,9 @@ "./browser/ax-snapshot": "./dist/src/browser/ax-snapshot.js", "./browser/cdp": "./dist/src/browser/cdp.js", "./browser/page": "./dist/src/browser/page.js", + "./browser/run": "./dist/src/browser/run/index.js", + "./browser/snapshot": "./dist/src/browser/snapshot/index.js", + "./browser/article-extract": "./dist/src/browser/article-extract.js", "./browser/utils": "./dist/src/browser/utils.js", "./download": "./dist/src/download/index.js", "./download/article-download": "./dist/src/download/article-download.js", @@ -54,6 +57,7 @@ "sync-community-plugins": "tsx scripts/sync-community-plugins.ts", "generate-release-notes": "tsx scripts/generate-release-notes.ts", "docs-sync-review": "tsx scripts/docs-sync-review.ts", + "benchmark:snapshot": "tsx scripts/benchmark-snapshot-render.ts", "start": "node dist/src/main.js", "start:bun": "bun dist/src/main.js", "preuninstall": "node -e \"fetch('http://127.0.0.1:9777/shutdown',{method:'POST',headers:{'X-Webcmd':'1'},signal:AbortSignal.timeout(3000)}).catch(()=>{})\" || true", @@ -97,12 +101,14 @@ "js-yaml": "^4.3.0", "jsdom": "^29.0.2", "playwright-core": "1.61.1", + "quickjs-emscripten": "0.32.0", "turndown": "^7.2.2", "turndown-plugin-gfm": "^1.0.2", "undici": "^6.27.0", "ws": "^8.18.0" }, "devDependencies": { + "@emnapi/core": "^1.11.2", "@emnapi/runtime": "^1.11.2", "@google/genai": "^2.10.0", "@types/js-yaml": "^4.0.9", @@ -110,6 +116,7 @@ "@types/node": "^25.5.2", "@types/turndown": "^5.0.6", "@types/ws": "^8.5.13", + "esbuild": "^0.28.1", "tsx": "^4.19.3", "typescript": "^6.0.2", "vitest": "^4.1.0" diff --git a/scripts/benchmark-snapshot-render.ts b/scripts/benchmark-snapshot-render.ts new file mode 100644 index 00000000..4ed948e9 --- /dev/null +++ b/scripts/benchmark-snapshot-render.ts @@ -0,0 +1,494 @@ +import { createHash } from "node:crypto"; +import { performance } from "node:perf_hooks"; +import { + DEFAULT_ACT_SNAPSHOT_CHARS, + DEFAULT_TREE_SNAPSHOT_CHARS, + allocateSnapshot, + captureSnapshot, + diffSnapshots, + renderSnapshotDiff, + renderSnapshotFrames, + renderSnapshotResult, +} from "../src/browser/snapshot/index.js"; +import type { AiSnapshot, AiSnapshotFrame, AiSnapshotNode } from "../src/browser/snapshot/types.js"; +import type { Page } from "playwright-core"; + +const CANDIDATES = [4_096, 6_144, 8_192, 12_288, 16_384, 24_576, 32_768] as const; +const BASELINE_ACT_TOKENS = { + median: 3_072, + p95: 3_072, + corpus: "snapshot-calibration-v1", + source: "frozen 2026-08-06 isolated baseline measurement", +} as const; +const WARM_ITERATIONS = 200; +const MEASURED_ITERATIONS = 1_000; +const ACTION_ROLES = new Set([ + "button", "link", "textbox", "checkbox", "radio", "switch", "combobox", + "listbox", "menuitem", "tab", "slider", +]); +const RECORD_ROLES = new Set(["listitem", "row", "treeitem", "article"]); +const RECORD_PARENT_ROLES = new Set(["list", "table", "grid", "tree", "feed"]); +const CRITICAL_ROLES = new Set(["alert", "alertdialog", "dialog", "status"]); + +type Fixture = { id: string; snapshot: AiSnapshot }; +type Distribution = { median: number; p95: number }; +type CriticalIdentity = { ref: string; role: string; contentAndState: string[] }; + +function fixture(id: string, build: (node: NodeFactory) => AiSnapshotFrame[]): Fixture { + let nextId = 0; + const node: NodeFactory = (role, input = {}) => ({ + nodeId: `${id}-${++nextId}`, + ignored: input.ignored ?? false, + role, + name: input.name ?? null, + value: input.value ?? null, + description: input.description ?? null, + properties: input.properties ?? {}, + attributes: input.attributes ?? {}, + children: input.children ?? [], + ref: input.ref ?? null, + subtreeSize: input.subtreeSize ?? 1, + }); + return { + id, + snapshot: { title: `Fixture ${id}`, url: `https://fixtures.test/${id}`, frames: build(node) }, + }; +} + +type NodeInput = Partial>; +type NodeFactory = (role: string, input?: NodeInput) => AiSnapshotNode; + +function frame(id: string, roots: AiSnapshotNode[], index = 0, parentId: string | null = null): AiSnapshotFrame { + return { + status: "ok", + scope: "document", + id, + index, + url: `https://fixtures.test/${id}`, + name: index ? `Frame ${index}` : null, + parentId, + roots, + }; +} + +function text(node: NodeFactory, value: string): AiSnapshotNode { + return node("StaticText", { name: value }); +} + +const corpus: Fixture[] = [ + fixture("deep-navigation-v1", (node) => { + let nested: AiSnapshotNode | null = null; + for (let section = 7; section >= 0; section -= 1) + nested = node("navigation", { + ref: `nav-${section}`, + children: [ + node("list", { + ref: `nav-list-${section}`, + children: Array.from({ length: 18 }, (_, item) => node("listitem", { + name: `Navigation section ${section + 1} item ${item + 1}`, + ref: `nr-${section}-${item}`, + children: [node("link", { + name: `Open destination ${section + 1}-${item + 1}`, + ref: `na-${section}-${item}`, + attributes: { href: `/destination/${section + 1}/${item + 1}` }, + })], + })), + }), + ...(nested ? [nested] : []), + ], + }); + return [frame("deep-navigation", [nested!])]; + }), + fixture("records-300-v1", (node) => [frame("records-300", [node("list", { + ref: "records-root", + children: Array.from({ length: 300 }, (_, index) => node("listitem", { + name: `Result ${String(index + 1).padStart(3, "0")}`, + ref: `rr${index + 1}`, + children: [ + text(node, `Deterministic supporting detail ${index + 1} ${"x".repeat(36)}`), + node("button", { name: `Open ${index + 1}`, ref: `ra${index + 1}` }), + ], + })), + })])]), + fixture("products-120-v1", (node) => [frame("products-120", [node("grid", { + ref: "products-root", + children: Array.from({ length: 120 }, (_, index) => node("row", { + name: `Product ${String(index + 1).padStart(3, "0")}`, + ref: `product-${index + 1}`, + children: [ + text(node, `Product detail ${index + 1} ${"y".repeat(48)}`), + node("button", { name: `View ${index + 1}`, ref: `product-view-${index + 1}` }), + node("button", { name: `Add ${index + 1}`, ref: `product-add-${index + 1}` }), + ], + })), + })])]), + fixture("form-state-v1", (node) => [frame("form-state", [node("form", { + ref: "form-root", + children: Array.from({ length: 180 }, (_, index) => node("textbox", { + name: `Field ${String(index + 1).padStart(3, "0")}`, + ref: `field-${index + 1}`, + value: `value-${index + 1}`, + properties: index === 179 ? { focused: true, invalid: true, required: true } : { required: true }, + })), + })])]), + fixture("alerts-v1", (node) => [frame("alerts", [node("main", { + ref: "alerts-root", + children: [ + ...Array.from({ length: 80 }, (_, index) => node(index % 2 ? "status" : "alert", { + name: `Critical notice ${index + 1}`, + ref: `critical-${index + 1}`, + children: [text(node, `Critical notice detail ${index + 1}`)], + })), + ...Array.from({ length: 180 }, (_, index) => node("button", { + name: `Alert action ${index + 1}`, + ref: `alert-action-${index + 1}`, + properties: index === 179 ? { focused: true } : {}, + })), + ], + })])]), + fixture("nested-critical-v1", (node) => [frame("nested-critical", [node("main", { + ref: "nested-critical-root", + children: [ + node("alert", { + ref: "payment-alert", + children: [ + text(node, "ERROR"), + node("list", { + ref: "payment-alert-list", + children: [ + node("listitem", { + ref: "payment-alert-item", + children: [text(node, "PAYMENT FAILED")], + }), + ], + }), + ], + }), + ...Array.from({ length: 20 }, (_, index) => node("button", { + name: `Payment action ${index + 1}`, + ref: `payment-action-${index + 1}`, + })), + ], + })])]), + fixture("iframes-v1", (node) => Array.from({ length: 4 }, (_, frameIndex) => frame( + `iframe-${frameIndex}`, + [node("main", { + ref: `iframe-root-${frameIndex}`, + children: Array.from({ length: 80 }, (_, index) => node("button", { + name: `Frame ${frameIndex + 1} action ${index + 1}`, + ref: `iframe-action-${frameIndex}-${index}`, + })), + })], + frameIndex, + frameIndex ? "iframe-0" : null, + ))), + fixture("article-prose-v1", (node) => [frame("article-prose", [node("article", { + name: "Deterministic benchmark article", + ref: "article-root", + children: Array.from({ length: 140 }, (_, index) => node("section", { + name: `Section ${index + 1}`, + ref: `article-s${index + 1}`, + children: [ + node("paragraph", { children: [text(node, `Paragraph ${index + 1} ${"prose ".repeat(24)}`)] }), + node("link", { + name: `Article reference ${index + 1}`, + ref: `article-a${index + 1}`, + attributes: { href: `/article/reference/${index + 1}` }, + }), + ], + })), + })])]), +]; + +const nodes10000 = fixture("nodes-10000-v1", (node) => [frame("nodes-10000", [node("main", { + ref: "nodes-root", + children: [ + node("list", { + ref: "synthetic-list", + children: Array.from({ length: 999 }, (_, index) => node("listitem", { + name: `Synthetic record ${index + 1}`, + ref: `synthetic-record-${index + 1}`, + children: [ + ...Array.from({ length: 8 }, (_, part) => text(node, `Detail ${index + 1}-${part + 1}`)), + node("button", { name: `Open ${index + 1}`, ref: `synthetic-action-${index + 1}` }), + ], + })), + }), + ...Array.from({ length: 8 }, (_, index) => text(node, `Terminal ${index + 1}`)), + ], +})])]); + +function quantile(values: number[], fraction: number): number { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]!; +} + +function distribution(values: number[], digits = 3): Distribution { + const round = (value: number): number => Number(value.toFixed(digits)); + return { median: round(quantile(values, 0.5)), p95: round(quantile(values, 0.95)) }; +} + +function estimatedTokens(characters: number): number { + return Math.ceil(characters / 4); +} + +function timed(operation: () => unknown): Distribution { + for (let index = 0; index < WARM_ITERATIONS; index += 1) operation(); + const samples: number[] = []; + for (let index = 0; index < MEASURED_ITERATIONS; index += 1) { + const started = performance.now(); + operation(); + samples.push(performance.now() - started); + } + return distribution(samples); +} + +function allNodes(snapshot: AiSnapshot): Array<{ node: AiSnapshotNode; parentRole: string | null }> { + const result: Array<{ node: AiSnapshotNode; parentRole: string | null }> = []; + const visit = (node: AiSnapshotNode, parentRole: string | null): void => { + result.push({ node, parentRole }); + for (const child of node.children) visit(child, node.role); + }; + for (const currentFrame of snapshot.frames) + if (currentFrame.status === "ok") + for (const root of currentFrame.roots) visit(root, null); + return result; +} + +function descendantStaticText(node: AiSnapshotNode): string[] { + const values: string[] = []; + const visit = (current: AiSnapshotNode): void => { + if (current.role === "StaticText" && current.name) values.push(current.name); + for (const child of current.children) visit(child); + }; + for (const child of node.children) visit(child); + return values; +} + +function identities(fixtureValue: Fixture): { + actions: string[]; + records: string[]; + critical: CriticalIdentity[]; +} { + const actions: string[] = []; + const records: string[] = []; + const critical: CriticalIdentity[] = []; + for (const { node, parentRole } of allNodes(fixtureValue.snapshot)) { + if (!node.ref) continue; + if (ACTION_ROLES.has(node.role)) actions.push(node.ref); + if (RECORD_ROLES.has(node.role) && parentRole && RECORD_PARENT_ROLES.has(parentRole)) records.push(node.ref); + if ( + node.properties.focused === true || node.properties.invalid === true || + node.properties.invalid === "true" || CRITICAL_ROLES.has(node.role) + ) { + const contentAndState = [node.name, node.description] + .filter((value): value is string => Boolean(value)); + contentAndState.push(...descendantStaticText(node)); + for (const property of ["focused", "invalid", "checked", "selected", "expanded", "disabled", "pressed"]) + if (node.properties[property] !== undefined) + contentAndState.push(`${property}="${String(node.properties[property])}"`); + critical.push({ ref: node.ref, role: node.role, contentAndState }); + } + } + return { actions, records, critical }; +} + +function recalled(output: string, refs: string[]): number { + if (refs.length === 0) return 1; + return refs.filter((ref) => output.includes(`ref="${ref}"`)).length / refs.length; +} + +function criticalRecalled(output: string, identities: CriticalIdentity[]): number { + if (identities.length === 0) return 1; + return identities.filter(({ ref, role, contentAndState }) => { + const refIndex = output.indexOf(`ref="${ref}"`); + if (refIndex === -1) return false; + const blockStart = output.lastIndexOf("<", refIndex); + const closingTag = ``; + const closingIndex = output.indexOf(closingTag, refIndex); + const lineEnd = output.indexOf("\n", refIndex); + const blockEnd = closingIndex === -1 + ? (lineEnd === -1 ? output.length : lineEnd) + : closingIndex + closingTag.length; + const block = output.slice(blockStart, blockEnd); + return contentAndState.every((value) => block.includes(value)); + }).length / identities.length; +} + +function corpusStats(mode: "act" | "tree", maxChars: number): { + characters: Distribution; + tokens: Distribution; + outputOverruns: number; + outputs: string[]; +} { + const rendered = corpus.map(({ snapshot }) => renderSnapshotResult(snapshot, { mode, maxChars })); + const characters = rendered.map(({ value }) => value.length); + return { + characters: distribution(characters, 0), + tokens: distribution(characters.map(estimatedTokens), 0), + outputOverruns: rendered.filter(({ value }) => value.length > maxChars).length, + outputs: rendered.map(({ value }) => value), + }; +} + +const candidates = CANDIDATES.map((maxChars) => ({ maxChars, ...corpusStats("act", maxChars) })); +const recommendedActChars = candidates.filter(({ tokens }) => + tokens.median <= BASELINE_ACT_TOKENS.median && tokens.p95 <= BASELINE_ACT_TOKENS.p95 +).at(-1)?.maxChars ?? 0; + +const expected = corpus.map(identities); +const treeCandidates = CANDIDATES.filter((value) => value > recommendedActChars).map((maxChars) => { + const stats = corpusStats("tree", maxChars); + const fixtureRecall = expected.map((ids, index) => ({ + fixture: corpus[index]!.id, + treeActionRecall: recalled(stats.outputs[index]!, ids.actions), + treeRecordRecall: recalled(stats.outputs[index]!, ids.records), + })); + return { + maxChars, + treeActionRecall: fixtureRecall.reduce((sum, value) => sum + value.treeActionRecall, 0) / corpus.length, + treeRecordRecall: fixtureRecall.reduce((sum, value) => sum + value.treeRecordRecall, 0) / corpus.length, + preservesAll: fixtureRecall.every(({ treeActionRecall, treeRecordRecall }) => + treeActionRecall === 1 && treeRecordRecall === 1), + }; +}); +const recommendedTreeChars = treeCandidates.find(({ preservesAll }) => preservesAll)?.maxChars ?? 0; + +const act = corpusStats("act", recommendedActChars); +const tree = corpusStats("tree", recommendedTreeChars); +const totalRefRecall = (outputs: string[], key: "actions" | "records"): number => { + const total = expected.reduce((sum, value) => sum + value[key].length, 0); + if (total === 0) return 1; + return expected.reduce((sum, value, index) => + sum + value[key].filter((ref) => outputs[index]!.includes(`ref="${ref}"`)).length, 0) / total; +}; +const actActionRecall = totalRefRecall(act.outputs, "actions"); +const treeRecordRecall = totalRefRecall(tree.outputs, "records"); +const criticalTotal = expected.reduce((sum, value) => sum + value.critical.length, 0); +const actCriticalRecall = criticalTotal === 0 ? 1 : expected.reduce((sum, value, index) => + sum + criticalRecalled(act.outputs[index]!, value.critical) * value.critical.length, 0) / criticalTotal; +const criticalOmitted = corpus.reduce((sum, { snapshot }) => + sum + renderSnapshotResult(snapshot, { mode: "act", maxChars: recommendedActChars }).criticalOmitted, 0); +const nestedCriticalRegression = (() => { + const fixtureValue = corpus.find(({ id }) => id === "nested-critical-v1")!; + const result = renderSnapshotResult(fixtureValue.snapshot, { mode: "act", maxChars: 240 }); + return { + contentRecalled: result.value.includes("PAYMENT FAILED"), + criticalOmitted: result.criticalOmitted, + characters: result.value.length, + }; +})(); + +const diffTokens: number[] = []; +const correspondingFullTokens: number[] = []; +for (const { snapshot } of corpus) { + const before = structuredClone(snapshot); + const after = structuredClone(snapshot); + const action = allNodes(after).map(({ node }) => node).findLast((node) => ACTION_ROLES.has(node.role)); + if (!action) continue; + action.name = `${action.name ?? action.role} changed`; + diffTokens.push(estimatedTokens(renderSnapshotDiff( + diffSnapshots(before, after, "act"), + recommendedActChars, + ).value.length)); + correspondingFullTokens.push(estimatedTokens(renderSnapshotResult( + after, + { mode: "act", maxChars: recommendedActChars }, + ).value.length)); +} +const diffMedianTokens = quantile(diffTokens, 0.5); +const correspondingFullMedianTokens = quantile(correspondingFullTokens, 0.5); +const diffToFullMedianRatio = Number((diffMedianTokens / correspondingFullMedianTokens).toFixed(4)); + +const renderTiming = timed(() => renderSnapshotResult(nodes10000.snapshot, { + mode: "act", + maxChars: recommendedActChars, +})); +const renderedNodes10000 = renderSnapshotFrames(nodes10000.snapshot, "act"); +const priorityTiming = timed(() => allocateSnapshot(renderedNodes10000, recommendedActChars, 0)); +const outputOverruns = candidates.reduce((sum, candidate) => sum + candidate.outputOverruns, 0) + + act.outputOverruns + tree.outputOverruns; +const localCloudParityHash = createHash("sha256").update([...act.outputs, ...tree.outputs].join("\0")).digest("hex"); +let countedBrowserCalls = 0; +const countedPage = { + context: () => ({ + newCDPSession: async () => ({ + send: async (method: string): Promise => { + countedBrowserCalls += 1; + if (method === "Page.getFrameTree") + return { frameTree: { frame: { id: "counted-frame", url: "https://fixtures.test/counted" } } }; + if (method === "Accessibility.getFullAXTree") + return { nodes: [{ nodeId: "counted-root", role: { value: "RootWebArea" } }] }; + return {}; + }, + detach: async () => undefined, + }), + }), + title: async () => "Counted capture", + url: () => "https://fixtures.test/counted", +}; +const capturedForAllocation = await captureSnapshot(countedPage as unknown as Page); +const captureBrowserCalls = countedBrowserCalls; +const callsBeforeAllocation = countedBrowserCalls; +renderSnapshotResult(capturedForAllocation, { mode: "act", maxChars: recommendedActChars }); +const additionalBrowserCalls = countedBrowserCalls - callsBeforeAllocation; + +const metrics = { + corpus: corpus.map(({ id }) => id), + iterations: { warm: WARM_ITERATIONS, measured: MEASURED_ITERATIONS }, + baselineActTokens: BASELINE_ACT_TOKENS, + candidates: candidates.map(({ maxChars, characters, tokens }) => ({ maxChars, characters, tokens })), + treeCandidates, + recommendedActChars, + recommendedTreeChars, + defaults: { actChars: DEFAULT_ACT_SNAPSHOT_CHARS, treeChars: DEFAULT_TREE_SNAPSHOT_CHARS }, + act: { characters: act.characters, estimatedTokens: act.tokens }, + tree: { characters: tree.characters, estimatedTokens: tree.tokens }, + nodes10000: { + fixture: nodes10000.id, + nodes: allNodes(nodes10000.snapshot).length, + renderMedianMs: renderTiming.median, + renderP95Ms: renderTiming.p95, + priorityMedianMs: priorityTiming.median, + priorityP95Ms: priorityTiming.p95, + }, + actActionRecall: Number(actActionRecall.toFixed(6)), + treeRecordRecall: Number(treeRecordRecall.toFixed(6)), + actCriticalRecall: Number(actCriticalRecall.toFixed(6)), + criticalOmitted, + nestedCriticalRegression, + diffMedianTokens, + correspondingFullMedianTokens, + diffToFullMedianRatio, + outputOverruns, + localCloudParityHash, + captureBrowserCalls, + additionalBrowserCalls, + browserCallAssertion: "one counted capture followed by render-only allocation", + parityEvidence: "shared package output target; hosted infrastructure timing pending", + gates: { + tokens: recommendedActChars === DEFAULT_ACT_SNAPSHOT_CHARS && + recommendedTreeChars === DEFAULT_TREE_SNAPSHOT_CHARS && + act.tokens.median <= BASELINE_ACT_TOKENS.median && act.tokens.p95 <= BASELINE_ACT_TOKENS.p95, + recall: treeRecordRecall === 1 && (actCriticalRecall === 1 || criticalOmitted > 0), + latency: renderTiming.p95 < 30 && priorityTiming.p95 < 5, + }, +}; + +const fail = (gate: string): never => { + throw new Error(`snapshot benchmark gate failed: ${gate}`); +}; + +console.log(JSON.stringify(metrics, null, 2)); + +if (metrics.nodes10000.renderP95Ms >= 30) fail("10k render P95"); +if (metrics.nodes10000.priorityP95Ms >= 5) fail("10k priority P95"); +if (metrics.actCriticalRecall < 1 && metrics.criticalOmitted === 0) fail("silent critical loss"); +if (!metrics.nestedCriticalRegression.contentRecalled && + metrics.nestedCriticalRegression.criticalOmitted === 0) fail("nested critical loss"); +if (metrics.diffToFullMedianRatio > 0.5) fail("diff/full token ratio"); +if (metrics.outputOverruns !== 0) fail("hard ceiling"); +if (metrics.additionalBrowserCalls !== 0) fail("additional browser calls"); +if (metrics.captureBrowserCalls === 0) fail("capture call counter"); +if (!metrics.recommendedActChars || !metrics.recommendedTreeChars) fail("budget recommendation"); +if (!Object.values(metrics.gates).every(Boolean)) fail("acceptance gates"); diff --git a/scripts/build-playwright-sandbox-client.mjs b/scripts/build-playwright-sandbox-client.mjs new file mode 100644 index 00000000..4e58a165 --- /dev/null +++ b/scripts/build-playwright-sandbox-client.mjs @@ -0,0 +1,75 @@ +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { build } from 'esbuild'; + +const output = 'src/browser/run/generated/playwright-client.js'; +const clientRoot = 'src/browser/run/playwright-client'; +const vendorRoot = `${clientRoot}/vendor`; +const manifest = JSON.parse(await readFile(`${clientRoot}/vendor-manifest.json`, 'utf8')); +const check = process.argv.includes('--check'); +const directory = check ? await mkdtemp(join(tmpdir(), 'webcmd-playwright-client-')) : 'src/browser/run/generated'; +const outfile = check ? join(directory, 'playwright-client.js') : output; + +async function vendorDigest(directory) { + const files = []; + const walk = async path => { + for (const entry of await readdir(path, { withFileTypes: true })) { + const child = join(path, entry.name); + if (entry.isDirectory()) await walk(child); + else if (entry.name !== '.DS_Store') files.push(child); + } + }; + await walk(directory); + const hash = createHash('sha256'); + for (const file of files.sort()) { + const entry = file.slice(directory.length + 1); + hash.update(entry); + hash.update('\0'); + hash.update(await readFile(file)); + hash.update('\0'); + } + return hash.digest('hex'); +} + +const [packageJson, readme, types, digest] = await Promise.all([ + readFile('node_modules/playwright-core/package.json', 'utf8').then(JSON.parse), + readFile(`${clientRoot}/README.md`, 'utf8'), + readFile('src/browser/run/types.ts', 'utf8'), + vendorDigest(vendorRoot), +]); +if (packageJson.version !== manifest.version || packageJson.license !== manifest.license + || !types.includes(`BROWSER_RUN_PLAYWRIGHT_VERSION = '${manifest.version}'`) + || !readme.includes(`v${manifest.version}`) || !readme.includes(manifest.commit) + || !readme.includes(manifest.license) || digest !== manifest.vendorSha256) { + throw new Error('Playwright QuickJS client provenance or vendor digest does not match the pinned manifest.'); +} + +const banner = `/* Webcmd Playwright QuickJS client: Playwright v${manifest.version} (${manifest.commit}, ${manifest.license}) */`; + +await build({ + entryPoints: ['src/browser/run/playwright-client/bundle-entry.ts'], + bundle: true, + format: 'iife', + globalName: '__WebcmdPlaywrightClient', + platform: 'neutral', + target: 'es2022', + minify: false, + sourcemap: false, + banner: { js: banner }, + outfile, + alias: { + '@isomorphic': './src/browser/run/playwright-client/vendor/isomorphic', + '@protocol/channels': './src/browser/run/playwright-client/vendor/protocol/channels.d.ts', + }, +}); + +if (check) { + try { + const [built, committed] = await Promise.all([readFile(outfile), readFile(output)]); + if (!built.equals(committed)) throw new Error('Generated Playwright QuickJS client is out of date. Run node scripts/build-playwright-sandbox-client.mjs.'); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/scripts/copy-yaml.cjs b/scripts/copy-yaml.cjs index cfe2a87d..9af6dbfb 100644 --- a/scripts/copy-yaml.cjs +++ b/scripts/copy-yaml.cjs @@ -10,3 +10,7 @@ if (existsSync(extSrc)) { mkdirSync('dist/src', { recursive: true }); copyFileSync(extSrc, 'dist/src/external-clis.yaml'); } + +const playwrightClient = 'src/browser/run/generated/playwright-client.js'; +mkdirSync('dist/src/browser/run/generated', { recursive: true }); +copyFileSync(playwrightClient, 'dist/src/browser/run/generated/playwright-client.js'); diff --git a/skills/webcmd-adapter-author/SKILL.md b/skills/webcmd-adapter-author/SKILL.md index 7e061525..bde0eab0 100644 --- a/skills/webcmd-adapter-author/SKILL.md +++ b/skills/webcmd-adapter-author/SKILL.md @@ -8,7 +8,10 @@ allowed-tools: Bash(webcmd:*), Read, Edit, Write, Grep You are an agent writing an adapter for a site. The goal of this skill is a 30-minute loop from zero context to a passing `webcmd browser verify`. -Use the existing tools throughout: `webcmd browser *`, `webcmd doctor`, `webcmd browser init`, and `webcmd browser verify`. This skill does not introduce new commands. +Use the existing tools throughout: Playwright `browser run` for reconnaissance, +plus `webcmd doctor`, `webcmd browser init`, +and `webcmd browser verify`. Browser-run programs are discovery evidence, not +adapter source. Browser-profile auth commands must reuse `registerSiteAuthCommands`. Keep only site-specific `verify` and `openLogin` logic in the adapter. The login row must return `action_required` and `verify_command` (normally `webcmd whoami`); after the user reports done, agents run that returned command and verification must succeed before retrying the original workflow. Credentials, MFA, and CAPTCHA always use human handoff: CAPTCHA stops automation until the user reports done and verification succeeds, and adapter code must not collect or type passwords or secrets. @@ -57,7 +60,7 @@ Why not simpler: | `COOKIE_API` | stable | Node-side `fetch` plus `page.getCookies()` / header helper can get the data | cookie/CSRF source is clear and replay is non-empty | | `UI_SELECTOR` | visible-ui | publish/upload/click/form flows, or page semantics are more stable than internal APIs | selector has a semantic anchor; failure path is a typed error | | `DOM_STATE` | visible-ui | data is in hydration state, bootstrap JSON, or SSR HTML | state key, script JSON, or HTML structure is clear | -| `PAGE_FETCH` | internal-unstable | only page-context `fetch` can reuse same-origin/session/runtime state | `webcmd browser eval fetch(...)` is non-empty; explain why the internal endpoint is unavoidable | +| `PAGE_FETCH` | internal-unstable | only page-context `fetch` can reuse same-origin/session/runtime state | a browser run returns a non-empty page-context fetch result; explain why the internal endpoint is unavoidable | | `INTERCEPT` | internal-unstable | request signing is complex but the page can naturally issue the request | target response is captured after triggering UI; explain why UI/DOM is insufficient | Selection rule: prefer `PUBLIC_API` / `COOKIE_API`. If UI/DOM semantics are stable, do not force an upgrade to `PAGE_FETCH` / `INTERCEPT`. Pay the maintenance cost of uncontracted internal endpoints only when public/official APIs are unavailable and UI/DOM cannot express the target data or operation. @@ -146,8 +149,9 @@ Check these off step by step: [ ] If memory is older than 30 days according to `verified_at`, treat it as stale and use the cold-start path through Steps 3 and 4. [ ] 3. Recon (`site-recon.md`): - [ ] **Preferred:** `webcmd browser analyze ` to get pattern, anti-bot signals, nearest adapter, and next step in one pass. - [ ] If `analyze` is ambiguous, run manual checks: `open` -> `wait time 2` (or `wait xhr `) -> `network`. + [ ] **Preferred:** use `webcmd browser recon run --stdin` for navigation, readiness, network hints, and page evidence in one Playwright-style program. + [ ] Use `webcmd browser recon snapshot --snapshot-mode tree` when structural page evidence is needed. + [ ] Use the run result as reconnaissance evidence; do not copy Playwright code into an adapter. [ ] Choose Pattern A / B / C / D / E. [ ] 4. API discovery (`api-discovery.md`) by Pattern: @@ -182,6 +186,7 @@ Check these off step by step: [ ] `webcmd browser init /`, then set `strategy: Strategy.` in the generated file [ ] Find the closest same-site or same-type adapter and copy it. [ ] Edit name, URL, and field mapping. + [ ] Use only the adapter-compatible path proven in Step 6A; never paste Playwright locators, `waitForResponse`, or browser-run globals into `func`. [ ] 10. Verification fixtures: [ ] After the first passing run, immediately use `--write-fixture` to seed `~/.webcmd/sites//verify/.json`. @@ -252,6 +257,7 @@ Check these off step by step: ## Key Conventions - Adapters import only `@agentrhq/webcmd/registry` and `@agentrhq/webcmd/errors`; do not add third-party dependencies. +- Browser-run’s Playwright-style `page` and adapter `func(page,args)` are different contracts. Preserve evidence and behavior, not syntax. Implement adapters with the existing `IPage`, pipeline, Node-fetch, or interceptor APIs. - The `columns` array and `func` return object keys must match exactly, including order. - **Intermediate parsing object keys must not overlap any `columns` entry.** Otherwise silent-column-drop audits can misread the adapter. Use dedicated internal names and destructure with aliases when pushing rows. - **The `browser:` field determines the `func` signature:** `browser:false -> (args)`, `browser:true -> (page, args)`. If this is reversed, `args` may actually be a debug flag and all external parameters can silently fall back to defaults. diff --git a/skills/webcmd-adapter-author/references/adapter-template.md b/skills/webcmd-adapter-author/references/adapter-template.md index f6d4931a..20871dac 100644 --- a/skills/webcmd-adapter-author/references/adapter-template.md +++ b/skills/webcmd-adapter-author/references/adapter-template.md @@ -2,6 +2,9 @@ Use this after recon, endpoint verification, field decoding, output design, and strategy-note writing are complete. +Playwright-style browser-run code is reconnaissance, not adapter source. +Implement the observed behavior with the existing adapter APIs. + ## Create The File For private iteration: diff --git a/skills/webcmd-adapter-author/references/api-discovery.md b/skills/webcmd-adapter-author/references/api-discovery.md index d8ef8088..1cf9bbf4 100644 --- a/skills/webcmd-adapter-author/references/api-discovery.md +++ b/skills/webcmd-adapter-author/references/api-discovery.md @@ -10,7 +10,7 @@ Read these before endpoint verification. If you miss either one, you can spend t ### 0.1 Anti-bot and WAF gates decide whether Node fetch is valid -`webcmd browser analyze ` reports the `anti_bot` field. You can also inspect cookies and the response body manually. +Use `browser run` to inspect cookies and the response body manually. | Cookie or body signal | Vendor | Bare Node fetch or curl result | Strategy | | --- | --- | --- | --- | @@ -28,7 +28,16 @@ For example, a page on `jobs.51job.com` fetching an API on `cupid.51job.com` wil Probe it explicitly: ```bash -webcmd browser eval "fetch('https:///api/...', { credentials: 'include' }).then(r => r.status).catch(e => 'cors:' + e.message)" +webcmd browser recon run --stdin <<'JS' +await page.goto('https:///'); +return await page.evaluate(async () => { + try { + return await fetch('https:///api/...', { credentials: 'include' }).then(r => r.status); + } catch (error) { + return `cors:${error instanceof Error ? error.message : String(error)}`; + } +}); +JS ``` - A numeric status means CORS allows the request. @@ -37,7 +46,7 @@ webcmd browser eval "fetch('https:///api/...', { credentials: When it is blocked, `credentials: include` is not a CORS fix across subdomains. It only asks the browser to send cookies; it does not grant cross-origin permission. Use this fallback order: 1. Prefer a same-origin endpoint on the current subdomain. -2. Open a page on the target subdomain with `webcmd browser open https:///`, then fetch relative paths from that origin. +2. Navigate to the target subdomain inside `browser run`, then fetch relative paths from that origin. 3. If the data is truly cross-origin and there is no same-origin alternative, use Section 5 intercept and capture the response from the page's own request. ## Section 1 - Network Deep Read @@ -45,9 +54,28 @@ When it is blocked, `credentials: include` is not a CORS fix across subdomains. Use for Pattern A and for deeper data in Pattern B. ```bash -webcmd browser open --trace on --keep-tab true --window foreground -webcmd browser wait xhr '' -webcmd browser network --format json +webcmd browser recon run --stdin <<'JS' +const candidates = []; +page.on('response', async response => { + const url = response.url(); + const contentType = response.headers()['content-type'] || ''; + if (!url.includes('') && !/json|graphql/i.test(contentType)) return; + let sample = ''; + try { sample = (await response.text()).slice(0, 2000); } catch {} + candidates.push({ + url, + method: response.request().method(), + status: response.status(), + contentType, + sample, + }); +}); + +await page.goto(''); +await page.waitForLoadState('domcontentloaded'); +await page.waitForTimeout(1500); +return candidates.slice(0, 20); +JS ``` Inspect each candidate: @@ -64,11 +92,35 @@ Reject candidates that only contain telemetry, unrelated recommendations, beacon Replay directly when possible: ```bash -webcmd browser eval "await fetch('', { credentials: 'include' }).then(r => r.text())" +webcmd browser recon run --stdin <<'JS' +return await page.evaluate(async () => + fetch('', { credentials: 'include' }).then(r => r.text()) +); +JS ``` If Node-side replay works without page runtime state, prefer `PUBLIC_API` or `COOKIE_API`. If the endpoint only works in page context, document why before selecting `PAGE_FETCH`. +For a request that exists only after a UI action, use `browser run` so the +listener is attached before the trigger: + +```js +const pending = page.waitForResponse( + response => response.url().includes('/api/target'), +); +await page.getByRole('button', { name: 'Load' }).click(); +const response = await pending; +return { + url: response.url(), + method: response.request().method(), + status: response.status(), + body: await response.json(), +}; +``` + +This is recon evidence only. Choose the adapter strategy from the verified +endpoint and UI evidence; do not copy the browser-run program into the adapter. + ## Section 2 - State Extraction Use for Pattern B. @@ -84,9 +136,13 @@ Look for: Commands: ```bash -webcmd browser eval "Object.keys(window).filter(k => /STATE|DATA|NUXT|APP/i.test(k))" -webcmd browser eval "document.querySelectorAll('script[type=\"application/json\"], script:not([src])').length" -webcmd browser eval "document.body.innerText.slice(0, 2000)" +webcmd browser recon run --stdin <<'JS' +return await page.evaluate(() => ({ + globals: Object.keys(window).filter(k => /STATE|DATA|NUXT|APP/i.test(k)), + jsonScriptCount: document.querySelectorAll('script[type="application/json"], script:not([src])').length, + textSample: document.body.innerText.slice(0, 2000), +})); +JS ``` Use `DOM_STATE` when the target data is stable in state or HTML. If only a deeper interaction loads the target data, return to section 1. @@ -98,7 +154,9 @@ Use for Pattern C. Collect script sources: ```bash -webcmd browser eval "[...document.querySelectorAll('script[src]')].map(s => s.src)" +webcmd browser recon run --stdin <<'JS' +return await page.evaluate(() => [...document.querySelectorAll('script[src]')].map(s => s.src)); +JS ``` Look for domains or paths containing: @@ -136,10 +194,14 @@ Find token sources in this order: Useful probes: ```bash -webcmd browser eval "document.querySelector('meta[name=\"csrf-token\"]')?.content" -webcmd browser eval "Object.keys(localStorage)" -webcmd browser eval "Object.keys(sessionStorage)" -webcmd browser eval "document.cookie" +webcmd browser recon run --stdin <<'JS' +return await page.evaluate(() => ({ + csrf: document.querySelector('meta[name="csrf-token"]')?.content ?? null, + localStorageKeys: Object.keys(localStorage), + sessionStorageKeys: Object.keys(sessionStorage), + cookieNames: document.cookie.split(';').map(part => part.trim().split('=')[0]).filter(Boolean), +})); +JS ``` Rules: @@ -156,10 +218,17 @@ Use only after public API, cookie API, DOM state, and UI selector options are in For page actions: ```bash -webcmd browser trace start -webcmd browser click '' -webcmd browser wait xhr '' -webcmd browser network --format json +webcmd browser recon run --stdin <<'JS' +const pending = page.waitForResponse(response => response.url().includes('')); +await page.locator('').click(); +const response = await pending; +return { + url: response.url(), + method: response.request().method(), + status: response.status(), + body: await response.text(), +}; +JS ``` Choose `INTERCEPT` when: diff --git a/skills/webcmd-adapter-author/references/field-decode-playbook.md b/skills/webcmd-adapter-author/references/field-decode-playbook.md index 0a4ad0e8..28d67626 100644 --- a/skills/webcmd-adapter-author/references/field-decode-playbook.md +++ b/skills/webcmd-adapter-author/references/field-decode-playbook.md @@ -18,9 +18,24 @@ Change the site's sort order in the UI, or change known query params, then compa Example workflow: ```bash -webcmd browser open -webcmd browser wait xhr '' -webcmd browser network --format json +webcmd browser recon run --stdin <<'JS' +const responses = []; +page.on('response', async response => { + if (!response.url().includes('')) return; + let body = ''; + try { body = await response.text(); } catch {} + responses.push({ + url: response.url(), + status: response.status(), + body, + }); +}); + +await page.goto(''); +await page.waitForLoadState('domcontentloaded'); +await page.waitForTimeout(1000); +return responses; +JS ``` Look for fields that move with: diff --git a/skills/webcmd-adapter-author/references/jsdom-fixture-pattern.md b/skills/webcmd-adapter-author/references/jsdom-fixture-pattern.md index 86002081..a7dc3934 100644 --- a/skills/webcmd-adapter-author/references/jsdom-fixture-pattern.md +++ b/skills/webcmd-adapter-author/references/jsdom-fixture-pattern.md @@ -35,7 +35,9 @@ Temporary debug dumps still belong only in: Use the browser to capture the specific DOM region, not the entire page. ```bash -webcmd browser eval "document.querySelector('')?.outerHTML" +webcmd browser recon run --stdin <<'JS' +return await page.evaluate(() => document.querySelector('')?.outerHTML ?? ''); +JS ``` Save only the required HTML for the parser. diff --git a/skills/webcmd-adapter-author/references/site-recon.md b/skills/webcmd-adapter-author/references/site-recon.md index 9cb77a58..61070b74 100644 --- a/skills/webcmd-adapter-author/references/site-recon.md +++ b/skills/webcmd-adapter-author/references/site-recon.md @@ -4,39 +4,78 @@ This file only classifies sites. It does not explain how to discover endpoints. -## One-Step Diagnosis +## Browser-Run Diagnosis -Preferred command: +Preferred flow: ```bash -webcmd browser analyze +webcmd browser recon run --stdin --snapshot-mode tree <<'JS' +const responses = []; +page.on('response', response => { + const contentType = response.headers()['content-type'] || ''; + if (/json|text\/event-stream/i.test(contentType) || /\/api\/|graphql/i.test(response.url())) { + responses.push({ + url: response.url(), + status: response.status(), + contentType, + }); + } +}); + +await page.goto(''); +await page.waitForLoadState('domcontentloaded'); +await page.waitForTimeout(1500); + +return { + url: page.url(), + title: await page.title(), + globals: await page.evaluate(() => ({ + react: Boolean(window.React || window.__REACT_DEVTOOLS_GLOBAL_HOOK__), + next: Boolean(window.__NEXT_DATA__), + nuxt: Boolean(window.__NUXT__), + })), + responses: responses.slice(0, 20), +}; +JS ``` -The command returns JSON with: +Then inspect page structure when needed: -```json -{ - "pattern": "A|B|C|D|E", - "anti_bot": [], - "api_candidates": [], - "nearest_adapters": [], - "recommended_next_step": "..." -} +```bash +webcmd browser recon snapshot --snapshot-mode tree ``` -`analyze` gives Pattern classification, anti-bot signals, nearest-adapter matches, and the next step in one pass. Follow `recommended_next_step` directly in most cases. +Use this evidence to choose Pattern A/B/C/D/E. Do not paste the Playwright-style program into the adapter. + +## Existing-Page Diagnosis + +Use this when the user already has a relevant tab open. List pages, bind the chosen page, +then run dependent recon steps together: -## Manual Three-Step Diagnosis +```bash +webcmd browser recon tabs +webcmd browser recon bind --page page-123 +webcmd browser recon run --stdin <<'JS' +const responsePromise = page.waitForResponse( + response => response.url().includes('/api/path-fragment'), +); +await page.goto('https://example.com'); +await page.waitForLoadState('domcontentloaded'); +const response = await responsePromise; +return { + url: page.url(), + endpoint: { url: response.url(), status: response.status() }, +}; +JS +``` -Use this only when `analyze` is ambiguous: +Then inspect the current page when a snapshot is needed: ```bash -webcmd browser open --trace on --keep-tab true --window foreground -webcmd browser wait time 2 -webcmd browser network --format json +webcmd browser recon snapshot --snapshot-mode tree ``` -Read `network` output this way: +Use the snapshot and any response evidence collected in the run to classify the site: | `network` shows | Site type | Signals | | --- | --- | --- | @@ -46,7 +85,13 @@ Read `network` output this way: | API exists but returns 401/403 or signature errors | **D. Token / CSRF auth** | Pattern A plus auth headers or page-sourced tokens | | `Content-Type: text/event-stream` or WebSocket handshake | **E. Streaming** | Live feed, chat, or tick data | -If data is loaded asynchronously, `wait time 2` may not be enough. Prefer `webcmd browser wait xhr '/api/path-fragment'` for a specific interface over blind `wait time 5`. +If data is loaded asynchronously, arm `page.waitForResponse(...)` before the +navigation or UI trigger in the same run. Do not use a separate browser wait. + +When classification needs a dependent UI trigger plus a request/response +waiter, use one sandboxed `browser run` program and arm the waiter before the +trigger. Record the endpoint and UI evidence; do not copy the Playwright-style +program into an adapter. --- @@ -64,7 +109,7 @@ If data is loaded asynchronously, `wait time 2` may not be enough. Prefer `webcm **Important:** Pattern A does not automatically mean `PAGE_FETCH`. -- First inspect `api_candidates[]` from `webcmd browser analyze`: only `verdict=likely_data` entries are real candidates. `verdict=noise` entries such as analytics, beacons, or personalization do not count as API signals. +- First inspect the response evidence collected by `browser run`; analytics, beacons, or personalization responses do not count as API signals. - The booking #1680 counterexample had many JSON XHRs that looked like Pattern A, but they were analytics side-channels; the final strategy was `DOM_STATE` / `UI_SELECTOR`. - After replaying a candidate endpoint, choose strategy through `strategy-selection.md`. Consider `PAGE_FETCH` only after `PUBLIC_API` and `COOKIE_API` fail. diff --git a/skills/webcmd-adapter-author/references/strategy-selection.md b/skills/webcmd-adapter-author/references/strategy-selection.md index 69788f36..4d226999 100644 --- a/skills/webcmd-adapter-author/references/strategy-selection.md +++ b/skills/webcmd-adapter-author/references/strategy-selection.md @@ -64,7 +64,7 @@ Use when only page-context `fetch` can reuse same-origin/session/runtime state. Required evidence: -- `webcmd browser eval fetch(...)` returns non-empty target data. +- `webcmd browser recon run --stdin` with page-context `fetch(...)` returns non-empty target data. - Simpler strategies are ruled out in the strategy note. - Internal endpoint drift risk is accepted and documented. @@ -78,20 +78,16 @@ Required evidence: - Captured response contains target data. - UI/DOM cannot provide the target data or operation. -## `api_candidates` Evidence +## Browser-Run Response Evidence -`webcmd browser analyze` may output `api_candidates[]`. +Treat a response observed by `browser run` as usable only when: -Treat a candidate as usable only when: - -- `verdict` is `likely_data`. - Response sample includes target fields. - URL and params are related to the user-visible result. - Replay succeeds or the failure clearly points to auth/token handling. Ignore candidates when: -- `verdict` is `noise`. - The response is analytics, beacon, ads, personalization, or experiment config. - It contains only layout metadata. - It cannot be tied to visible page data. diff --git a/skills/webcmd-autofix/SKILL.md b/skills/webcmd-autofix/SKILL.md index eadb8e55..54fc2f93 100644 --- a/skills/webcmd-autofix/SKILL.md +++ b/skills/webcmd-autofix/SKILL.md @@ -61,7 +61,7 @@ Persistent-session adapters (`siteSession: 'persistent'`) share one tab per site - Check the trace screenshot and `location.href`: a modal over a blank page or the wrong URL means the tab carried stale DOM from a previous command, not that the site rejected this request. - Check session-scoped context: sites often scope results to a selected city, date, or account. A "closed" / "unavailable" verdict can simply mean the browser's selected context does not match the request (for example, a seat layout opened while the site's location cookie points at another city). -- Reproduce in a clean tab (`webcmd browser open `) before trusting the verdict. If it only fails in the adapter's persistent tab, fix state handling (`freshPage: true`, dismiss-and-renavigate, context preconditions) instead of selectors. +- Reproduce in a separate browser session with `webcmd browser repair-clean run --stdin` before trusting the verdict. If it only fails in the adapter's persistent tab, fix state handling (`freshPage: true`, dismiss-and-renavigate, context preconditions) instead of selectors. ## Step 1: Collect Trace Context @@ -144,22 +144,39 @@ Use `webcmd browser` to inspect the live site. Do not use the broken adapter for For DOM changes: ```bash -webcmd browser open https://example.com/target-page -webcmd browser state +webcmd browser repair run --stdin --snapshot-mode tree <<'JS' +await page.goto('https://example.com/target-page'); +await page.waitForLoadState('domcontentloaded'); +return { url: page.url(), title: await page.title() }; +JS +webcmd browser repair snapshot --snapshot-mode tree ``` For API changes: ```bash -webcmd browser open https://example.com/target-page -webcmd browser state -webcmd browser click -webcmd browser network -webcmd browser network --filter author,text,likes -webcmd browser network --detail +webcmd browser repair run --stdin <<'JS' +const responses = []; +page.on('response', async response => { + if (!response.url().includes('')) return; + let body = ''; + try { body = (await response.text()).slice(0, 2000); } catch {} + responses.push({ + url: response.url(), + method: response.request().method(), + status: response.status(), + body, + }); +}); + +await page.goto('https://example.com/target-page'); +await page.locator('').click(); +await page.waitForTimeout(1000); +return responses; +JS ``` -Use the `key` field from network output with `--detail`. +Use the captured response evidence to decide whether the adapter broke because of selectors, endpoint drift, auth state, or real empty data. ## Step 4: Patch The Adapter @@ -273,7 +290,8 @@ In all stop cases, clearly report the situation instead of making speculative pa -> Page loaded, but post cards now use "[data-testid=post-container]" 4. Agent explores: - -> webcmd browser open https://www.reddit.com && webcmd browser state + -> webcmd browser repair run --stdin --snapshot-mode tree + -> webcmd browser repair snapshot --snapshot-mode tree 5. Agent patches adapterSourcePath: -> Replace old selector with stable scoped selector diff --git a/skills/webcmd-browser-sitemap/SKILL.md b/skills/webcmd-browser-sitemap/SKILL.md index 3b895a73..e25b57e1 100644 --- a/skills/webcmd-browser-sitemap/SKILL.md +++ b/skills/webcmd-browser-sitemap/SKILL.md @@ -6,7 +6,7 @@ allowed-tools: Bash(webcmd:*), Read, Edit, Write, Grep # webcmd-browser-sitemap -Use this skill when `webcmd browser open` or `webcmd browser analyze` reports `sitemap.available: true`, or when the user asks you to use a site's sitemap. +Use this skill when `webcmd browser run --stdin` or an adapter trace reports `sitemap.available: true`, or when the user asks you to use a site's sitemap. The sitemap is **prior knowledge**, not ground truth. It should reduce blind clicking, but it must never override the live browser state. @@ -14,7 +14,7 @@ The sitemap is **prior knowledge**, not ground truth. It should reduce blind cli ## Consumption Loop -1. Run or reuse `webcmd browser state` to know the current page. +1. Run or reuse `webcmd browser snapshot --snapshot-mode tree` to know the current page. 2. Read only the smallest relevant sitemap files: - `SITE.md` for site-level orientation. - One matching `pages/.md` for current state. @@ -47,7 +47,7 @@ Do not load an entire large sitemap into context. If the directory is large, lis If sitemap says a button, URL, route, or API should exist but the browser does not show it: -- Re-run `state` or `find` with semantic anchors. +- Re-run `snapshot --snapshot-mode tree` or a targeted `browser run` probe with semantic anchors. - Check whether login, locale, viewport, A/B test, or route state differs. - Follow the real page if a safe path is visible. - Mark the sitemap item stale in local overlay. diff --git a/skills/webcmd-browser/SKILL.md b/skills/webcmd-browser/SKILL.md index 321f9973..a6deb8fe 100644 --- a/skills/webcmd-browser/SKILL.md +++ b/skills/webcmd-browser/SKILL.md @@ -1,27 +1,20 @@ --- name: webcmd-browser -description: Use when an agent needs to drive a real Chrome window via webcmd — inspect a page, fill forms, click through logged-in flows, or extract data ad-hoc. Covers the selector-first target contract, compound form fields, stale-ref handling, network capture, and the agent-native envelopes the CLI returns. Not for writing adapters — see webcmd-adapter-author for that. -allowed-tools: Bash(webcmd:*), Read, Edit, Write +description: Use when no deterministic Webcmd adapter command covers a live browser task requiring Playwright interaction, authenticated handoff, visible UI verification, or ad-hoc page inspection. +allowed-tools: Bash(webcmd:*), Read --- # webcmd-browser -The first reader of this CLI is an agent, not a human. Every subcommand returns a structured envelope that tells you exactly what matched, how confident the match is, and what to do if it didn't. Lean on those envelopes — do not guess. - -This skill is for **driving a live browser** to accomplish an agent task. If you are building a reusable adapter under `~/.webcmd/clis//` use `webcmd-adapter-author` instead. +The first reader of this CLI is an agent, not a human. Use browser output as structured evidence, not as prose to skim. This skill is for **driving a live browser** to finish a task or understand a surface. If the workflow should become reusable, switch to `webcmd-adapter-author`. --- ## Adapter fallback gate -Before starting a raw browser session, filter `webcmd list -f json` at the source using request-derived terms across `site`, `name`, `description`, and `columns`; follow `webcmd-usage` for the command shape. Any truncation warning means adapter discovery is incomplete: narrow the filter and inspect again. Absence from truncated output never proves that no adapter exists. - -Raw `webcmd browser` use is allowed only after both conditions hold: - -1. The complete, non-truncated filtered registry result for the missing capability is exactly `[]`. -2. A complete, non-truncated `webcmd plugin search -f json` result returns no match and no error. +Before starting a raw browser session, filter `webcmd list -f json` at the source using request-derived terms across `site`, `name`, `description`, and `columns`; follow `webcmd-usage` for the exact command shape. Any truncation warning means adapter discovery is incomplete: narrow the filter and inspect again. Absence from truncated output never proves that no adapter exists. -If plugin search returns a match, offer installation. If its output is truncated, refine the query or output and inspect again. If it errors, report the error and stop instead of opening the browser. +Use raw `webcmd browser` only after a complete, non-truncated registry check shows no suitable adapter. If plugin search is relevant and returns a match, offer installation; if it errors, report the error instead of opening the browser. --- @@ -31,306 +24,93 @@ If plugin search returns a match, offer installation. If its output is truncated webcmd doctor ``` -Until `doctor` is green, browser commands will not work. Registry and plugin discovery do not require `doctor`. Typical failures: Chrome not running, extension not installed, debug port blocked by 1Password / other extensions. The doctor output tells you which. +Until `doctor` is green, browser commands may fail. Registry and plugin discovery do not require `doctor`. --- ## Session lifecycle -- `webcmd browser *` commands require a `` positional immediately after `browser`. Use the same session name for a multi-step flow; use a different name to isolate parallel browser work. -- Use a stable session name for any multi-command or human-paced browser workflow. Example: `webcmd browser fb-yaya-warmup open https://example.com`, then reuse `webcmd browser fb-yaya-warmup state`, `extract`, `click`, etc. -- Owned browser sessions keep a tab lease alive between calls. Release it with `webcmd browser close` or let the idle timeout expire. -- `webcmd browser bind --page ` binds an existing webcmd-managed Cloak tab to that session. Use this after the user manually logs in or navigates inside a visible Cloak window. -- Browser commands default to background mode. -- Pass `--window foreground` (or set `WEBCMD_WINDOW=foreground`) when the user must see or interact with the browser. - -### Bind Tab - -```bash -webcmd browser gmail tab list -webcmd browser gmail bind --page page-123 -webcmd browser gmail state -webcmd browser gmail click "Search" -webcmd browser gmail network -webcmd browser gmail unbind -``` - -Binding is explicit: run `tab list`, choose the Cloak tab's `page` id or `index`, then bind that tab to the named session. It fails closed if the tab is closed or the page id/index is stale. Re-run `webcmd browser bind --page ` when the user switches to a different Cloak tab. - -Navigation is allowed on bound sessions because the session now represents explicit agent ownership of that Cloak tab. Tab mutation (`tab new`, `tab select`, `tab close`) works on webcmd-managed Cloak tabs. - -Bound sessions use the normal Webcmd session lifecycle; `unbind` releases the Cloak session lease, and tab/window/daemon close also ends the binding. - ---- - -## Mental model - -1. **Selector-first target contract.** Every interaction command (`click`, `type`, `select`, `get text/value/attributes`) takes one ``, which is *either* a numeric ref from `state`/`find` *or* a CSS selector. Use `--nth ` to disambiguate multiple CSS matches. -2. **Every envelope reports `matches_n` and `match_level`.** `match_level` is `exact`, `stable`, or `reidentified` — the CLI already rescued moderate DOM drift for you, but the level tells you how confident to be. -3. **Compact output first, full payload on demand.** `state` is a budget-aware snapshot; `get html --as json` supports `--depth/--children-max/--text-max`; `network` returns shape previews and you re-fetch a single body with `--detail `. If you emit a giant payload you are burning context you did not need to burn. -4. **Structured errors are machine-readable.** On failure the CLI emits `{error: {code, message, hint?, candidates?}}`. Branch on `code`, not on message strings. - ---- - -## Critical rules - -1. **Always inspect before you act.** Run `state` or `find` first. Never hard-code a ref or selector from memory across sessions — indices are per-snapshot. -2. **Prefer site adapters before raw browser driving.** Complete the adapter fallback gate above. If `webcmd ` already covers the task, use that adapter command first (`webcmd facebook notifications`, `webcmd reddit read`, `webcmd chatgpt model `, etc.). Use `webcmd browser ...` only for gaps, debugging, or one-off UI flows the adapter does not expose. -3. **Prefer numeric ref over CSS once you have it.** Numeric refs survive mild DOM shifts because the CLI fingerprints each tagged element. A CSS selector written by hand will break the first time the site re-renders. -4. **Read `match_level` after every write.** `exact` = all good. `stable` = the element is the same but some soft attrs drifted — your action still applied. `reidentified` = the original ref was gone and the CLI found a unique replacement; double-check you hit the right element. -5. **Use the `compound` field for form controls.** Do not regex-guess a date format, do not `state` twice to get the full ``. -6. **Verify writes that matter.** After `type `, run `get value `. After `select`, run `get value`. Autocomplete widgets, React controlled inputs, and masked fields all silently eat characters. The CLI cannot detect this for you. -7. **`state` → action → `state` after a page change.** Navigations, form submits, and SPA route changes invalidate refs. Take a fresh snapshot. Do not reuse refs from before the transition. -8. **Chain with `&&` when reusing freshly parsed refs.** A chained sequence runs in one shell so the ref you just read from output can be passed directly to the next command. Separate shell invocations keep the named browser session, but any shell-local variables or copied refs from the previous command can go stale after page changes. -9. **`eval` is read-only.** Wrap the JS in an IIFE and return JSON. If you need to *change* the page, use the structured `click` / `type` / `select` / `keys` commands instead — they produce structured output and fingerprints, `eval` does not. -10. **Prefer `network` to screen-scraping.** If a page you care about fetches its data from a JSON API, the API is almost always more reliable than scraping the rendered DOM. Capture once, inspect the shape, then `--detail ` the body you need. - ---- - -## Sitemaps - -If `browser open` or `browser analyze` returns `sitemap.available: true`, switch to `webcmd-browser-sitemap` before continuing a multi-step site flow. The sitemap is prior context for pages, actions, workflows, APIs, and pitfalls; it is not truth. If the browser state disagrees with the sitemap, trust the browser and mark the sitemap stale via `webcmd-sitemap-author`. +- `webcmd browser *` commands require a `` positional immediately after `browser`. +- Use the same session name for a multi-step flow; use a different name to isolate parallel browser work. +- Browser state in the bound page persists between calls, but each `run` gets a fresh JavaScript scope. +- `webcmd browser tabs` lists existing pages without creating a new one. +- `webcmd browser bind --page ` explicitly attaches a session to an existing page. +- `webcmd browser close` releases the session when finished. +- If the user manually signs in or changes the visible tab, re-bind or inspect with a fresh snapshot before continuing. --- -## Target contract (`` for click / type / select / get text|value|attributes) - -``` - ::= | -``` - -- **Numeric ref** — the `[N]` index from `state` or `find`. Cheap, resilient to soft DOM drift. -- **CSS selector** — anything `querySelectorAll` accepts. Must be unambiguous on write ops, or pair with `--nth `. - -### Envelope on success - -```json -{ "clicked": true, "target": "3", "matches_n": 1, "match_level": "exact" } -``` - -```json -{ "value": "kalevin@example.com", "matches_n": 1, "match_level": "stable" } -``` - -### match_level - -| level | meaning | you should | -|-------|---------|------------| -| `exact` | Fingerprint agreed on tag + strong IDs with at most one soft drift | Proceed. | -| `stable` | Tag + strong IDs still agree, soft signals (aria-label, role, text) drifted | Proceed, but if *what* you typed/clicked matters, re-check with `get value` or `state`. | -| `reidentified` | Original ref was gone; a unique live element matched the fingerprint and was re-tagged with the old ref | Double-check you hit the right element before chaining more writes. | - -### Structured error codes +## Command surface -Branch on these, not on the human message: +The raw surface is `tabs`, `bind --page`, `snapshot`, `run`, and `close`. -| code | meaning | -|------|---------| -| `not_found` | Numeric ref is no longer in the DOM. Re-`state`. | -| `stale_ref` | Ref exists but the element at that ref changed identity. Re-`state`. | -| `invalid_selector` | CSS was rejected by `querySelectorAll`. Fix the selector. | -| `selector_not_found` | CSS matches 0 elements. Try `find` with a looser selector. | -| `selector_ambiguous` | CSS matches >1 and no `--nth`. Add `--nth` or narrow the selector. | -| `selector_nth_out_of_range` | `--nth` beyond match count. | -| `option_not_found` | `select` couldn't find an option matching that label/value. Error envelope includes `available: string[]` of the real option labels. | -| `not_a_select` | `select` was called on a non-`` option by label first, then value. With semantic flags, omit `target` and pass option as the only positional. Use `compound` from `find`/`state` to see exactly what labels are available. | -| `browser keys ` | `Enter`, `Escape`, `Tab`, `Control+a`, etc. Runs against the focused element. | -| `browser scroll [--amount px]` | `up` / `down`. Default amount `500`. | - -### Wait +Keep related browser actions in one `run` and return compact JSON-compatible data. Successful runs return `snapshotDiff` automatically. Use `--no-snapshot-diff` only when the program is pure read-only and its result already contains the needed state. Do not call the legacy semantic-snapshot page helper; it is not part of Webcmd's Playwright runtime. ```bash -browser wait selector "" [--timeout ms] # wait until the selector matches -browser wait text "" [--timeout ms] # wait until the text appears -browser wait download [pattern] [--timeout ms] # wait for a Chrome download whose filename/URL/mime contains pattern -browser wait time # hard sleep, last resort +webcmd browser work run --stdin <<'JS' +await page.goto('https://example.com'); +await page.getByRole('link', { name: 'More information' }).click(); +return { title: await page.title(), url: page.url() }; +JS ``` -Default timeout `10000` ms. SPA routes, login redirects, and lazy-loaded lists need `wait` before `state`/`get`. - -`browser wait download` uses the CloakBrowser runtime download lifecycle. Pass a -narrow filename or URL substring such as `receipt.pdf` when possible; an empty -pattern waits for the next/recent download in the timeout window. The command -reports `{downloaded, filename, url, state, elapsedMs}` on success and a JSON -error envelope on timeout/failure. - -### Extract - -- **`web fetch-browser --url `** — One-shot Markdown reader for arbitrary pages. It expands relevant same-origin iframes by default, so old iframe-shell sites work better than with a top-document-only scrape. Use `--frames all-same-origin` when completeness matters more than Markdown noise. For AJAX shell pages use `webcmd web fetch-browser --url --wait-for "" --wait-until networkidle --diagnose`; diagnostics show frame URLs, empty containers, and API-like XHRs. If the value you need is table/API data, switch to `browser network` or a dedicated adapter instead of relying on Markdown. -- **`browser eval [--frame N]`** — Run an expression in the page (or in a cross-origin frame via `--frame`). Wrap in an IIFE and return JSON. Read-only: no `document.forms[0].submit()`, no clicks, no navigations. If the result is a string, stdout is the raw string; otherwise it's JSON. -- **`browser extract [--selector ] [--chunk-size N] [--start N]`** — Markdown extraction of long-form content with a continuation cursor. Returns `{url, title, selector, total_chars, chunk_size, start, end, next_start_char, content}`. Loop on `next_start_char` until it is `null`. Auto-scopes to `
`/`
`/`` if you don't pass `--selector`. - -### Network - -```bash -browser network # shape preview + cache key list -browser network --detail # full body for one cached entry -browser network --filter "field1,field2" # keep only entries whose body shape contains ALL fields as path segments -browser network --all # include static resources (usually noise) -browser network --raw # full bodies inline — large; use sparingly -browser network --ttl # cache TTL (default 24h) -``` - -List entries look like `{key, method, status, url, ct, size, shape, body_truncated?}`. Detail envelope is `{key, url, method, status, ct, size, shape, body, body_truncated?, body_full_size?, body_truncation_reason}`. Cache lives in `~/.webcmd/cache/browser-network/` so you can re-inspect without re-triggering the request. - -Default output keeps JSON/XML/plain-text and JS-like API responses, then drops obvious static assets and telemetry by URL. If an expected endpoint is missing, run `browser network --all` once and check whether an unusual content type or URL filter hid it. - -### Tabs & session - -| command | purpose | -|---------|---------| -| `browser tab list` | JSON array of `{index, page, url, title, active}`. The `page` string is the tab identity you pass as `` to `tab select` / `tab close`, or to `--tab ` on any subcommand. (`--tab`'s placeholder is historical — the value is always `page`.) | -| `browser tab new [url]` | Open a new tab. Prints the new `page` string. | -| `browser tab select [targetId]` | Make a tab the default. All subcommands accept `--tab ` to target one without changing the default. | -| `browser tab close [targetId]` | Close by `page`. | -| `browser back` | History back on the active tab. | -| `browser close` | Release the current owned browser session when done. | -| `browser bind --page ` | Bind an existing Cloak tab to the named browser session. | -| `browser bind --index ` | Bind an existing Cloak tab by index from `tab list`. | -| `browser unbind` | Release the named Cloak browser session lease. | +For sandbox boundaries, artifacts, errors, snapshots, and timings, read [`references/browser-run-playwright.md`](references/browser-run-playwright.md). --- -## Compound form controls - -Every date/time, select, and file input carries a `compound` field. Use it — do not regex attributes. +## Mental model -### Date family +1. **Adapter first, browser second.** Browser driving is fallback or reconnaissance, not the default execution path. +2. **One run is the unit of action.** Put dependent waits, clicks, fills, and response listeners in the same Playwright program so ordering is deterministic. +3. **Snapshots are observations, not durable handles.** After navigation, form submit, SPA route change, login, or human handoff, take a fresh snapshot before trusting old observations. +4. **Use semantic locators first.** Prefer Playwright `getByRole`, `getByLabel`, `getByText`, and scoped locators before brittle CSS. +5. **Return compact evidence.** Return URL, title, selected text, response URL/status/body sample, or specific field values. Do not dump the whole DOM unless the task truly needs it. +6. **Network evidence beats screen scraping when available.** Attach response listeners before the UI trigger in the same `run`. +7. **Structured warnings matter.** If a timeout warns that side effects may have occurred, inspect state before retrying a write. -```json -{ - "control": "date", - "format": "YYYY-MM-DD", - "current": "2026-04-21", - "min": "2026-01-01", - "max": "2026-12-31" -} -``` +--- -`control` is one of `date | time | datetime-local | month | week`. `format` is a concrete template string — type into the field using that exact format, or `select` by label if the site wraps the native input in a custom widget. - -### Select - -```json -{ - "control": "select", - "multiple": false, - "current": "United States", - "options": [ - { "label": "United States", "value": "us", "selected": true }, - { "label": "Canada", "value": "ca" } - ], - "options_total": 137 -} -``` +## Chaining rules -`options[]` is capped at 50 entries. **`current` is always correct** even when the selected option is past the cap — it's computed by scanning every option, not from the truncated list. If `options_total > options.length` and you need an option that isn't in `options[]`, call `browser select "