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.
Files changed (98) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +514 -0
  3. package/bin/patchrome.js +10 -0
  4. package/dist/build-id.d.ts +2 -0
  5. package/dist/build-id.js +21 -0
  6. package/dist/challenges.d.ts +22 -0
  7. package/dist/challenges.js +97 -0
  8. package/dist/chrome-profiles.d.ts +17 -0
  9. package/dist/chrome-profiles.js +141 -0
  10. package/dist/cli-options.d.ts +131 -0
  11. package/dist/cli-options.js +43 -0
  12. package/dist/cli.d.ts +48 -0
  13. package/dist/cli.js +572 -0
  14. package/dist/client.d.ts +16 -0
  15. package/dist/client.js +210 -0
  16. package/dist/commands.d.ts +58 -0
  17. package/dist/commands.js +1076 -0
  18. package/dist/completions.d.ts +1 -0
  19. package/dist/completions.js +114 -0
  20. package/dist/copy-guard.d.ts +75 -0
  21. package/dist/copy-guard.js +167 -0
  22. package/dist/daemon.d.ts +7 -0
  23. package/dist/daemon.js +313 -0
  24. package/dist/diagnostics.d.ts +44 -0
  25. package/dist/diagnostics.js +117 -0
  26. package/dist/engine.d.ts +51 -0
  27. package/dist/engine.js +257 -0
  28. package/dist/events.d.ts +41 -0
  29. package/dist/events.js +106 -0
  30. package/dist/extract.d.ts +27 -0
  31. package/dist/extract.js +62 -0
  32. package/dist/focus.d.ts +1 -0
  33. package/dist/focus.js +44 -0
  34. package/dist/glob.d.ts +4 -0
  35. package/dist/glob.js +63 -0
  36. package/dist/har.d.ts +105 -0
  37. package/dist/har.js +88 -0
  38. package/dist/history.d.ts +35 -0
  39. package/dist/history.js +277 -0
  40. package/dist/host-platform.d.ts +5 -0
  41. package/dist/host-platform.js +19 -0
  42. package/dist/host-prompts-macos.d.ts +2 -0
  43. package/dist/host-prompts-macos.js +102 -0
  44. package/dist/host-prompts-wsl.d.ts +6 -0
  45. package/dist/host-prompts-wsl.js +64 -0
  46. package/dist/host-prompts.d.ts +3 -0
  47. package/dist/host-prompts.js +25 -0
  48. package/dist/index.d.ts +17 -0
  49. package/dist/index.js +47 -0
  50. package/dist/network.d.ts +54 -0
  51. package/dist/network.js +204 -0
  52. package/dist/origin-storage.d.ts +31 -0
  53. package/dist/origin-storage.js +82 -0
  54. package/dist/paths.d.ts +17 -0
  55. package/dist/paths.js +52 -0
  56. package/dist/pipe.d.ts +9 -0
  57. package/dist/pipe.js +73 -0
  58. package/dist/profile-mode.d.ts +10 -0
  59. package/dist/profile-mode.js +42 -0
  60. package/dist/protocol-help.d.ts +34 -0
  61. package/dist/protocol-help.js +66 -0
  62. package/dist/protocol.d.ts +49 -0
  63. package/dist/protocol.js +89 -0
  64. package/dist/refs.d.ts +9 -0
  65. package/dist/refs.js +46 -0
  66. package/dist/routes.d.ts +20 -0
  67. package/dist/routes.js +106 -0
  68. package/dist/runner.d.ts +20 -0
  69. package/dist/runner.js +81 -0
  70. package/dist/session-name.d.ts +9 -0
  71. package/dist/session-name.js +50 -0
  72. package/dist/session-store.d.ts +5 -0
  73. package/dist/session-store.js +58 -0
  74. package/dist/sessions.d.ts +47 -0
  75. package/dist/sessions.js +171 -0
  76. package/dist/tab-groups.d.ts +9 -0
  77. package/dist/tab-groups.js +13 -0
  78. package/dist/targets.d.ts +43 -0
  79. package/dist/targets.js +229 -0
  80. package/dist/validate.d.ts +3 -0
  81. package/dist/validate.js +31 -0
  82. package/dist/wait.d.ts +24 -0
  83. package/dist/wait.js +88 -0
  84. package/examples/go/go.mod +3 -0
  85. package/examples/go/main.go +104 -0
  86. package/examples/hn-front-page.sh +18 -0
  87. package/examples/hn-front-page.ts +24 -0
  88. package/examples/hn_front_page.py +56 -0
  89. package/extension/tab-groups/manifest.json +8 -0
  90. package/extension/tab-groups/service-worker.js +41 -0
  91. package/package.json +60 -0
  92. package/skills/patchrome/SKILL.md +74 -0
  93. package/skills/patchrome/references/commands.md +130 -0
  94. package/skills/patchrome/references/debugging.md +20 -0
  95. package/skills/patchrome/references/hard-pages.md +49 -0
  96. package/skills/patchrome/references/logins.md +46 -0
  97. package/skills/patchrome/references/scraping.md +51 -0
  98. 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
+ [![npm](https://img.shields.io/npm/v/patchrome)](https://www.npmjs.com/package/patchrome)
4
+ [![CI](https://github.com/akaike-byob/patchrome/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/akaike-byob/patchrome/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/patchrome)](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)
@@ -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));
@@ -0,0 +1,2 @@
1
+ export declare const moduleExtension: string;
2
+ export declare function currentBuildId(env?: NodeJS.ProcessEnv): string;
@@ -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>;