patchrome 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +514 -0
- package/bin/patchrome.js +10 -0
- package/dist/build-id.d.ts +2 -0
- package/dist/build-id.js +21 -0
- package/dist/challenges.d.ts +22 -0
- package/dist/challenges.js +97 -0
- package/dist/chrome-profiles.d.ts +17 -0
- package/dist/chrome-profiles.js +141 -0
- package/dist/cli-options.d.ts +131 -0
- package/dist/cli-options.js +43 -0
- package/dist/cli.d.ts +48 -0
- package/dist/cli.js +572 -0
- package/dist/client.d.ts +16 -0
- package/dist/client.js +210 -0
- package/dist/commands.d.ts +58 -0
- package/dist/commands.js +1076 -0
- package/dist/completions.d.ts +1 -0
- package/dist/completions.js +114 -0
- package/dist/copy-guard.d.ts +75 -0
- package/dist/copy-guard.js +167 -0
- package/dist/daemon.d.ts +7 -0
- package/dist/daemon.js +313 -0
- package/dist/diagnostics.d.ts +44 -0
- package/dist/diagnostics.js +117 -0
- package/dist/engine.d.ts +51 -0
- package/dist/engine.js +257 -0
- package/dist/events.d.ts +41 -0
- package/dist/events.js +106 -0
- package/dist/extract.d.ts +27 -0
- package/dist/extract.js +62 -0
- package/dist/focus.d.ts +1 -0
- package/dist/focus.js +44 -0
- package/dist/glob.d.ts +4 -0
- package/dist/glob.js +63 -0
- package/dist/har.d.ts +105 -0
- package/dist/har.js +88 -0
- package/dist/history.d.ts +35 -0
- package/dist/history.js +277 -0
- package/dist/host-platform.d.ts +5 -0
- package/dist/host-platform.js +19 -0
- package/dist/host-prompts-macos.d.ts +2 -0
- package/dist/host-prompts-macos.js +102 -0
- package/dist/host-prompts-wsl.d.ts +6 -0
- package/dist/host-prompts-wsl.js +64 -0
- package/dist/host-prompts.d.ts +3 -0
- package/dist/host-prompts.js +25 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +47 -0
- package/dist/network.d.ts +54 -0
- package/dist/network.js +204 -0
- package/dist/origin-storage.d.ts +31 -0
- package/dist/origin-storage.js +82 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.js +52 -0
- package/dist/pipe.d.ts +9 -0
- package/dist/pipe.js +73 -0
- package/dist/profile-mode.d.ts +10 -0
- package/dist/profile-mode.js +42 -0
- package/dist/protocol-help.d.ts +34 -0
- package/dist/protocol-help.js +66 -0
- package/dist/protocol.d.ts +49 -0
- package/dist/protocol.js +89 -0
- package/dist/refs.d.ts +9 -0
- package/dist/refs.js +46 -0
- package/dist/routes.d.ts +20 -0
- package/dist/routes.js +106 -0
- package/dist/runner.d.ts +20 -0
- package/dist/runner.js +81 -0
- package/dist/session-name.d.ts +9 -0
- package/dist/session-name.js +50 -0
- package/dist/session-store.d.ts +5 -0
- package/dist/session-store.js +58 -0
- package/dist/sessions.d.ts +47 -0
- package/dist/sessions.js +171 -0
- package/dist/tab-groups.d.ts +9 -0
- package/dist/tab-groups.js +13 -0
- package/dist/targets.d.ts +43 -0
- package/dist/targets.js +229 -0
- package/dist/validate.d.ts +3 -0
- package/dist/validate.js +31 -0
- package/dist/wait.d.ts +24 -0
- package/dist/wait.js +88 -0
- package/examples/go/go.mod +3 -0
- package/examples/go/main.go +104 -0
- package/examples/hn-front-page.sh +18 -0
- package/examples/hn-front-page.ts +24 -0
- package/examples/hn_front_page.py +56 -0
- package/extension/tab-groups/manifest.json +8 -0
- package/extension/tab-groups/service-worker.js +41 -0
- package/package.json +60 -0
- package/skills/patchrome/SKILL.md +74 -0
- package/skills/patchrome/references/commands.md +130 -0
- package/skills/patchrome/references/debugging.md +20 -0
- package/skills/patchrome/references/hard-pages.md +49 -0
- package/skills/patchrome/references/logins.md +46 -0
- package/skills/patchrome/references/scraping.md +51 -0
- package/skills/patchrome/references/scripting.md +79 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akaike Technologies
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
# patchrome
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/patchrome)
|
|
4
|
+
[](https://github.com/akaike-byob/patchrome/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
One stealth Chrome, shared by all your coding agents. Each agent session gets its own tabs.
|
|
8
|
+
|
|
9
|
+
patchrome is a command-line browser for Claude Code, Codex, pi and humans in a terminal. A small
|
|
10
|
+
daemon keeps one headed Google Chrome running through [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright-nodejs),
|
|
11
|
+
the undetected fork of Playwright. Every `patchrome` call is a short-lived client, so five agents
|
|
12
|
+
can browse at once in the same logged-in profile without touching each other's tabs.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
patchrome open https://news.ycombinator.com
|
|
16
|
+
patchrome snapshot # accessibility tree with @refs, written to a file
|
|
17
|
+
patchrome click @f1e42
|
|
18
|
+
patchrome text
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **One browser, many sessions.** Chrome's profile lock allows one automation client per profile.
|
|
22
|
+
patchrome puts a daemon in front of it, and every session owns only the tabs it opened.
|
|
23
|
+
- **Stealth by default.** Real Google Chrome, headed, with Patchright's patches: no `Runtime.enable`,
|
|
24
|
+
no `navigator.webdriver`, no open debugging port.
|
|
25
|
+
- **Small context.** A CLI plus a 5 KB `SKILL.md`, loaded only in sessions that browse, with details
|
|
26
|
+
in reference files the agent reads when a task needs them. Snapshots go to files, and stdout carries
|
|
27
|
+
the path.
|
|
28
|
+
- **Scriptable.** Explore a site with an agent, then `session history` exports what worked as a sh
|
|
29
|
+
script with refs rewritten as role and name locators. `patchrome pipe` runs JSON requests from any
|
|
30
|
+
language over one connection, and `import { connect } from "patchrome"` does the same in Node.
|
|
31
|
+
- **Stays out of your way.** Tabs open in the background, and agents work in them while you use the Mac.
|
|
32
|
+
When the daemon first starts Chrome, macOS brings it forward for under a second, and patchrome hands
|
|
33
|
+
focus back to the app you were in.
|
|
34
|
+
|
|
35
|
+
> [!NOTE]
|
|
36
|
+
> Early software. The core loop, network capture, scraping, the debug profile and scripting work
|
|
37
|
+
> (milestones M1 to M5 below). macOS is the only platform tested so far.
|
|
38
|
+
|
|
39
|
+
## Why
|
|
40
|
+
|
|
41
|
+
Browser tools for agents usually get one of two things right:
|
|
42
|
+
|
|
43
|
+
- **The session model.** [agent-browser](https://github.com/vercel-labs/agent-browser) has tabs per
|
|
44
|
+
session and a `tab_gone` error, but it drives stock Chrome over plain CDP. Bot walls notice that.
|
|
45
|
+
- **Stealth.** Patchright, CloakBrowser and Camoufox pass the detectors. But they are libraries: each
|
|
46
|
+
script launches its own browser and locks its own profile.
|
|
47
|
+
|
|
48
|
+
With several agents in parallel, you end up logging in once per agent or taking turns. patchrome
|
|
49
|
+
takes agent-browser's session model and runs it on Patchright.
|
|
50
|
+
|
|
51
|
+
| | patchrome | agent-browser | playwright-cli | chrome-devtools-mcp | Patchright |
|
|
52
|
+
|---|---|---|---|---|---|
|
|
53
|
+
| Many agents in one browser, tabs owned per session | yes, always on | yes, with `--pin-tab` | separate browsers per session | shared, pass `pageId` | no, a library |
|
|
54
|
+
| Stealth against bot detection | Patchright on real Chrome | stock CDP | stock Playwright | stock CDP | yes |
|
|
55
|
+
| Snapshot with refs | yes | yes | yes | yes, uids | no |
|
|
56
|
+
| Network log, HAR, request mocking | yes, per session | yes | yes | log only | via API |
|
|
57
|
+
| Console, perf traces | debug profile only | yes | yes | yes, the best | console disabled |
|
|
58
|
+
| Interface | CLI + skill | CLI + skill | CLI + skill | MCP, CLI | Node/Python API |
|
|
59
|
+
|
|
60
|
+
patchrome is built for real sites: logged-in apps, scraping, pages behind Cloudflare or DataDome,
|
|
61
|
+
and several agents at once. For your own localhost app, a debug profile adds console capture, page
|
|
62
|
+
errors, traces and raw CDP, and it lets chrome-devtools-mcp attach for Lighthouse and performance work.
|
|
63
|
+
|
|
64
|
+
## Requirements
|
|
65
|
+
|
|
66
|
+
- macOS (Linux untested, Windows and WSL not supported yet)
|
|
67
|
+
- Node.js 24.2 or newer. A checkout runs the TypeScript sources through Node's type stripping. Node
|
|
68
|
+
refuses to strip types under `node_modules`, so the package ships JavaScript compiled into `dist/`.
|
|
69
|
+
- Google Chrome installed in the usual place
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npm i -g patchrome # the `patchrome` command, on PATH everywhere
|
|
75
|
+
npx patchrome session # or without installing
|
|
76
|
+
npm i patchrome # in a Node project, for `import { connect } from "patchrome"`
|
|
77
|
+
patchrome session # starts the daemon and Chrome, prints your session name
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Every install of the same version shares one daemon per profile, so a global CLI, a project's library
|
|
81
|
+
and `npx` can browse at once.
|
|
82
|
+
|
|
83
|
+
From a checkout:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
cd patchrome
|
|
87
|
+
npm install
|
|
88
|
+
npm link # runs the TypeScript sources; edits take effect on the next daemon start
|
|
89
|
+
npm run build # dist/, which `import "patchrome"` and the packed tarball use
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A checkout's CLI runs `src/` and its library runs `dist/`, so they report different builds and cannot
|
|
93
|
+
share a daemon: stop one's daemon before using the other.
|
|
94
|
+
|
|
95
|
+
### Give it to your agents
|
|
96
|
+
|
|
97
|
+
The skill installs with the [`skills`](https://github.com/vercel-labs/skills) CLI, which supports
|
|
98
|
+
Claude Code, Codex, Cursor, Gemini CLI, OpenCode and dozens more:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npx skills@latest add akaike-byob/patchrome # this project's agents
|
|
102
|
+
npx skills@latest add akaike-byob/patchrome -g # every project, for your user
|
|
103
|
+
npx skills@latest add akaike-byob/patchrome -g -a claude-code -a codex
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
[`skills/patchrome/SKILL.md`](skills/patchrome/SKILL.md), 5 KB, teaches the snapshot, ref, act, wait
|
|
107
|
+
loop, locators, the error codes, and the rules: never touch another session's tabs, read big snapshots
|
|
108
|
+
from their files, and hand CAPTCHAs to a person. A table in it sends the agent to one file in
|
|
109
|
+
[`references/`](skills/patchrome/references) per kind of task: the full command list, scraping,
|
|
110
|
+
scripting, logins, iframes and CAPTCHAs, and debugging a local app. Those files cost nothing until
|
|
111
|
+
read. A unit test keeps SKILL.md under 6 KB, every reference linked from it and none linking onward. Its `allowed-tools` grants `Bash(patchrome:*)`, so
|
|
112
|
+
agents that honour it run browser commands without a permission prompt.
|
|
113
|
+
|
|
114
|
+
## How it works
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
claude session --+
|
|
118
|
+
codex session --+-- patchrome CLI -- unix socket -- daemon -- Patchright -- Chrome
|
|
119
|
+
you, a terminal --+ (one per command) (JSON lines) (one per profile) (headed)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- **Daemon.** The first command starts the daemon. `mkdir` is atomic, so three agents starting at once
|
|
123
|
+
still get one daemon. It exits after 30 minutes without requests, or when you close Chrome.
|
|
124
|
+
- **Socket.** A unix socket with mode `0600` under `~/.cache/patchrome/<profile>/`. No TCP port is
|
|
125
|
+
opened: page scripts can probe localhost ports, and a debugging port would give them control of
|
|
126
|
+
every logged-in account.
|
|
127
|
+
- **Sessions.** A session is a name, resolved in order: `--session`, `$PATCHROME_SESSION`,
|
|
128
|
+
`$CLAUDE_CODE_SESSION_ID`, the terminal's tty, then the first non-shell parent process. Claude Code
|
|
129
|
+
sessions need no setup.
|
|
130
|
+
- **Tab ownership.** Commands from one session run in order. Different sessions run in parallel.
|
|
131
|
+
Popups join the session whose click opened them. If your current tab closes, the next command fails
|
|
132
|
+
with `tab_gone` instead of acting on someone else's tab.
|
|
133
|
+
- **Shared profile.** Cookies and logins are shared: log in once, and every agent is logged in. A
|
|
134
|
+
session started with `open --isolated` gets its own in-memory cookie jar instead, gone when the
|
|
135
|
+
session closes or the daemon restarts.
|
|
136
|
+
- **Restarts.** Chrome closes with the daemon. The daemon saves each session's tabs to
|
|
137
|
+
`sessions.json`, and after a restart it reopens a session's tabs, same ids and URLs, on that
|
|
138
|
+
session's next command. Form input, scroll position and snapshot refs do not survive. Session
|
|
139
|
+
folders untouched for 7 days are deleted when the daemon starts.
|
|
140
|
+
- **Tab groups.** Each session's tabs sit in a Chrome tab group titled with the session name, so you
|
|
141
|
+
can see which agent owns which tabs. `session label "checkout flow"` makes the title
|
|
142
|
+
`agent-1: checkout flow`. The colour comes from the name. The daemon loads a small bundled extension
|
|
143
|
+
over its launch pipe (`--enable-unsafe-extension-debugging`, no port) to do this; it injects nothing
|
|
144
|
+
into pages and shows in `chrome://extensions`. Isolated sessions stay ungrouped, because Chrome keeps
|
|
145
|
+
their browser context out of reach of extensions.
|
|
146
|
+
|
|
147
|
+
## Commands
|
|
148
|
+
|
|
149
|
+
Global flags come before the command: `--json`, `--timeout-ms <n>` (default 30000),
|
|
150
|
+
`--session <name>`, `--profile <name>` (default `stealth`).
|
|
151
|
+
|
|
152
|
+
| Command | Does |
|
|
153
|
+
|---|---|
|
|
154
|
+
| `open [url] [--wait load\|domcontentloaded\|networkidle] [--isolated]` | new background tab, becomes your current tab; `--isolated` on a session's first tab gives it its own cookies |
|
|
155
|
+
| `goto <url> [--wait ...]` | navigate the current tab |
|
|
156
|
+
| `tabs [--all]` | your tabs; `--all` lists every session's, read-only |
|
|
157
|
+
| `switch <tab>`, `close [tab]` | among your own tabs |
|
|
158
|
+
| `snapshot [--inline\|--out <file>]` | accessibility tree with `@refs`, iframes included, to a file |
|
|
159
|
+
| `click <ref>\|<element>`, `fill <ref>\|<element> <text>`, `press <key>` | act on the page; `<element>` is a locator, see [Scripting](#scripting) |
|
|
160
|
+
| `click --selector <css> [--frame <iframe-css>]` | act on elements a snapshot cannot show: CSS pierces closed shadow roots |
|
|
161
|
+
| `click --at <x>,<y>` | trusted click at viewport pixels, reaching any iframe under the point |
|
|
162
|
+
| `type <text>` | trusted keystrokes with human gaps into the focused element |
|
|
163
|
+
| `challenge [--handoff]` | detect Turnstile, reCAPTCHA, hCaptcha, MTCaptcha, DataDome or Cloudflare's full-page check; `--handoff` raises the tab for you to solve it |
|
|
164
|
+
| `text [<element>] [--inline\|--out <file>]` | visible text; over 2 KB goes to a file |
|
|
165
|
+
| `screenshot [--full] [<element>] [--out <file>]` | PNG to a file |
|
|
166
|
+
| `eval <js> [--main-world] [--inline\|--out <file>]` | run JS, print JSON |
|
|
167
|
+
| `extract <schema> [<element>] [--inline\|--out <file>]` | CSS selectors to JSON rows; schema is a file or inline JSON |
|
|
168
|
+
| `wait <element> \| --url <glob> \| --title <text> [--gone]` | block until the page shows the condition, or stops showing it with `--gone` |
|
|
169
|
+
| `wait --load load\|domcontentloaded\|networkidle` | block until the current tab reaches a load state |
|
|
170
|
+
| `watch [--events navigation,load,response,console,error] [--url <glob>] [--count <n>]` | stream your tabs' events, one line each; `console` and `error` need a debug profile |
|
|
171
|
+
| `network list [--url <glob>] [--type xhr,fetch] [--status 4xx]` | your session's requests, last 1000 |
|
|
172
|
+
| `network get <id>\|--url <glob> [--body]` | headers, timing, and the response body; `--url` takes the newest match |
|
|
173
|
+
| `network har start\|stop [--out <file>]` | HAR of your session's requests, with text bodies |
|
|
174
|
+
| `route block <glob>`, `route mock <glob> <file>`, `route list\|clear` | abort or fake requests on your tabs |
|
|
175
|
+
| `login <url> [--until <url-glob>]` | a visible tab for you to sign in; waits up to 10 minutes |
|
|
176
|
+
| `cookies [--domain <domain>]` | cookies in the profile, or in an isolated session's own jar |
|
|
177
|
+
| `state save <file>`, `state load <file>` | cookies and localStorage, in Playwright's storageState format |
|
|
178
|
+
| `state import <site> [--from <chrome-profile>]` | one site's login from your everyday Chrome: cookies, localStorage, IndexedDB |
|
|
179
|
+
| `console [--level <level>] [--follow]` | debug profile: your tabs' console messages; `--follow` streams them |
|
|
180
|
+
| `errors` | debug profile: uncaught page errors with stacks |
|
|
181
|
+
| `trace start\|stop` | debug profile: Playwright trace zip, one session at a time |
|
|
182
|
+
| `cdp <Domain.method> [params-json]` | debug profile: raw CDP on your current tab |
|
|
183
|
+
| `cdp help [Domain\|Domain.method]` | debug profile: CDP reference read from the running Chrome's `/json/protocol` |
|
|
184
|
+
| `devtools-url` | debug profile: the `127.0.0.1` endpoint for chrome-devtools-mcp |
|
|
185
|
+
| `profile create <name> --mode stealth\|debug` | fix a profile's mode before first use |
|
|
186
|
+
| `audit [--count <n>]` | recent login copies into and out of profiles, with who approved them |
|
|
187
|
+
| `session`, `session close` | your session, its label, tab group and tabs; close them all |
|
|
188
|
+
| `session label <text>` | say what the session is doing; shown in its Chrome tab group title |
|
|
189
|
+
| `session history [--format sh\|jsonl] [--out <file>]` | the session's working commands as a replayable script; `session history clear` empties it |
|
|
190
|
+
| `pipe [--bail]` | JSON requests on stdin, one JSON response per line on stdout |
|
|
191
|
+
| `sessions [pattern]` | every session in the daemon, with tab counts and labels |
|
|
192
|
+
| `session close <session\|pattern>` | close other sessions by name or glob; a plain name never matches as a glob |
|
|
193
|
+
| `daemon status\|stop\|logs` | |
|
|
194
|
+
| `completions zsh` | zsh completion for commands, session names and tab ids |
|
|
195
|
+
|
|
196
|
+
### Output
|
|
197
|
+
|
|
198
|
+
Plain text by default, a few lines an agent can read cheaply. Pass `--json` for one object:
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{"ok":true,"data":{"path":"/Users/you/.cache/patchrome/stealth/sessions/readme-example/snapshot-t3-1.yml","url":"https://en.wikipedia.org/wiki/Web_browser","title":"Web browser - Wikipedia","refCount":993}}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Snapshots of real pages are large. Measured during the M1 run on 13 September 2026: 18 to 278 KB
|
|
205
|
+
for Wikipedia articles, and 49 KB for a Hacker News thread. That is why they go to disk, and why the
|
|
206
|
+
agent greps them for what it needs.
|
|
207
|
+
|
|
208
|
+
### Errors
|
|
209
|
+
|
|
210
|
+
Exit code 0 on success, 1 on a command error, 2 on bad usage. `--json` errors carry a `code` from a
|
|
211
|
+
closed set:
|
|
212
|
+
|
|
213
|
+
| Code | Meaning | Recovery |
|
|
214
|
+
|---|---|---|
|
|
215
|
+
| `tab_gone` | your current tab closed, or you have none | `patchrome open <url>` |
|
|
216
|
+
| `ref_stale` | the page navigated since your last snapshot | `patchrome snapshot` |
|
|
217
|
+
| `timeout` | the element or page did not arrive in time | snapshot, or raise `--timeout-ms` |
|
|
218
|
+
| `navigation_failed` | DNS, TLS or connection failure | check the URL |
|
|
219
|
+
| `daemon_unreachable` | the daemon is down or did not start | `patchrome daemon logs` |
|
|
220
|
+
| `daemon_outdated` | the daemon was started by another patchrome build | `patchrome daemon stop` when no agent is browsing |
|
|
221
|
+
| `bad_args` | wrong usage, or a JS error in `eval` | read the message |
|
|
222
|
+
| `unsupported_in_stealth` | the command needs a debug profile | rerun with `--profile debug` |
|
|
223
|
+
| `copy_denied` | the person did not approve a login copy | approve the prompt, or run the command yourself |
|
|
224
|
+
|
|
225
|
+
A stale ref fails at once. Plain Playwright would wait out the full timeout on it.
|
|
226
|
+
|
|
227
|
+
### Waiting for a page
|
|
228
|
+
|
|
229
|
+
A bot wall's interstitial can clear by itself within seconds, so one early read reports a block that
|
|
230
|
+
is not there. `wait` returns as soon as the condition holds. Selector, text and URL waits use Patchright's own
|
|
231
|
+
waits, which query from an isolated world; a title wait polls `document.title` from outside the page:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
patchrome open https://www.reddit.com/r/programming/
|
|
235
|
+
patchrome wait --title "Prove your humanity" --gone --timeout-ms 20000
|
|
236
|
+
patchrome snapshot
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`watch` streams events instead, for an agent that runs it in the background and reads the output:
|
|
240
|
+
|
|
241
|
+
```bash
|
|
242
|
+
patchrome watch --events response --url '*/api/*' --count 1 # returns after the first API response
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Scripting
|
|
246
|
+
|
|
247
|
+
Once an agent has worked out a flow, the same commands run unattended from sh, Python, Go, Node or
|
|
248
|
+
anything else that starts a process. [`examples/`](examples) scrapes Hacker News in each.
|
|
249
|
+
|
|
250
|
+
**Locators.** Refs die when the page changes. Every command that takes an element also takes a
|
|
251
|
+
locator, which finds the element again on the next run:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
patchrome click --role button --name "Sign in" --exact # role and name, as on the snapshot line
|
|
255
|
+
patchrome fill --label Email ada@example.com # form label
|
|
256
|
+
patchrome wait --text "Order placed" # visible text, case-insensitive
|
|
257
|
+
patchrome text --selector "#total" --inline # CSS
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
A locator takes its first match. `--nth <n>` picks another, and `--frame <iframe-css>` looks inside an
|
|
261
|
+
iframe.
|
|
262
|
+
|
|
263
|
+
**One output shape.** Without flags, `text`, `eval`, `extract`, `cookies`, `network list` and
|
|
264
|
+
`network get --body` print a value up to 2 KB and a file path past that. A script passes `--inline` to
|
|
265
|
+
always get the value, or `--out <file>` to always get the file. With `--json`, values keep their JSON
|
|
266
|
+
type: `eval` answers `{"ok":true,"data":{"value":{"n":2}}}`, and `extract` answers `rows` as an array.
|
|
267
|
+
`network get --url '*/api/items*'` takes the newest matching request, so scripts need no request ids.
|
|
268
|
+
|
|
269
|
+
**Recording.** The daemon keeps each session's successful commands. Snapshots, tabs and session
|
|
270
|
+
commands are left out. When a command used a ref, the daemon looks up the ref's line in the snapshot
|
|
271
|
+
it came from and records the role and name instead:
|
|
272
|
+
|
|
273
|
+
```bash
|
|
274
|
+
patchrome session history clear
|
|
275
|
+
patchrome open https://shop.example/login
|
|
276
|
+
patchrome snapshot # - textbox "Email" [ref=e5], - button "Sign in" [ref=e9]
|
|
277
|
+
patchrome fill @e5 ada@example.com
|
|
278
|
+
patchrome click @e9
|
|
279
|
+
patchrome session history
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
```sh
|
|
283
|
+
#!/bin/sh
|
|
284
|
+
# patchrome session tty-s003: 3 steps, 2026-09-13T14:50:02.114Z to 2026-09-13T14:50:09.870Z
|
|
285
|
+
# Lines starting with `# check:` need a look before this runs unattended.
|
|
286
|
+
set -eu
|
|
287
|
+
export PATCHROME_SESSION="${PATCHROME_SESSION:-replay-$$}"
|
|
288
|
+
|
|
289
|
+
patchrome open https://shop.example/login
|
|
290
|
+
patchrome fill --role textbox --name Email --exact ada@example.com
|
|
291
|
+
patchrome click --role button --name 'Sign in' --exact
|
|
292
|
+
patchrome session close
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
`# check:` lines flag what a person should read first. When several elements share the role and
|
|
296
|
+
name, the step gets `--nth` from its position on the recorded page. A ref inside an iframe needs
|
|
297
|
+
`--frame`. Text typed into a password field is not recorded, and the script reads
|
|
298
|
+
`$PATCHROME_SECRET` in its place. Tab ids may differ on replay. `--format jsonl` writes the same
|
|
299
|
+
steps as `pipe` requests.
|
|
300
|
+
|
|
301
|
+
**`patchrome pipe`.** Reads one request per line and writes one JSON response per line. A request
|
|
302
|
+
is the words after `patchrome`, as a JSON array or as `{"id": ..., "argv": [...]}`. A response is the
|
|
303
|
+
`--json` object plus the id, which is the line number when the request gave none:
|
|
304
|
+
|
|
305
|
+
```bash
|
|
306
|
+
printf '%s
|
|
307
|
+
' '["open", "https://news.ycombinator.com"]' \
|
|
308
|
+
'{"id": "top", "argv": ["extract", "{\"rows\": \"tr.athing\", \"fields\": {\"title\": \".titleline > a\"}}", "--inline"]}' \
|
|
309
|
+
| patchrome pipe
|
|
310
|
+
# {"id":1,"ok":true,"data":{"tab":"t1","url":"https://news.ycombinator.com/","title":"Hacker News"}}
|
|
311
|
+
# {"id":"top","ok":true,"data":{"count":30,"rows":[{"title":"..."}]}}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Requests for one session run in order, and requests for different sessions (`--session` inside
|
|
315
|
+
`argv`) run at once. `watch` and `console --follow` send `{"id", "stream"}` lines before their
|
|
316
|
+
response, and they do not hold up the requests behind them. `--bail` stops at the first failure and
|
|
317
|
+
runs no later lines. The exit code is 0 when every request succeeded, 1 otherwise. Kept open as a
|
|
318
|
+
coprocess, one pipe answers a program's commands with no process start per step. 50 `eval`s through
|
|
319
|
+
one pipe took 0.23 s in total on an M-series Mac, against 0.06 s per CLI call from an installed package
|
|
320
|
+
and 0.10 s from a checkout.
|
|
321
|
+
|
|
322
|
+
**Node library.** The same requests, without a child process:
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
import { CommandError, connect } from "patchrome";
|
|
326
|
+
|
|
327
|
+
const browser = connect({ session: "prices" });
|
|
328
|
+
await browser.run("open", "https://shop.example");
|
|
329
|
+
const { rows } = await browser.run("extract", "schema.json", "--inline");
|
|
330
|
+
await browser.stream(["watch", "--events", "response", "--count", "3"], (event) => console.log(event));
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`run` resolves to what `--json` puts under `data` and throws a `CommandError` with the `code`. An idle
|
|
334
|
+
connection does not keep the process alive.
|
|
335
|
+
|
|
336
|
+
**Sessions in scripts.** A script resolves its session the same way the CLI does. Started by Claude
|
|
337
|
+
Code, it shares the agent's session and tabs. Two scripts started from the same terminal share the
|
|
338
|
+
terminal's session. Set `PATCHROME_SESSION`, or `connect({ session })`, per script run.
|
|
339
|
+
|
|
340
|
+
### Tab completion
|
|
341
|
+
|
|
342
|
+
```bash
|
|
343
|
+
source <(patchrome completions zsh) # in ~/.zshrc, after compinit
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Session names and tab ids come from the running daemon. With no daemon running, completion offers
|
|
347
|
+
commands only and never starts Chrome.
|
|
348
|
+
|
|
349
|
+
### Scraping
|
|
350
|
+
|
|
351
|
+
Most shops and apps render from a JSON API. Reading the response the page already fetched is cheaper
|
|
352
|
+
and sturdier than parsing its HTML:
|
|
353
|
+
|
|
354
|
+
```bash
|
|
355
|
+
patchrome open https://shop.example/product/42
|
|
356
|
+
patchrome network list --type xhr,fetch
|
|
357
|
+
# requests: 2
|
|
358
|
+
# n17 t2 200 fetch GET https://shop.example/api/product/42 88ms
|
|
359
|
+
# n18 t2 404 fetch GET https://shop.example/api/reviews 31ms
|
|
360
|
+
patchrome network get n17 --body
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
For data that only exists in HTML:
|
|
364
|
+
|
|
365
|
+
```bash
|
|
366
|
+
patchrome extract '{"rows": "li.product", "fields": {"name": "h2", "url": {"selector": "a", "attr": "href"}}}'
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
`state load` adds cookies to the shared profile without clearing it. Playwright's
|
|
370
|
+
`setStorageState` would clear every cookie first, which would sign every agent out of every site. For
|
|
371
|
+
localStorage, a background tab loads the origin from a local route that never contacts the site.
|
|
372
|
+
|
|
373
|
+
### Reusing a login from your everyday Chrome
|
|
374
|
+
|
|
375
|
+
```bash
|
|
376
|
+
patchrome state import github.com --from "Profile 1"
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`--from` takes the name in Chrome's profile menu, the folder name (`Default`, `Profile 1`), or the
|
|
380
|
+
signed-in email; without it, Chrome's last used profile. A wrong name lists the profiles. The site
|
|
381
|
+
covers its subdomains.
|
|
382
|
+
|
|
383
|
+
The import copies the profile's cookie jar, its localStorage, and the site's IndexedDB into a
|
|
384
|
+
temporary folder, opens the copy in a headless Chrome whose network is routed to empty pages, and
|
|
385
|
+
reads the site's cookies and storage. The copy is deleted afterwards, and your everyday Chrome can keep
|
|
386
|
+
running. Cookies are encrypted with Chrome's key in the macOS Keychain, so the reader Chrome runs
|
|
387
|
+
without Playwright's mock keychain. Cookies go in next to the ones already there. localStorage items
|
|
388
|
+
are added, and each imported IndexedDB database replaces the one with the same name.
|
|
389
|
+
|
|
390
|
+
### Approving login copies
|
|
391
|
+
|
|
392
|
+
`state import` and `state load` put logins into a profile, so they wait for a person. The daemon shows
|
|
393
|
+
one prompt at a time and refuses the copy with `copy_denied` when the person cancels or a minute passes.
|
|
394
|
+
|
|
395
|
+
- **macOS:** Touch ID, or the account password on a Mac without it, through LocalAuthentication.
|
|
396
|
+
- **WSL:** Windows Hello, through `powershell.exe`. Without Hello set up, copies are refused.
|
|
397
|
+
- **Linux:** no prompt a script cannot click exists, so copies are refused.
|
|
398
|
+
|
|
399
|
+
`state save` does not ask. Every copy, approved or not, is appended to
|
|
400
|
+
`~/.cache/patchrome/copy-audit.jsonl`, and `patchrome audit` lists the recent ones. A notification
|
|
401
|
+
appears for every copy the person did not answer themselves: saves, timeouts and prompts that could
|
|
402
|
+
not be shown. No setting skips the prompt.
|
|
403
|
+
|
|
404
|
+
These are guard rails for agents that follow the skill, not a security boundary. An agent running as
|
|
405
|
+
your user can copy Chrome's cookie files and open them itself, and none of this would see it.
|
|
406
|
+
|
|
407
|
+
Limits:
|
|
408
|
+
|
|
409
|
+
- Google accounts sign in with device-bound sessions, so an imported Google login may be refused.
|
|
410
|
+
Use `login` in patchrome for those.
|
|
411
|
+
- Session cookies exist only in Chrome's memory, so they do not come across.
|
|
412
|
+
- IndexedDB values that are Blobs, Maps or Sets are not copied; Dates and binary arrays are.
|
|
413
|
+
- `PATCHROME_CHROME_USER_DATA_DIR` points at another Chrome, such as Chrome Beta.
|
|
414
|
+
|
|
415
|
+
### Debug profile
|
|
416
|
+
|
|
417
|
+
A profile's mode is fixed when it is created. `--profile debug` is a debug profile unless you create
|
|
418
|
+
it otherwise; every other name defaults to stealth. The two profiles run separate daemons and separate
|
|
419
|
+
Chromes side by side.
|
|
420
|
+
|
|
421
|
+
```bash
|
|
422
|
+
patchrome --profile debug open http://localhost:3000
|
|
423
|
+
patchrome --profile debug console --level warning
|
|
424
|
+
patchrome --profile debug errors
|
|
425
|
+
patchrome --profile debug devtools-url
|
|
426
|
+
# http://127.0.0.1:62819
|
|
427
|
+
npx chrome-devtools-mcp@latest --browserUrl http://127.0.0.1:62819
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
A debug Chrome enables `Runtime` on every tab and listens on a random `127.0.0.1` port. Any site can
|
|
431
|
+
detect both, and any local process can take over the browser through the port. Keep it to your own
|
|
432
|
+
apps, and keep your logins in the stealth profile. A stealth profile refuses debug commands with
|
|
433
|
+
`unsupported_in_stealth` and never turns instrumentation on.
|
|
434
|
+
|
|
435
|
+
`console --follow` streams outside your session's command queue, so the same session can keep
|
|
436
|
+
clicking while it runs. A trace records the whole browser context, every session's tabs included, so
|
|
437
|
+
only one session traces at a time.
|
|
438
|
+
|
|
439
|
+
## Stealth
|
|
440
|
+
|
|
441
|
+
The launch follows Patchright's recommended setup: persistent context, `channel: "chrome"`, headed,
|
|
442
|
+
`viewport: null`, and no custom user agent or headers. It also sets `chromiumSandbox: true`, since
|
|
443
|
+
Playwright otherwise passes `--no-sandbox`.
|
|
444
|
+
|
|
445
|
+
Tested 13 September 2026, macOS, Chrome 152, patchright 1.63.0. "Busy" means 3 more tabs were open,
|
|
446
|
+
with network listeners reading every response body.
|
|
447
|
+
|
|
448
|
+
| Detector | Result, quiet and busy | Plain Chrome, same Mac |
|
|
449
|
+
|---|---|---|
|
|
450
|
+
| [bot.sannysoft.com](https://bot.sannysoft.com) | every row passed | not run |
|
|
451
|
+
| [BrowserScan bot detection](https://www.browserscan.net/bot-detection) | Normal on all checks, including WebDriver and CDP | not run |
|
|
452
|
+
| [CreepJS](https://abrahamjuliot.github.io/creepjs/) | 0% headless, 0% stealth, 31% like headless | 0%, 0%, 25% |
|
|
453
|
+
|
|
454
|
+
Two caveats:
|
|
455
|
+
|
|
456
|
+
- CreepJS rates the automated browser 6 points more "like headless" than plain Chrome on the same
|
|
457
|
+
machine. Its red WebGL, Screen and Audio marks appear in plain Chrome too, with identical hashes.
|
|
458
|
+
- Cloudflare Turnstile and DataDome have not been tested against a live challenge yet.
|
|
459
|
+
|
|
460
|
+
Chrome shows a warning bar for `--disable-blink-features=AutomationControlled`. Page scripts cannot
|
|
461
|
+
see it. Removing the flag makes `navigator.webdriver` true, and BrowserScan then reports a robot. So
|
|
462
|
+
the flag stays.
|
|
463
|
+
|
|
464
|
+
`eval` runs in an isolated world by default, where page globals are invisible. `--main-world` sees
|
|
465
|
+
them, but runs inside the page's own JavaScript realm.
|
|
466
|
+
|
|
467
|
+
## Responsible use
|
|
468
|
+
|
|
469
|
+
patchrome drives your own browser, in your profile, on your machine. Stealth exists so that your
|
|
470
|
+
agents can use sites you already use, without tripping bot walls meant for bulk abuse. Do not use it
|
|
471
|
+
for unauthorized access, credential stuffing, mass account creation, or scraping against a site's
|
|
472
|
+
terms. You are responsible for what your agents do with it.
|
|
473
|
+
|
|
474
|
+
## Roadmap
|
|
475
|
+
|
|
476
|
+
- **M1, core loop** (done): daemon, sessions and tab ownership, `open`, `goto`, `snapshot`, `click`,
|
|
477
|
+
`fill`, `press`, `screenshot`, `text`, `eval`, skill.
|
|
478
|
+
- **M2, network and scraping** (done): request log, response bodies, session-scoped HAR, `route`
|
|
479
|
+
block/mock, `extract`, `login`, `cookies`, `state save/load`.
|
|
480
|
+
- **M3, debug profile** (done): profile modes, `console`, `errors`, `trace`, `cdp`, `devtools-url`,
|
|
481
|
+
and `unsupported_in_stealth` in stealth profiles.
|
|
482
|
+
- **M4, hardening** (done): tabs reopen after a daemon restart, `--isolated` sessions with their own
|
|
483
|
+
cookies, session folder pruning, an installable npm package.
|
|
484
|
+
- **M5, scripting** (done): locators, `--inline` and `--out`, `network get --url`, `pipe`, session
|
|
485
|
+
history with refs rewritten as locators, the Node library, and examples in sh, Python, Node and Go.
|
|
486
|
+
- **Later:** Linux and WSL.
|
|
487
|
+
|
|
488
|
+
## Development
|
|
489
|
+
|
|
490
|
+
```bash
|
|
491
|
+
npm install
|
|
492
|
+
npm run format:check # oxfmt; markdown is left as written
|
|
493
|
+
npm run lint # oxlint, type-aware
|
|
494
|
+
npm run typecheck
|
|
495
|
+
npm test # unit tests plus integration tests against a local fixture server
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
The integration tests start a real headed Chrome. They cover:
|
|
499
|
+
|
|
500
|
+
- 3 CLI processes starting the daemon at once
|
|
501
|
+
- concurrent sessions, popups, `tab_gone` and `ref_stale`
|
|
502
|
+
- timeouts, idle exit, and file output
|
|
503
|
+
- tabs reopening after `kill -9` of the daemon, isolated cookie jars, and a global install of the
|
|
504
|
+
packed tarball
|
|
505
|
+
- Chrome tab groups per session, labels, popups joining their opener's group, and groups after a restart
|
|
506
|
+
- locators, output flags, `pipe` with ids and `--bail`, the Node library, and a flow recorded with refs
|
|
507
|
+
replaying from its jsonl and sh exports
|
|
508
|
+
|
|
509
|
+
`spikes/` holds the throwaway scripts that settled the design: snapshot refs under Patchright, the
|
|
510
|
+
stealth baseline, isolated contexts, response bodies at 2,000 requests, and focus theft on macOS.
|
|
511
|
+
|
|
512
|
+
## License
|
|
513
|
+
|
|
514
|
+
[MIT](LICENSE)
|
package/bin/patchrome.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
// A checkout runs the TypeScript sources through Node's type stripping. Node refuses to strip types under
|
|
5
|
+
// node_modules, so the published package ships only the compiled dist/.
|
|
6
|
+
const sourceEntry = new URL("../src/cli.ts", import.meta.url);
|
|
7
|
+
const { runCli } = await import(
|
|
8
|
+
existsSync(sourceEntry) ? sourceEntry.href : new URL("../dist/cli.js", import.meta.url).href
|
|
9
|
+
);
|
|
10
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
package/dist/build-id.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
const srcDir = new URL("./", import.meta.url);
|
|
5
|
+
// ".ts" in a checkout, where Node strips types, and ".js" in the published package, built into dist/.
|
|
6
|
+
export const moduleExtension = extname(fileURLToPath(import.meta.url));
|
|
7
|
+
// An installed package is identified by its version, so the same release installed twice (globally, in a
|
|
8
|
+
// project, in the npx cache) shares one daemon. A checkout adds the newest source mtime, so a daemon left
|
|
9
|
+
// running across an edit reports a different id than the CLI talking to it.
|
|
10
|
+
export function currentBuildId(env = process.env) {
|
|
11
|
+
// Tests set this to play a CLI from another build without editing sources.
|
|
12
|
+
if (env.PATCHROME_BUILD_ID !== undefined && env.PATCHROME_BUILD_ID !== "")
|
|
13
|
+
return env.PATCHROME_BUILD_ID;
|
|
14
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
15
|
+
if (moduleExtension === ".js")
|
|
16
|
+
return version;
|
|
17
|
+
const newestMtimeMs = Math.max(...readdirSync(srcDir)
|
|
18
|
+
.filter((name) => name.endsWith(moduleExtension))
|
|
19
|
+
.map((name) => statSync(new URL(name, srcDir)).mtimeMs));
|
|
20
|
+
return `${version}+${Math.floor(newestMtimeMs)}`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Page } from "patchright";
|
|
2
|
+
export declare const challengeVendors: readonly ["turnstile", "recaptcha", "hcaptcha", "datadome", "mtcaptcha", "cloudflare-interstitial", "unknown"];
|
|
3
|
+
export type ChallengeVendor = (typeof challengeVendors)[number];
|
|
4
|
+
export interface ChallengeWidget {
|
|
5
|
+
vendor: ChallengeVendor;
|
|
6
|
+
url: string;
|
|
7
|
+
box: {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
} | undefined;
|
|
13
|
+
}
|
|
14
|
+
export type ChallengeState = "none" | "pending" | "solved";
|
|
15
|
+
export interface ChallengeReport {
|
|
16
|
+
state: ChallengeState;
|
|
17
|
+
widgets: ChallengeWidget[];
|
|
18
|
+
}
|
|
19
|
+
export declare function vendorOfFrameUrl(url: string): ChallengeVendor | undefined;
|
|
20
|
+
export declare function isInterstitialTitle(title: string): boolean;
|
|
21
|
+
export declare function inspectChallenges(page: Page): Promise<ChallengeReport>;
|
|
22
|
+
export declare function waitForPersonToSolve(page: Page, timeoutMs: number): Promise<ChallengeReport>;
|