@glittering0717/ai-browser 0.0.0-stage → 1.2.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/README.md CHANGED
@@ -1,3 +1,785 @@
1
- # Temporary Holding Version
1
+ <h1 align="center">AI Browser</h1>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <p align="center">
4
+ <b>A real browser whose only operator is your agent.</b><br>
5
+ It reads pages as a semantic tree instead of screenshots, acts through trusted browser input —
6
+ and because it is a normal, visible Electron window, a human can reach for the mouse at any time.
7
+ </p>
8
+
9
+ <p align="center">
10
+ <img alt="CI" src="https://github.com/Glittering/ai-browser/actions/workflows/ci.yml/badge.svg">
11
+ <img alt="MIT" src="https://img.shields.io/badge/license-MIT-blue.svg">
12
+ <img alt="Electron" src="https://img.shields.io/badge/built%20with-Electron%2033-9cf">
13
+ <img alt="Platforms" src="https://img.shields.io/badge/platforms-macOS%20%E2%80%A2%20Linux-blueviolet">
14
+ <img alt="Tests" src="https://img.shields.io/badge/tests-411%20unit%20%2B%20409%20e2e-2ea043">
15
+ <img alt="MCP" src="https://img.shields.io/badge/spec-MCP%20(stdio)-f5b23b">
16
+ </p>
17
+
18
+ <p align="center">
19
+ <em>One agent, no coordinates: open Bing → type through trusted key events (live suggestions) → scroll the results → open page 2 → back to page 1 → open the project site in a new tab → keep scrolling there. Every target was found in the semantic tree by role/label.</em><br>
20
+ <a href="docs/marketing/demo.gif"><img src="docs/marketing/demo.gif" alt="AI Browser demo: search, paginate, and open a website — all driven by the semantic tree" width="860"></a><br>
21
+ Reproduce: <code>npm start</code> then <code>node examples/demo.mjs</code>
22
+ </p>
23
+
24
+ **What it is.** An Electron app that exposes its tabs over two equivalent interfaces — an MCP server
25
+ (17 `browse_*` tools) and a raw WebSocket JSON-RPC API (`ws://localhost:9223`, methods `ui.*`). Your
26
+ agent navigates, reads a structured semantic tree (`role` / `label` / `value` / `states` / `actions`),
27
+ and clicks or types by `data-ai-id`. There is no screenshot step and no OCR: `src/` contains no
28
+ capture call at all.
29
+
30
+ **What it is not.** It is not headless. The window really opens, really renders, and stays interactive —
31
+ see [Letting a human step in](#letting-a-human-step-in). Nor does it "co-drive" with you: the hand-off
32
+ is a conversation between the agent and the user, not a browser API.
33
+
34
+ > **Security note.** The WebSocket server has no authentication — it listens on localhost and trusts
35
+ > whoever connects. Do not expose port 9223 beyond loopback, and avoid untrusted networks.
36
+
37
+ ---
38
+
39
+ ## Install
40
+
41
+ Requires **Node ≥ 18** and a desktop session (this is a GUI app).
42
+
43
+ ### Option A — npx (recommended for MCP users)
44
+
45
+ The package is published as [`@glittering0717/ai-browser`](https://www.npmjs.com/package/@glittering0717/ai-browser); no clone needed:
46
+
47
+ ```bash
48
+ npx -y @glittering0717/ai-browser --help
49
+ ```
50
+
51
+ To open the visible browser window directly:
52
+
53
+ ```bash
54
+ npx -y @glittering0717/ai-browser start
55
+ ```
56
+
57
+ The first launch prepares the Electron engine (~100 MB, downloaded once). npm ≥ 11 blocks
58
+ dependency install scripts by default — the launcher detects that and runs Electron's own
59
+ downloader itself, so nothing extra is needed. If the download from GitHub releases is slow or
60
+ blocked (common on mainland-China networks), point it at a mirror:
61
+
62
+ ```bash
63
+ ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npx -y @glittering0717/ai-browser start
64
+ ```
65
+
66
+ ### Option B — from source
67
+
68
+ ```bash
69
+ git clone https://github.com/Glittering/ai-browser.git
70
+ cd ai-browser
71
+ npm install
72
+ ```
73
+
74
+ If the Electron binary download is slow or fails outright, use the mirror:
75
+
76
+ ```bash
77
+ ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm install
78
+ ```
79
+
80
+ Or make it sticky by creating a `.npmrc` in the project root:
81
+
82
+ ```ini
83
+ electron_mirror=https://npmmirror.com/mirrors/electron/
84
+ ```
85
+
86
+ Confirm the binary actually landed before blaming anything else — a missing binary still leaves you
87
+ with an "install that succeeded":
88
+
89
+ ```bash
90
+ ls node_modules/electron/dist
91
+ ```
92
+
93
+ To check the install end to end:
94
+
95
+ ```bash
96
+ npm start
97
+ ```
98
+
99
+ A 1280×800 window opens and the terminal prints `AI Browser WS server listening on ws://localhost:9223`.
100
+ (The first tab opens on the default start page; ignore it and navigate away.)
101
+
102
+ ---
103
+
104
+ ## Connect your agent
105
+
106
+ Add this to your MCP client's config file — Claude Desktop's config, `~/.cursor/mcp.json`, or a
107
+ project-level `.mcp.json`:
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "ai-browser": {
113
+ "command": "npx",
114
+ "args": ["-y", "@glittering0717/ai-browser", "mcp"]
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ A ready-made copy sits in [`examples/mcp-config.json`](examples/mcp-config.json). From-source users
121
+ can instead point `command: "node"` at `/absolute/path/to/ai-browser/src/main/mcp_server.js`.
122
+
123
+ **You do not need to start the browser yourself.** On the first `browse_*` call the MCP server probes
124
+ port 9223; if nothing is listening it spawns Electron detached and waits up to 30 seconds for the
125
+ WebSocket server (the very first call may additionally spend time preparing the engine). If you would
126
+ rather keep one long-lived window shared by every client, run `npx -y @glittering0717/ai-browser start`
127
+ (or `npm start` from source) in a terminal — a second launch just focuses the existing window.
128
+
129
+ `examples/AGENT_INSTRUCTIONS.md` has a copy-paste prompt you can drop into a `CLAUDE.md`, Cursor Rule,
130
+ or any system prompt; it teaches the tool loop, the token-saving params, and when to ask a human.
131
+ Agents installing this themselves (Cline, etc.) can read the short machine-oriented
132
+ [`llms-install.md`](llms-install.md) instead of this README.
133
+
134
+ ---
135
+
136
+ ## The MCP tools
137
+
138
+ Source of truth: [`src/main/mcp_tools.js`](src/main/mcp_tools.js). There are 17 — a deliberately
139
+ small surface, since every tool schema is injected into your agent's context on every turn.
140
+
141
+ | Tool | One line |
142
+ |---|---|
143
+ | `browse_navigate` | Load a URL in the active (or given) tab. |
144
+ | `browse_get_tree` | Read the page as a semantic tree + page context (modals, messages, stats). |
145
+ | `browse_act` | `click` / `type` (append at caret) / `setContent` (replace all) / `clear` / `focus` / `hover` / `scroll_to` / `upload` / `get_value` (read-only) / `drag` / `press` / `wheel` on a `data-ai-id`. |
146
+ | `browse_diff` | `snapshot` a baseline, then `diff` to get added / removed / changed paths with before/after values. **This is how you prove an action did anything.** |
147
+ | `browse_capabilities` | Probe what a page affords and which route to take on it (see below). |
148
+ | `browse_repaint` | Force the compositor to redraw the tab when the window shows a stale page; returns `repaint_count`. |
149
+ | `browse_downloads` | List a tab's downloads with their on-disk paths. Downloads are asynchronous — poll this after clicking an export button. |
150
+ | `browse_canvas` | Read `<canvas>` through captured draw calls: `list` / `read` / `configure` / `capture`. **Experimental — see the canvas section below.** |
151
+ | `browse_evaluate` | Run a JS *expression* in page context (5000-char cap, Node identifiers rejected). |
152
+ | `browse_scroll` | Scroll the page, or scroll an element into view. |
153
+ | `browse_wait` | Poll until `button_enabled` / `modal_appeared` / `text_contains` / `url_contains`. |
154
+ | `browse_list_tabs` | List open tabs (id, url, title, active). |
155
+ | `browse_new_tab` | Open a new tab, optionally at a URL. |
156
+ | `browse_close_tab` | Close a tab by id. |
157
+ | `browse_set_active_tab` | Switch which tab subsequent calls act on. |
158
+ | `browse_network` | Chrome-DevTools-style network inspection: `list` (filter by method / URL / status / type), `get` (request + response headers and bodies), `clear`, `configure`. |
159
+ | `browse_quit` | Shut the whole browser down and release the process. |
160
+
161
+ ### Prove it happened: `browse_diff`
162
+
163
+ A tool reporting `success` is **not** evidence that anything changed — the browser will happily
164
+ tell you a click landed on an element that ignored it, a drag completed when the app snapped the
165
+ node somewhere else entirely, or a keystroke went into a field that then cleared itself. Every
166
+ write should be wrapped in a snapshot/diff pair:
167
+
168
+ ```js
169
+ const base = await browse_diff({ operation: "snapshot", js: "app.graph.serialize()" });
170
+ await browse_act({ action: "click", target: "e:button-25" });
171
+ const d = await browse_diff({ operation: "diff", snapshot: base.snapshot });
172
+ // d.verdict === "changed"
173
+ // d.changed_paths → [{ path: "nodes[7].widgets.steps", from: 20, to: 21, reason: "value" }]
174
+ ```
175
+
176
+ - `verdict` is an explicit `"changed"` / `"unchanged"`, so "nothing happened" is never inferred from
177
+ an empty array you might have built wrong.
178
+ - Every entry carries a `path` plus **both** values, so you can check *why* it changed.
179
+ - Arrays are compared **element-wise by index**, not as sets: a moved node reads as changed entries,
180
+ never as "one removed + one added" (which would look like two events instead of one reorder).
181
+ - Float noise is tolerated (`201354.48743718592` vs `...1859` is *not* a change), but a real 22-unit
182
+ coordinate shift is.
183
+ - Omit `js` for a **site-agnostic DOM snapshot** — role / label / value / disabled / size per
184
+ element plus the page's visible text. No per-site JS required, and plain `<div>` status text
185
+ changes are caught.
186
+ - Omit `js` and it **also fingerprints the app's own data model** when the page hangs one off
187
+ `window`. A canvas-rendered app's DOM is a still photograph — dragging a node or editing a
188
+ widget repaints `<canvas>` and moves nothing the element walk can see, so a DOM-only diff
189
+ answers `unchanged` to a write that demonstrably happened. ComfyUI's documented write path
190
+ (`node.widgets[].value`) moved the DOM by exactly zero bytes. With the fingerprint, that same
191
+ write shows up as `model.fp` (and `model.count` when the node count moved). It is read-only
192
+ (`loadGraphData` is a loader and is never called), it picks whichever candidate can actually be
193
+ read back rather than whichever *looks* most promising, and a library namespace — which scores
194
+ well on names and has no model to hand back — is skipped.
195
+ - `rearm: true` moves the baseline forward for the *next* diff, so you can loop "assert nothing
196
+ changed" across many steps; `forget: true` drops it.
197
+ - A throwing expression, a missing id, or a cross-tab comparison all return an explicit error.
198
+ They never degrade into an empty snapshot, which would make every subsequent diff report
199
+ "unchanged" — the one failure mode that turns all verification into false green.
200
+
201
+ ### Which route on this page? `browse_capabilities`
202
+
203
+ Before improvising on an unfamiliar site, ask. One call reports the semantic-tree size and its
204
+ role/action histogram, canvas count, same-origin iframes and their internal size, objects the app
205
+ hangs off `window` (auto-detected by shape: `has .graph` / `._nodes` / `getState` / `serialize`),
206
+ the JSON endpoints it has already called, and a **recommended route with the evidence for it**:
207
+
208
+ - `L1_structured_data` — the app exposes its own model; read and write the whole graph as JSON.
209
+ - `L2_semantic_tree` — enough elements to find targets by role/label and drive them by `data-ai-id`.
210
+ - `L3_pointer_gestures` — only for what has no structured path (drag a node, draw a connection,
211
+ wheel pan/zoom). Always follow with `browse_diff`.
212
+
213
+ It also flags traps explicitly: "the body is in a same-origin iframe" or "there are 7 canvases but
214
+ only 17 semantic elements — stop trying to hit canvas-drawn items with coordinates and go find the
215
+ data model instead".
216
+
217
+ Notes that save round-trips:
218
+
219
+ - `browse_get_tree` accepts `subset: "interactive"` to drop layout-only branches, `mode: "diff"` to
220
+ return only nodes that appeared/disappeared since the last read, and `ax: true` to read through the
221
+ accessibility layer (links then carry `url`).
222
+ - **`type` appends; `setContent` replaces.** They used to be identical (both replaced the whole
223
+ content), which made incremental editing impossible — insert a Weibo-style topic tag and the next
224
+ `type` wiped it. Now `type` writes at the caret (`params.at`: `end` (default) / `start` /
225
+ `cursor`), so "insert a topic, then keep writing the body" works. To refill a field, use
226
+ `setContent` (or `clear` first).
227
+ - `browse_act` also takes `action: "get_value"` — a **read-only** way to fetch an element's full text,
228
+ which matters because `browse_get_tree` truncates long values at 200 characters (see below).
229
+ - **Pointer gestures are first-class**: `drag`, `press` and `wheel`. Canvas / board / sortable UIs are
230
+ built on them, and no amount of `evaluate` can substitute — synthetic `PointerEvent`s do not move a
231
+ framework that uses Pointer Capture (measured on a React Flow canvas: the node's position changed by
232
+ exactly 0,0), so these go through CDP trusted input.
233
+ - `drag`: `dx`/`dy`, `to_x`/`to_y`, or `to_target` (drop onto another element). `from_anchor`
234
+ / `to_anchor` (`left`/`right`/`top`/`bottom`/`center`) grab a side — a side prefers that element's
235
+ connection handle, **but only if the handle is actually hit-testable** (see the note below).
236
+ `from_x`/`from_y` start from a bare point (panning empty canvas), in which case no `target` is needed.
237
+ - `press`: `key` (`Delete`, `Escape`, `ArrowRight`, `Enter`, … or a combo like `"Meta+a"`), or `keys`
238
+ for a sequence. Deleting a node, undo, deselection — all keyboard-only.
239
+ - `wheel`: `delta_x` / `delta_y` plus `hold: ["Control"]` for canvas zoom. **`browse_scroll` cannot do
240
+ this** — it calls `window.scrollBy`, which an infinite canvas ignores entirely.
241
+ - The drag result reports `drag_from` / `drag_to` **and** which element actually sits at each point
242
+ (`from_at_id`), plus `warning` + `start_hit_target: false` when the press landed on a *different*
243
+ element — visual overlap otherwise looks exactly like "drag does nothing".
244
+ - **`actions` is inferred from behaviour, not from `role`.** An element whose computed `cursor` is a
245
+ drag cursor, or that has `draggable="true"` / `cursor: pointer` / an `onclick`, now advertises
246
+ `drag` / `click` in the tree even when its role is `group`. Without this, a canvas full of
247
+ `role: group` nodes reported `actions: []` and the agent could not tell they were interactive at all.
248
+
249
+ ---
250
+ - `browse_network` captures requests from the moment a tab opens, **including POST request bodies** —
251
+ no subscription needed. Request headers are redacted by default
252
+ (`authorization` / `cookie` → `[REDACTED]`); pass `include_sensitive_headers: true` to see them.
253
+ - Most tools take an optional `tab` for multi-tab work; without it they target the active tab.
254
+
255
+ ---
256
+
257
+ ## Letting a human step in
258
+
259
+ ### It does not steal your focus
260
+
261
+ The agent works fine while the window is in the **background**. CDP trusted input
262
+ (`Input.dispatchKeyEvent` / `Input.dispatchMouseEvent`) does not need the window to be frontmost, and
263
+ the app already runs with `disable-backgrounding-occluded-windows`, `disable-renderer-backgrounding`
264
+ and `disable-background-timer-throttling`, so a background window is neither throttled nor losing
265
+ events.
266
+
267
+ That was already true for `act` / `get_tree` / `evaluate` / `peek` / `scroll` — but two paths used to
268
+ drag the window to the front on **every** call: `new_tab` and `set_active_tab` (they called
269
+ `webContents.focus()`, which activates the whole window). On top of that the window was shown with
270
+ `show()` at boot even when the *agent* launched it. Both are fixed:
271
+
272
+ - `new_tab` / `set_active_tab` no longer take window focus. The page still *thinks* it is focused —
273
+ `Emulation.setFocusEmulationEnabled` keeps `document.hasFocus()` and focus-dependent lazy-loading
274
+ behaving normally — while the keyboard focus stays wherever you left it.
275
+ - When the agent launches the browser (over MCP), the window appears via `showInactive()`:
276
+ **visible, but not stealing focus**. When *you* run `npm start`, it comes up in front as usual.
277
+
278
+ So the window stays visible — that is the whole point — but it is **not** dragged to the front while
279
+ the agent drives. You can keep working.
280
+
281
+ ```bash
282
+ AI_BROWSER_FOCUS=always npm start # pre-1.2 behaviour: show + moveTop + focus on every action
283
+ AI_BROWSER_FOCUS=never npm start # never change focus (the window is still shown)
284
+ ```
285
+
286
+ Default is `auto`: keep the window visible (restore it if it was minimised or hidden), but never steal
287
+ focus. Whatever the value, the window is always displayed — that is a product invariant.
288
+
289
+ `npm run focus` asserts all of this **from the outside** (macOS `lsappinfo`, no accessibility
290
+ permission needed) and includes a deliberate control group configured to steal focus — so the suite
291
+ would go red if the measurement ever stopped working, instead of passing vacuously.
292
+
293
+ It also proves the stronger claim — that the window **does not need to be in the foreground at all**.
294
+ With another app holding the foreground, it runs a representative slice of the API and checks the real
295
+ result of each one: typing lands, a click fires exactly once, `get_tree` returns a tree, canvas
296
+ `getImageData` returns real pixels, `network_list` sees the request, `navigate` completes — and after
297
+ sitting in the background for 10 seconds it still does all of the above without ever coming forward.
298
+
299
+ > Note on `document.hasFocus()`: it returns `true` even when the window is in the background, because
300
+ > `Emulation.setFocusEmulationEnabled` is deliberately on — that is what keeps focus-dependent
301
+ > lazy-loading and animations from stalling. So it is **not** a valid way to detect "am I in the
302
+ > foreground"; the test uses the external observer instead.
303
+
304
+ ---
305
+
306
+ This is the part most easily misunderstood, so plainly: **the agent drives the browser, and the human
307
+ can always take over the window.**
308
+
309
+ Because it is a real Electron window, a person can click, type, scroll, and log in exactly as they
310
+ would in Chrome — with the mouse and keyboard, at any moment, with no permission to request. That
311
+ covers the things an agent genuinely cannot do:
312
+
313
+ - **Login / SSO** and anything behind a password the agent shouldn't have
314
+ - **QR-code scans** (app-to-web authorization)
315
+ - **Slider and image CAPTCHAs**, SMS or email codes
316
+ - **Payment confirmation**, risk-control interstitials, OTP screens
317
+ - Anything where continuing would mean fabricating credentials
318
+
319
+ **How the hand-off works: there isn't one — in the browser.** Nothing coordinates it on AI Browser's
320
+ side, and nothing needs to. It is a conversation between the agent and its user:
321
+
322
+ 1. The agent hits something it cannot do.
323
+ 2. It says so in chat, plainly: *"Please scan the QR code in the browser window, then reply 'done'."*
324
+ 3. It stops and waits for the reply — no retry loops on clicks that cannot work.
325
+ 4. After "done" it re-reads with `browse_get_tree` to confirm the new state, then continues.
326
+
327
+ See [`examples/with-human.mjs`](examples/with-human.mjs) for the scripted equivalent: it prints a
328
+ prompt and waits for Enter over `readline`. The browser has no idea any of that happened.
329
+
330
+ Two practical consequences for the agent:
331
+
332
+ - **Never call `browse_quit` while a human is working in the window.** It tears down the window and
333
+ exits the process — anything half-solved is lost. Quit when the task is finished, not before.
334
+ - **Login state persists.** Tabs live in the `persist:ai-browser` session partition, so cookies and
335
+ sessions survive a restart; usually a human only has to step in once per site per machine.
336
+
337
+ ---
338
+
339
+ ## Raw WebSocket API
340
+
341
+ MCP is a thin stdio adapter. Anything MCP can do is reachable directly over JSON-RPC 2.0 at
342
+ `ws://localhost:9223` from any language. Source of truth: the method switch in
343
+ [`src/main/ws_server.js`](src/main/ws_server.js).
344
+
345
+ | Method | Purpose | Maps to |
346
+ |---|---|---|
347
+ | `ui.get_tree` | Semantic tree (+ context). Params: `focusedOnly`, `ax`, `subset`, `mode`, `tab` | `browse_get_tree` |
348
+ | `ui.act` | `params.action` + `params.target`; per-action args nested under `params.params` | `browse_act` |
349
+ | `ui.navigate` | Load a URL (`params.url`, optional `tab`). Returns `{ok, error?}` — `{ok:false}` always carries a reason. | `browse_navigate` |
350
+ | `ui.snapshot` / `ui.diff` | Capture a baseline (`params.js`, optional `label`) and diff against it (`params.snapshot`, `rearm`, `forget`) | `browse_diff` |
351
+ | `ui.capabilities` | Probe the page: tree histogram, canvas, iframes, app globals, endpoints, recommended route | `browse_capabilities` |
352
+ | `ui.repaint` | Force the compositor to redraw a tab; returns `repaint_count` so a repaint is provable | `browse_repaint` |
353
+ | `ui.evaluate` | Run JS in page context, same guard as MCP | `browse_evaluate` |
354
+ | `ui.scroll` | Scroll page or element (`direction`, `amount`, `target`). **Document scrolling only** — for canvas apps use `ui.act{action:'wheel'}`. | `browse_scroll` |
355
+ | `ui.wait` | Poll until a condition or timeout | `browse_wait` |
356
+ | `ui.new_tab` / `ui.close_tab` / `ui.list_tabs` / `ui.set_active_tab` | Tab management | `browse_*_tab` |
357
+ | `ui.subscribe` / `ui.unsubscribe` | Event subscription; `params.events`, `"*"` for all | — (WS only — see below) |
358
+ | `ui.downloads` | This tab's downloads with on-disk paths (`state`, `received_bytes`, `dir`) | `browse_downloads` |
359
+ | `ui.get_focused` | The focused element subtree | — |
360
+ | `ui.peek` | Hover a folded/hover-only affordance and return the diff, without committing | — |
361
+ | `ui.network_body` | Response body by URL substring (`url_pattern`) | — (superseded by `browse_network`) |
362
+ | `ui.quit` | Graceful shutdown | `browse_quit` |
363
+
364
+ Note two differences from the MCP spelling, both visible in the switch above: it is `focusedOnly`
365
+ (camelCase), not `focused_only`; and `ui.act` nests its per-action arguments under `params.params`.
366
+ `ui.subscribe` / `ui.unsubscribe` are also valid as notifications with no `id`.
367
+
368
+ `ui.subscribe` is deliberately **not** exposed as an MCP tool: MCP's stdio path drops
369
+ server→client notifications, so an MCP caller would subscribe and then never receive a single
370
+ event. Real-time events are a raw-WebSocket capability only. For anything pull-based, use
371
+ `browse_network` instead.
372
+
373
+ ```js
374
+ import WebSocket from 'ws';
375
+
376
+ const ws = new WebSocket('ws://localhost:9223');
377
+ let id = 0;
378
+ const call = (method, params) => new Promise((resolve, reject) => {
379
+ const myId = ++id;
380
+ const onMsg = (raw) => {
381
+ const m = JSON.parse(raw.toString());
382
+ if (m.id === myId) { ws.off('message', onMsg); m.error ? reject(new Error(m.error.message)) : resolve(m.result); }
383
+ };
384
+ ws.on('message', onMsg);
385
+ ws.send(JSON.stringify({ jsonrpc: '2.0', id: myId, method, params }));
386
+ });
387
+
388
+ ws.on('open', async () => {
389
+ await call('ui.navigate', { url: 'https://example.com' });
390
+ const { tree } = await call('ui.get_tree', { subset: 'interactive' });
391
+ console.log(tree.role, tree.children?.length);
392
+ ws.close();
393
+ });
394
+ ```
395
+
396
+ Error codes follow JSON-RPC: `-32700` parse error, `-32601` unknown method, `-32602` invalid params
397
+ (the `ui.evaluate` guard returns this), `-32603` internal error.
398
+
399
+ ---
400
+
401
+ ## How it works
402
+
403
+ ```
404
+ agent ── MCP stdio (17 browse_*) ──┐
405
+ agent ── WS JSON-RPC :9223 (ui.*) ─┤
406
+ ▼
407
+ ┌──────────────────────────────────────────────────┐
408
+ │ Electron main │
409
+ │ index.js window · tab strip · watchdog │
410
+ │ ws_server.js JSON-RPC routing, event stream │
411
+ │ page_manager.js tabs · id-matched IPC · CDP │
412
+ └───────────────┬──────────────────────────────────┘
413
+ │ IPC (request-id matched)
414
+ ┌───────────────▼──────────────────────────────────┐
415
+ │ preload (contextIsolation:true, nodeIntegration:false)
416
+ │ bridge.cjs · extractor.cjs · actions.cjs · │
417
+ │ watcher.cjs (captcha / message / state scan) │
418
+ └───────────────┬──────────────────────────────────┘
419
+ ▼
420
+ live DOM → semantic tree
421
+ ```
422
+
423
+ **Reading.** Each node in the returned tree carries a `data-ai-id` — the element's own DOM `id` when it
424
+ has one, otherwise a generated `e:tag-N`. Ids are **stable across reads**: an element that already
425
+ carries one keeps it, and generated ids come from a monotonic counter, so inserting an element above
426
+ others does not renumber them. (It used to: a stale id then silently resolved to a *different* element,
427
+ and the act reported success while hitting the wrong thing.) They reset on navigation. Alongside the id
428
+ come `role`, `label`, `value`, `states`, and `actions`, plus a `context` block — open modals, transient
429
+ messages, required-field hints, coarse stats. Two read paths exist: the default preload extractor, and
430
+ the accessibility layer behind `ax: true` (which additionally gives links their `url`).
431
+
432
+ The first read of a page annotates elements with `data-ai-id`; that is the one write a *read* performs,
433
+ and it is what makes later acts resolvable. **Repeat reads write nothing.** An attribute is only set
434
+ when its value actually changes, because real Chromium delivers a `MutationObserver` record even when
435
+ you set an attribute to the value it already holds — measured at 4001 records per read on a
436
+ 4000-element page before this guard existed. A read that spams the page's own observers is a read that
437
+ perturbs the thing it is measuring.
438
+
439
+ **Writing.** Actions are **trusted browser input**, not synthesized JS. The main process drives the
440
+ page over the Chrome DevTools Protocol — `Input.dispatchMouseEvent` for clicks, and per-character
441
+ `Input.dispatchKeyEvent` (`rawKeyDown → char → keyUp`) for typing — then validates that the click
442
+ actually landed via `elementFromPoint`. Because the events arrive as real keyboard and mouse input,
443
+ Draft.js, ProseMirror, CKEditor, Quill, and plain `<textarea>` all work through the same path without
444
+ per-framework branches.
445
+
446
+ **Where it is thin.** `extractor.cjs` is a heuristic over the DOM, not a clean-room specification: it
447
+ handles ordinary pages well and will mislabel exotic ones. The window runs with hardware acceleration
448
+ off plus three Chromium switches
449
+ (`--disable-backgrounding-occluded-windows`, `--disable-renderer-backgrounding`,
450
+ `--disable-background-timer-throttling`) so that an IDE window on top of the browser doesn't cause
451
+ injected input to be silently dropped.
452
+
453
+ ---
454
+
455
+ ## Examples
456
+
457
+ Runnable scripts live in [`examples/`](examples/) and connect straight to `ws://localhost:9223`:
458
+
459
+ | File | What it shows |
460
+ |---|---|
461
+ | [`mcp-config.json`](examples/mcp-config.json) | Minimal MCP client config; replace the placeholder path. |
462
+ | [`AGENT_INSTRUCTIONS.md`](examples/AGENT_INSTRUCTIONS.md) | Copy-paste instructions for your agent, incl. the human hand-off protocol. |
463
+ | [`raw-ws.mjs`](examples/raw-ws.mjs) | Smallest possible raw WebSocket client. |
464
+ | [`read-page.mjs`](examples/read-page.mjs) | Load one page, print its interactive elements. |
465
+ | [`with-human.mjs`](examples/with-human.mjs) | Full flow for when a human has to step in. |
466
+
467
+ ```bash
468
+ node examples/read-page.mjs
469
+ ```
470
+
471
+ ---
472
+
473
+ ## Troubleshooting
474
+
475
+ **The page updated but the window still shows the old one.** This used to require clicking tabs
476
+ back and forth, because the only thing that forced the compositor to redraw a `BrowserView` was
477
+ `setActiveTab` doing `removeBrowserView` + `addBrowserView` + `setBounds` — navigation never did it.
478
+ Now every navigation signal triggers it: `did-navigate`, `did-navigate-in-page` (SPA route changes
479
+ fire no load event at all), `did-frame-finish-load` (a canvas editor inside an iframe boots long
480
+ after the top document finished), plus window `show` / `restore` / `focus` — which matters here
481
+ because this window is designed never to steal your focus, so it sits behind your editor for
482
+ minutes at a time. If it still looks stale, `ui.repaint` forces it on demand and returns
483
+ `repaint_count` so you can confirm a repaint actually happened.
484
+
485
+ **An action reported success but nothing changed.** Two causes worth knowing:
486
+
487
+ - Over raw WS, `ui.act` nests its per-action arguments under `params.params` — `{action:"type",
488
+ target:"<id>", params:{text:"…"}}`. Flattening it to `{text:"…"}` passes an empty string, which
489
+ used to hit the idempotent short-circuit and report `success` with nothing typed. It now returns
490
+ an explicit error instead.
491
+ - Sometimes the action genuinely did nothing (an overlay ate the click, the app reverted the
492
+ change). That is what `browse_diff` is for — wrap the write in a snapshot/diff pair rather than
493
+ trusting the return value.
494
+
495
+ **An action never returns at all.** Every path that goes into the page is bounded by a deadline, so a
496
+ page whose main thread is blocked (a heavy recompute, a huge `JSON.parse`, a third-party script in a
497
+ loop) can never hang your tool call. Measured against a page that occupies its main thread for 120s:
498
+
499
+ | Call | Behaviour |
500
+ |---|---|
501
+ | `ui.get_tree` / `ui.get_focused` | errors at 5s (`getTree timeout`) |
502
+ | `ui.evaluate` | errors at 10s (CDP 5s, then the preload fallback's 5s) |
503
+ | `ui.act` | returns at 15s with `success:false, timed_out:true` |
504
+ | `ui.capabilities` | returns normally (~15s) |
505
+ | `ui.list_tabs` / `ui.network_list` | instant — they never touch the page |
506
+
507
+ `ui.act`'s budget scales with the work requested, so a legitimate long operation is not killed: `type`
508
+ /`setContent` budget `text.length × interval_ms`, `drag` budgets `duration_ms + steps`, `press`
509
+ budgets `repeat × interval_ms`, `clear` gets 20s. Override with `params.params.timeout_ms`
510
+ (1s floor, 300s cap) or globally with `AI_BROWSER_ACTION_TIMEOUT_MS`. A timeout explicitly warns that
511
+ **the action may have partially applied** — pointer already down, half the characters typed — so
512
+ re-read before retrying rather than firing the same write twice.
513
+
514
+ **The app restarts itself.** Electron's NetworkService can wedge as a whole: the port still listens,
515
+ every navigation fails with `ERR_FAILED`, and `curl` works fine. A watchdog probes from the main
516
+ process every 30s; after 5 consecutive failed sweeps it broadcasts `network_wedged` and relaunches
517
+ (the MCP client reconnects on its own). With a 5s per-request deadline that puts a genuine wedge at
518
+ roughly two minutes.
519
+
520
+ - Change what it probes: `AI_BROWSER_WATCHDOG_URLS=https://www.baidu.com/,https://example.com/`
521
+ (comma-separated). The default is `example.com` **plus** `www.baidu.com`, and that plurality is
522
+ deliberate — the network counts as alive if *any* target answers, so one flaky or geo-blocked host
523
+ can't trigger a reboot loop. Never set a single target that your network can't reach.
524
+ - Turn it off entirely: `AI_BROWSER_WATCHDOG=0` (also accepts `off` / `false` / `no`).
525
+
526
+ **Port 9223 is already busy.** Another instance — often a stale one from an earlier session — owns the
527
+ port, or something else does. Either quit that instance, or move AI Browser off it:
528
+
529
+ ```bash
530
+ AI_BROWSER_PORT=9224 npm start
531
+ ```
532
+
533
+ Keep the port consistent across every client; both the app and the MCP server read
534
+ `AI_BROWSER_PORT` from `src/shared/config.js`.
535
+
536
+ **Electron is missing or launch fails immediately.** The ~100 MB engine never landed. With the npm
537
+ package, just launch again — the launcher downloads it itself (set `ELECTRON_MIRROR` for a slow
538
+ network, see [Install](#install)). From source, check `ls node_modules/electron/dist` and re-run
539
+ `npm install` with the mirror. Setting `AI_BROWSER_PORT` will not help here — this is a missing
540
+ binary, not a port conflict.
541
+
542
+ **Input is accepted but nothing happens.** Usually the window is occluded. The three
543
+ `disable-*-backgrounding` switches above exist to prevent it, but if you still see clicks that report
544
+ success while nothing moves, bring the AI Browser window to the front and try again.
545
+
546
+ **Login state keeps getting lost.** Sessions persist under `~/.ai-browser`. If you override it, note
547
+ that separate `AI_BROWSER_USER_DATA` directories are separate session stores — "logged in" in one is
548
+ not "logged in" in another:
549
+
550
+ ```bash
551
+ AI_BROWSER_USER_DATA=~/.ai-browser-work npm start
552
+ ```
553
+
554
+ ---
555
+
556
+ ## Test & dev
557
+
558
+ ```bash
559
+ npm test # vitest — 411 tests across 27 files
560
+ npm run smoke # Electron contract layer, offline fixture — 31 checks
561
+ npm run capabilities # text fields, canvas, page source, network, DOM editing — 54 checks
562
+ npm run richtext # rich-text editors, nested menus, forms, errors, tags, upload — 117 checks
563
+ npm run network # request log, POST bodies, headers, pagination, redaction — 41 checks
564
+ npm run value # value truncation contract + read-only get_value — 15 checks
565
+ npm run canvas # canvas draw-call capture — 13 checks
566
+ npm run modeldiff # canvas app data-model diff (the "unchanged" false green) — 12 checks
567
+ npm run audit # every advertised action must exist; wait/downloads/permissions/quit — 35 checks
568
+ npm run focus # no focus stealing + full capability while backgrounded — 32 checks
569
+ npm run interact # trusted drag / key press / wheel (pointer gestures) — 31 checks
570
+ npm run view # viewport repaint + snapshot/diff verification — 28 checks
571
+ npm run realsites # navigate/read/act against a matrix of real sites
572
+ ```
573
+
574
+ All e2e suites boot a genuine Electron against offline fixtures and exit non-zero on failure.
575
+ Together they cover what jsdom structurally cannot: preload integrity, `ui.evaluate` under a strict
576
+ CSP, CDP click hit-testing, multi-tab lifecycle, event fan-out, and the `evaluate` guard rails.
577
+
578
+ They all need the WS port free — stop a running `npm start` first, or point them elsewhere:
579
+
580
+ ```bash
581
+ AI_BROWSER_PORT=9333 npm run smoke
582
+ ```
583
+
584
+ They boot their own Electron in an **isolated profile** (`/tmp/ai-browser-e2e-<port>` by default), so
585
+ they never touch your real `~/.ai-browser` profile — the one holding your logged-in sessions. They
586
+ also clean up their Electron on exit, including on Ctrl-C / timeout / crash: an orphaned process
587
+ would otherwise hold the port and the single-instance lock, and you would find `npm start` refusing
588
+ to launch with *"another AI Browser instance is already running"*.
589
+
590
+ ### What each capability suite proved
591
+
592
+ **`npm run view`** — the suite for the two things that quietly produce false confidence.
593
+
594
+ *Viewport.* A `BrowserView` can be fully loaded and still show stale pixels, and the only thing
595
+ that forced a repaint was switching tabs — which is exactly why "click the tabs a few times" used to
596
+ fix it. It now fires on every navigation signal and on window `show`/`restore`/`focus`. The suite
597
+ proves it from the **cumulative repaint counter**, not from a screenshot: a screenshot needs eyes, a
598
+ counter falsifies. It also proves the debounce doesn't self-trigger (a repaint loop would flicker
599
+ forever) and that repainting changes no page data.
600
+
601
+ *Verification.* `ui.diff` is only useful if it is right in both directions, so each assertion comes
602
+ in pairs: a real change **must** be reported *and* an unchanged page **must not** be. The negative
603
+ controls are the point — a diff that invented changes, or a snapshot that quietly became empty on
604
+ error (making every later diff report "unchanged"), would turn all verification into false green.
605
+ The suite also pins the failure mode where a mistyped parameter layer made `type` report success
606
+ without typing anything.
607
+
608
+ **`npm run richtext`** — the deepest suite. Rich-text editors and formatting, two- and three-level
609
+ nested menus, required vs optional fields (native `required`, `aria-required`, red asterisks),
610
+ error and warning surfaces (field-level, form-level, toasts, modal dialogs), radio / checkbox /
611
+ select / combobox, tag inputs and Weibo-style topic insertion, and image upload. Every
612
+ action assertion is checked against the **real DOM**, not just the semantic tree.
613
+
614
+ Two things it changed in the product:
615
+
616
+ - A click on a button that dismisses itself used to fire **twice** (the hit-test ran after the
617
+ dispatch, found the target gone, and fell back to a second click). Duplicate submissions,
618
+ duplicate mentions. Clicks now hit-test before dispatching and no longer fall back.
619
+ - Field-level errors used to be invisible — they are carried by bare `<span>`s with no ARIA role and
620
+ were pruned as layout. They now surface in `context.errors` / `context.field_errors`, scanned
621
+ across the whole document rather than only inside dialog containers.
622
+ - Three reads used to depend on a container's **class name**. Red asterisks were scanned only
623
+ inside containers whose class happened to contain `modal`/`dialog`/`popup`/…, so a plain-page
624
+ form's required fields were invisible; `modals[].fields[]` entries carried no `id`, so an error
625
+ read for one field could not be mapped back to it; and auxiliary text in bare
626
+ `<span>`/`<div>` (character counters, format hints) was pruned from the semantic tree with no
627
+ second path at all. All three now sit at the **top level** of `context`: `required_hints` is
628
+ scanned across the whole document, `fields[]` entries are shaped identically in `modals[]` and
629
+ `forms[]` (`id` included, verified against the real DOM), and form-adjacent bare-span text lands
630
+ in `hints` — the semantic tree itself keeps its pruning, so the token cost is unchanged.
631
+
632
+ **`npm run capabilities`** — typing into every kind of text field (plain inputs, `password`,
633
+ `number`, `textarea`, `contenteditable`, plus `readonly` / `disabled` / `maxlength` — including
634
+ **CJK input** and 1000-character text), reading page source, capturing response bodies, and editing
635
+ the page through `ui.evaluate`.
636
+
637
+ **`npm run network`** — that a POST can be captured with its full request body, that headers are
638
+ redacted by default, and that bodies paginate instead of being silently truncated.
639
+
640
+ **`npm run modeldiff`** — the suite for the one place `ui.diff` used to lie. A canvas-rendered app's
641
+ DOM is a still photograph: dragging a node or editing a widget repaints `<canvas>` and moves
642
+ nothing the element walk can see, so the DOM-only default snapshot answered `unchanged` to a write
643
+ that demonstrably happened — including ComfyUI's documented write path, `node.widgets[].value`,
644
+ which moves the DOM by exactly zero bytes. The suite proves the model fingerprint on a self-built
645
+ fixture (never someone's live project, where snapping and quantization make "move it back" untrue):
646
+
647
+ - adding a node → `verdict: changed`, and the diff paths are **only** `model.count` / `model.fp` —
648
+ zero `nodes.*` and zero `text*`, which is how the test proves the credit belongs to the model
649
+ rather than to the DOM happening to move;
650
+ - the ComfyUI case — changing one widget value with the node count unchanged → still `changed`,
651
+ and specifically `model.fp` without `model.count`;
652
+ - both directions: an untouched page must stay `unchanged`, and a plain page with no app object
653
+ must never acquire a `model` field (no false reds from machinery that invented a candidate);
654
+ - the model signal must not mask DOM changes — when both move, both are reported.
655
+
656
+ Two design points the suite pins, both learned the hard way:
657
+
658
+ - selection is by **what can actually be read back**, not by how promising the name looks. A library
659
+ namespace scores well on names and has no model to hand back, so returning null is what
660
+ disqualifies it;
661
+ - the *rank* comes from the strongest accessor the object **has**, while the *read* goes through
662
+ `serialize()` when one exists (it returns JSON-safe data; the live collections are usually
663
+ circular). Collapsing the two makes every candidate report `serialize`, they all tie, and a mere
664
+ serializer can outrank the real model — that bug was caught by the tie-break test, not by review.
665
+
666
+ ### Canvas: read through draw calls, not pixels
667
+
668
+ The semantic tree reports a `<canvas>` as `role: canvas` and nothing more — no text, no pixels.
669
+ `browse_canvas` fixes that **without taking a screenshot**: a recorder is injected into the
670
+ page's main world and wraps the Canvas API, capturing the draw calls themselves.
671
+
672
+ The point is that a canvas is drawn by code, so the code already knows what it drew. `fillText`
673
+ hands us the literal string — **text inside a canvas needs no OCR**:
674
+
675
+ ```jsonc
676
+ // browse_canvas(operation: "read", canvas_id: "canvas:nav-…:frame-main:1")
677
+ {
678
+ "kind": "2d", "readability": "semantic",
679
+ "texts": [
680
+ { "seq": 2, "method": "fillText", "text": "确认支付",
681
+ "bounds_canvas": { "x": 20, "y": 40, "width": 120, "height": 30 },
682
+ "style": { "font": "20px sans-serif", "fill_style": "#000000" } }
683
+ ],
684
+ "regions": [ { "seq": 3, "kind": "rect", "method": "fillRect", "bounds_canvas": {…} } ]
685
+ }
686
+ ```
687
+
688
+ The recorder is a **tee**, never a takeover: it calls the native API first
689
+ (`Reflect.apply(original, …)`) and only then records, inside its own `try/catch`. A recording
690
+ failure can never change a return value, an exception, `this`, or draw order.
691
+
692
+ Two limits you should know:
693
+
694
+ - **The hook installs after a page's first load**, so anything drawn during that initial load is
695
+ not recoverable. `browse_canvas(operation: "configure", reload: true)` reloads with the hook in
696
+ place and gives you the complete history. (Root cause: awaited CDP commands hang on a
697
+ freshly-created tab in Electron, so installation has to wait for `did-finish-load`.)
698
+ - **WebGL is opaque.** Vertices, textures and shaders are not reversible to business meaning, and
699
+ text is usually a glyph atlas. Such canvases are marked `opaque` rather than guessed at. For
700
+ that case only, `operation: "capture"` returns a PNG of the canvas region — off by default,
701
+ explicitly invoked, and it costs *your* agent's vision tokens.
702
+
703
+ Disable entirely with `AI_BROWSER_CANVAS_HOOK=0`.
704
+
705
+ ### Boundaries worth knowing before you rely on them
706
+
707
+ - **`node.value` is truncated at 200 characters.** That is *our* limit, not CDP's. When it truncates
708
+ the node carries `value_truncated: true`, `value_full_length`, and a ready-to-call `value_fetch`
709
+ hint — use `browse_act(action: "get_value")`, which is read-only and paginates.
710
+ - **Password fields expose no value at all** — only `value_sensitive: true`. The semantic tree is fed
711
+ wholesale into your agent's context, so it should not carry secrets.
712
+ - **`js_error` is broadcast for every tab** (each event carries `tabId`), so ordering and indices are
713
+ not guaranteed. Filter by `tabId`; do not rely on "the first error is the one I care about".
714
+ - **A "connection handle" is only used if a real pointer could hit it.** Many sites render handles as
715
+ `pointer-events: none` + `opacity: 0` decorations (verified on the rhtv canvas: every handle is
716
+ `pe:none / op:0`). Anchoring on such an element would press the node body instead — so the resolver
717
+ self-checks with `elementFromPoint` and **falls back to the element's border midpoint, reporting
718
+ `via: "edge"`**. If you see `via: "edge"` where you expected a handle, that is the honest answer:
719
+ drag-to-connect is not available at that spot, and the site must wire nodes some other way.
720
+ - **`ui.scroll` is document scrolling.** It does `window.scrollBy`, so it is a no-op on canvas-based
721
+ editors, maps and charts. Use `ui.act{action:'wheel'}` there.
722
+
723
+ `npm run realsites` is table-driven from `e2e/realsites/sites.cjs`, currently 17 sites across 8 buckets
724
+ (search, finance, media, dev, ecommerce, spa, editor, marketplace). The run prints **two numbers, and
725
+ deliberately never blends them into one**:
726
+
727
+ - **行为正确率 / behaviour rate** — did the browser behave as expected? Sites that require login or
728
+ answer with an anti-bot wall count as passing *when the wall is correctly detected*, because that
729
+ means the detection works.
730
+ - **内容成功率 / content rate** — did we actually read the content? This counts **only** sites where
731
+ the tree came back above the site's `minNodes`; a wall is not content.
732
+
733
+ Reporting a single "success %" would let a correctly-detected login wall inflate the content number.
734
+ The run also prints the corpus size, runs-per-site, run count and timestamp, so any percentage in a
735
+ report can be re-derived rather than taken on faith.
736
+
737
+ Layout:
738
+
739
+ ```
740
+ src/main/ Electron main — ws_server · page_manager (tabs, CDP) · mcp_server · mcp_tools · axExtractor
741
+ src/preload/ bridge · extractor (semantic tree, data-ai-id) · actions · watcher
742
+ src/renderer/ tab bar UI
743
+ src/shared/ config (AI_BROWSER_* env) · guards (evaluate safety) · protocol · watchdog
744
+ e2e/ smoke_ws.cjs · realsites/
745
+ tests/ vitest unit suites
746
+ skills/ optional higher-level recipes built on the tools
747
+ ```
748
+
749
+ `src/shared/config.js` is the single source for port, user-data dir, and UA overrides; `guards.js` is
750
+ the single source for `evaluate` limits, enforced identically on both transports.
751
+
752
+ ---
753
+
754
+ ## Compatibility
755
+
756
+ | Dimension | Support |
757
+ |---|---|
758
+ | Agents | Any MCP stdio client; alternatively raw WS from any language |
759
+ | Node | ≥ 18 |
760
+ | OS | **macOS** and **Linux** — developed and tested on these. **Windows: experimental and untested.** It is plain Electron plus CDP, so it ought to build, but no one has run it; expect to debug `npm install` and the window layer yourself. Please report back either way. |
761
+ | Page frameworks | React / Vue / Angular / vanilla DOM — element locating is DOM-based, not framework-based |
762
+ | Editors | Rich editors driven through trusted input rather than per-framework code paths |
763
+ | Page types | Static and SPA; dynamic updates observable via the tree, `dom_change` / `state_changed` events |
764
+
765
+ ---
766
+
767
+ ## Contributing
768
+
769
+ Issues and PRs are welcome, especially reproduction cases where the semantic tree gets a page wrong —
770
+ those are the most useful bug reports this project can get.
771
+
772
+ What we try to hold the line on:
773
+
774
+ - **Keep the core generic.** Site-specific knowledge belongs outside `src/main` and `src/preload`.
775
+ - **IPC stays request-id matched**, and CDP sessions stay reference-counted — tabs leak otherwise.
776
+ - **No screenshots.** If a change needs pixels to work, the design question is upstream of it.
777
+ - **`evaluate` stays guarded** (length cap, no Node identifiers), on both transports at once.
778
+ - **Agent instructions change when behaviour changes.** The MCP tool list, `ws_server.js` methods,
779
+ and `examples/AGENT_INSTRUCTIONS.md` must agree; a doc that drifts from `mcp_tools.js` is a bug.
780
+
781
+ ---
782
+
783
+ ## License
784
+
785
+ MIT © Glittering