dsh-inner-browser-use 0.11.44 → 0.18.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 +112 -75
- package/README.zh.md +90 -45
- package/client.js +473 -3276
- package/docs/ARCHITECTURE.md +196 -150
- package/docs/BENCH.md +234 -0
- package/docs/COMPATIBILITY.md +72 -102
- package/docs/ROADMAP.md +176 -0
- package/docs/VERIFY.md +131 -415
- package/index.js +230 -1117
- package/locale/en.json +11 -22
- package/locale/zh.json +11 -22
- package/package.json +12 -5
- package/read.js +718 -0
- package/scripts/doctor.mjs +89 -0
- package/sidecar.js +1364 -0
- package/tools.js +1652 -0
- package/docs/upstream/cookie-persistence.md +0 -52
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,172 +1,218 @@
|
|
|
1
1
|
# Architecture
|
|
2
2
|
|
|
3
|
-
Two halves,
|
|
3
|
+
Two halves in two processes, and a Chrome that neither of them is.
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
│
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
server side (index.js, in the DSH process)
|
|
7
|
+
sidecar_* tools ──> Sidecar ── CDP over a socket ──> headless Chrome
|
|
8
|
+
│
|
|
9
|
+
HTTP + SSE routes <────┘
|
|
10
|
+
│
|
|
11
|
+
│ same origin as the product document, no other channel
|
|
12
|
+
│
|
|
13
|
+
page side (client.js, in the product document)
|
|
14
|
+
right column: JPEG picture, black arrow, input handlers, address row
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
-
## Why
|
|
17
|
+
## Why a wire
|
|
18
18
|
|
|
19
|
-
The
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
The page half runs in the product document. It can paint a picture and read the mouse, and
|
|
20
|
+
that is the limit: a page may not read another origin's DOM, and the product's own browser
|
|
21
|
+
tab belongs to the product. The agent needs the page's text, its controls and real input
|
|
22
|
+
events, which only the browser itself can give.
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
So the server half owns a Chrome process and speaks CDP to it, and the two halves exchange
|
|
25
|
+
two things over the plugin's own routes: frames going one way, input and navigation going the
|
|
26
|
+
other. The picture is a consequence of that, not the mechanism.
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
registers that prefix on its web server.
|
|
28
|
+
## Chrome lives outside the product
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
The browser is a normal Chrome launched with `--user-data-dir` pointing at
|
|
31
|
+
`~/.dsh/inner-browser-use/chrome/<profile>`, the profile name lowercased to `default`.
|
|
32
|
+
Chrome's own storage is not used, and the product's browser storage is not used either: that
|
|
33
|
+
one is created per process, so every sign-in in it is gone at the next restart.
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
- **Desktop build:** the product document is `dsh-app://app`. Its protocol
|
|
33
|
-
handler forwards every path it does not serve itself to the Host, with the
|
|
34
|
-
Host cookie attached. A relative `fetch` therefore lands on these routes with
|
|
35
|
-
no CORS preflight, no port number, and no token for the Client to learn.
|
|
35
|
+
Other launch flags:
|
|
36
36
|
|
|
37
|
-
|
|
|
38
|
-
| --- | --- |
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
42
|
-
|
|
|
43
|
-
| `
|
|
44
|
-
|
|
45
|
-
## Surviving a reload of the other half
|
|
46
|
-
|
|
47
|
-
Each host half names itself (`epoch`) in every heartbeat reply. A page whose ask
|
|
48
|
-
stream predates that name still holds an open socket to the old half, which can
|
|
49
|
-
never open a tab. The next heartbeat (≤20 s) answers with a new name, and the page
|
|
50
|
-
reopens the stream. Installing, or reloading the host, therefore needs no page
|
|
51
|
-
reload.
|
|
52
|
-
|
|
53
|
-
## Routing a chat to its tab
|
|
54
|
-
|
|
55
|
-
The tool layer names a chat by its agent id; the tab the page opens may carry a
|
|
56
|
-
different session id. Right after an ask, the page reports which tab it opened
|
|
57
|
-
(`phase:"open"`, `opened:"current"`), and the host records that pair for the next
|
|
58
|
-
30 s. Later calls resolve chat → tab through the pair, so two open tabs stay
|
|
59
|
-
apart instead of both falling back to the single-seat rule.
|
|
60
|
-
|
|
61
|
-
## Opening the tab on demand
|
|
62
|
-
|
|
63
|
-
A tool cannot ask a user to click `+`, so the call carries the ask: when no seat
|
|
64
|
-
exists, the Host pushes `wanted` down `/watch`, the page opens this chat's tab
|
|
65
|
-
through `ctx.sidebarRight.openTabIn(sessionId, "browser")` — the product's own
|
|
66
|
-
kind, the one with the address bar — and the call waits up to `openWaitMs` (8 s)
|
|
67
|
-
for the seat to appear before it fails.
|
|
68
|
-
|
|
69
|
-
`/watch` is per page rather than per tab on purpose: a page with no tab open has
|
|
70
|
-
no `/downlink` stream, and it is exactly that page which must be able to obey.
|
|
71
|
-
|
|
72
|
-
## Seats
|
|
73
|
-
|
|
74
|
-
One open tab is one seat, keyed by the chat id. A tool resolves its seat by the
|
|
75
|
-
agent id that called it and falls back to the only seat when exactly one tab is
|
|
76
|
-
open — that fallback is what lets a chat drive a tab another chat opened, which
|
|
77
|
-
is occasionally what you want while debugging.
|
|
78
|
-
|
|
79
|
-
A seat that disappears mid-call rejects the waiter; it does not hang until the
|
|
80
|
-
timeout. A seat that goes silent does hang until the timeout, which is
|
|
81
|
-
deliberate: a page can be slow, and the model should see "did not finish" rather
|
|
82
|
-
than a misleading success.
|
|
83
|
-
|
|
84
|
-
## Frames
|
|
85
|
-
|
|
86
|
-
```jsonc
|
|
87
|
-
// Host → tab
|
|
88
|
-
{ "type": "call", "id": "<random>", "tool": "click", "args": { "ref": 4 } }
|
|
89
|
-
// tab → Host
|
|
90
|
-
{ "type": "reply", "id": "<same>", "ok": true, "value": { "text": "…" } }
|
|
91
|
-
{ "type": "reply", "id": "<same>", "ok": false, "error": "no control 4 on this page" }
|
|
92
|
-
{ "type": "wanted", "session": "<chat id>" }
|
|
93
|
-
{ "type": "client-alive", "phase": "registered", "tabType": true }
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
A navigation to the address the tab already shows is answered by `reloadIgnoringCache()`
|
|
97
|
-
rather than `loadURL()`: a browser serves that second request from its own cache, so a
|
|
98
|
-
page whose file changed on the machine would otherwise come back stale and the numbers
|
|
99
|
-
handed to the model would point at text that no longer exists.
|
|
100
|
-
|
|
101
|
-
Frames are JSON, not binary, and they carry text, not screenshots. A page
|
|
102
|
-
observation is a few kilobytes of text; a screenshot would be megabytes over the
|
|
103
|
-
same socket, and it is not what the model needs to decide where to click.
|
|
37
|
+
| Flag | Why |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `--headless=new` | No window on the desktop, still a full rendering engine. |
|
|
40
|
+
| `--remote-debugging-port=0` | Chrome picks its own debugging address. | <!-- ui-copy-ignore -->
|
|
41
|
+
| `--use-mock-keychain`, `--password-store=basic` | No keychain prompt on macOS. |
|
|
42
|
+
| `--remote-allow-origins=*` | The CDP client connects over a plain WebSocket. |
|
|
43
|
+
| `--disable-blink-features=AutomationControlled` | Sites that sniff for automation get the ordinary path. |
|
|
44
|
+
| `--window-size=1440,900`, `--lang=zh-CN` | The viewport the pane mirrors, and a Chinese page locale. |
|
|
104
45
|
|
|
105
|
-
|
|
46
|
+
The binary comes from a fixed list: Google Chrome, Chromium or Canary under `/Applications`,
|
|
47
|
+
then `/usr/bin/google-chrome` and `/usr/bin/chromium`. Nothing else is probed and there is no
|
|
48
|
+
setting to add a path.
|
|
106
49
|
|
|
107
|
-
|
|
50
|
+
## Attach and lifetime
|
|
108
51
|
|
|
109
|
-
|
|
110
|
-
el.executeJavaScript(`(async function(){ ${RUNTIME}; return await run(${op}, ${args}) })()`)
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
Rewriting it on every call costs a few milliseconds and buys immunity from the
|
|
114
|
-
one failure that would otherwise break everything: the page navigating and
|
|
115
|
-
taking an earlier injection with it. Nothing persists in the guest except what
|
|
116
|
-
the call itself creates.
|
|
52
|
+
The browser outlives the plugin:
|
|
117
53
|
|
|
118
|
-
`
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
54
|
+
1. `ensure()` reads `DevToolsActivePort` from the profile directory.
|
|
55
|
+
2. It probes `GET http://127.0.0.1:<address>/json/version`. A stale file is deleted and Chrome
|
|
56
|
+
is launched again.
|
|
57
|
+
3. Otherwise the running Chrome is adopted: the page target is read from `/json/list` (with
|
|
58
|
+
`/json/new?about:blank` as a fallback) and a client connects to it.
|
|
123
59
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
sequence) so frameworks that listen on any of them react.
|
|
60
|
+
One page per browser. `apply()` only constructs the `Sidecar`; no process exists until the
|
|
61
|
+
first tool call, and `sidecar_state` reports a stopped browser without starting one.
|
|
127
62
|
|
|
128
|
-
|
|
63
|
+
Shutdown asks Chrome to close (`Browser.close`), waits briefly, then sends `SIGTERM`, which is
|
|
64
|
+
the point where Chrome flushes cookies. An idle timer closes the browser after 15 minutes; a
|
|
65
|
+
tool call resets the clock, and a watching column defers the close when the timer fires, so a
|
|
66
|
+
page someone is reading outlives the idle window.
|
|
129
67
|
|
|
130
|
-
|
|
131
|
-
page drives the `<webview>` the product's browser tab already holds — it finds
|
|
132
|
-
that node (right of the chat, `[data-sidebar-browser-frame="webview"]`, or a
|
|
133
|
-
`webview` no other half parked) and never creates, moves, or disposes one.
|
|
68
|
+
## Routes
|
|
134
69
|
|
|
135
|
-
|
|
136
|
-
main process:
|
|
70
|
+
Everything hangs under `/dsh-inner-browser-use`, all same-origin, bodies capped at 1 MB.
|
|
137
71
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
72
|
+
| Route | Kind | Purpose |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `GET /sidecar/frames?session=` | SSE | The mirror: `hello`, then frame messages, plus pointer messages. |
|
|
75
|
+
| `GET /sidecar/state` | JSON | What the browser is doing. |
|
|
76
|
+
| `POST /sidecar/open` | JSON | Start or reuse the browser, optionally navigating. |
|
|
77
|
+
| `POST /sidecar/input` | JSON | `hover`, `move`, `click`, `wheel`, `text`, `key`; answers with the page's cursor. |
|
|
78
|
+
| `POST /sidecar/nav` | JSON | `back`, `forward`, `reload`, or a `goto` with an address. |
|
|
79
|
+
| `POST /sidecar/stop` | JSON | Close the browser now. |
|
|
80
|
+
| `GET /downlink?session=` | SSE | Per-session notices; the only message today is `sidecar-wanted`. |
|
|
81
|
+
|
|
82
|
+
The page half never learns the debugging address. It knows the route prefix and nothing else;
|
|
83
|
+
the CDP socket stays in the server half.
|
|
144
84
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
`
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
-
|
|
172
|
-
|
|
85
|
+
## Mirror
|
|
86
|
+
|
|
87
|
+
The server half starts `Page.startScreencast` with JPEG quality 72, `maxWidth` and `maxHeight`
|
|
88
|
+
at 1440×900, `optimizeForSpeed`, `everyNthFrame: 1`, and acknowledges every frame it receives
|
|
89
|
+
so Chrome keeps sending. Frames are never queued: with two frames in flight, further frames
|
|
90
|
+
are dropped and the next one that fits takes their place. A slow reader loses frames, never
|
|
91
|
+
freshness.
|
|
92
|
+
|
|
93
|
+
The viewport is pinned with `Emulation.setDeviceMetricsOverride` to the same 1440×900, so a
|
|
94
|
+
frame is the whole page as laid out, not a crop.
|
|
95
|
+
|
|
96
|
+
If `Browser.getVersion` reports `HeadlessChrome`, the user agent is overridden with an ordinary
|
|
97
|
+
Chrome string, `acceptLanguage: 'zh-CN,zh;q=0.9,en;q=0.8'` and `platform: 'MacIntel'`, so a site
|
|
98
|
+
that gates on headless gets the normal rendering.
|
|
99
|
+
|
|
100
|
+
The stream is SSE with a comment beat every 15 seconds. A response that falls more than 3 MB
|
|
101
|
+
behind is given up rather than buffered, Nagle is off on the socket, and a session that opens a
|
|
102
|
+
second stream replaces its first seat: one column per session, no fan-out of stale frames.
|
|
103
|
+
|
|
104
|
+
## Pointer, input, cursor
|
|
105
|
+
|
|
106
|
+
Two pointers, two sources:
|
|
107
|
+
|
|
108
|
+
- The agent's arrow comes from the tool calls. `movePointer()` sends `{type: 'pointer', w, h,
|
|
109
|
+
pointer}` on the same stream and the pane draws it at once, so the arrow does not wait for
|
|
110
|
+
the next frame. Hovering with your own mouse sends `mouseMoved` and never touches that state.
|
|
111
|
+
- Your own cursor stays the system cursor. The pane asks the page which CSS cursor sits under
|
|
112
|
+
the hand (`cursorAt()`), the reply to `/sidecar/input` carries it, and the pane applies it
|
|
113
|
+
only when it is in the pane's allow-list.
|
|
114
|
+
|
|
115
|
+
Clicks are `Input.dispatchMouseEvent` down and up at one point. Typing is `Input.insertText`:
|
|
116
|
+
a composed Chinese word or a pasted string lands as one insertion rather than key by key.
|
|
117
|
+
Named keys go through `Input.dispatchKeyEvent` with a table mapping Enter, Tab, Escape,
|
|
118
|
+
Backspace, Delete, the arrows, Home, End, PageUp and PageDown to their key and code, which is
|
|
119
|
+
what makes Backspace delete a character on the page instead of nothing.
|
|
120
|
+
|
|
121
|
+
## Session scoping
|
|
122
|
+
|
|
123
|
+
The right column is shared by every plugin and belongs to whichever session is on screen. A
|
|
124
|
+
tool call pushes `sidecar-wanted` down that session's own `/downlink`, and the page half opens
|
|
125
|
+
the mirror tab only when `sidebarRight.mounted` equals the session that asked. An unknown or
|
|
126
|
+
empty `mounted` is a refusal, not permission.
|
|
127
|
+
|
|
128
|
+
The frame stream is opened by the pane with its own `session` in the query. A chat whose column
|
|
129
|
+
sits on another session gets no frames, and a tool call in the background drives the same
|
|
130
|
+
browser without pulling the column away from the person reading it.
|
|
131
|
+
|
|
132
|
+
## Tool surface
|
|
133
|
+
|
|
134
|
+
`sidecarTools()` in `tools.js` builds the 28 definitions and `index.js` registers them on the
|
|
135
|
+
plugin's tool scope. All of them return one text block. A page read numbers the controls and
|
|
136
|
+
keeps those numbers across later reads, so `sidecar_click` by ref means the element the model
|
|
137
|
+
last saw; navigation drops them and the model gets `REF_GONE` rather than a wrong click.
|
|
138
|
+
|
|
139
|
+
Before a ref click or a ref focus, one page-side probe asks what is on top of that control.
|
|
140
|
+
When something is, and that something carries its own safe way out (a banner with Accept /
|
|
141
|
+
Close, a modal with 同意 / 关闭), the probe's answer names that button's point, the driver takes
|
|
142
|
+
it, checks the control is clear, and then performs the action the model asked for. The result
|
|
143
|
+
says both things. An overlay whose wording involves deleting, paying or signing out is only
|
|
144
|
+
reported, never accepted on the person's behalf. The whole exchange is one `Runtime.evaluate`
|
|
145
|
+
and a possible `mousePressed`/`mouseReleased` pair on an already-open CDP connection, so an
|
|
146
|
+
uncovered click pays nothing.
|
|
147
|
+
|
|
148
|
+
`sidecar_do` runs the same probe before a step that names a control — by `ref`, by
|
|
149
|
+
text, or by field label — so a banner the first step clears is gone for the rest of
|
|
150
|
+
the batch, and the step's own line says it in one clause (`after Accept on a banner,`).
|
|
151
|
+
A batch therefore pays for one overlay, not one per step.
|
|
152
|
+
|
|
153
|
+
An action answers with one line — address, title, focused field — and reads the page only when
|
|
154
|
+
`read: true` is passed. That is what keeps a long batch cheap (see `BENCH.md`).
|
|
155
|
+
|
|
156
|
+
Calls that touch the browser are marked not concurrency-safe; reading state, console and failed
|
|
157
|
+
requests is. `sidecar_open`, `sidecar_view`, `sidecar_wait` and `sidecar_shot` carry longer
|
|
158
|
+
timeouts than the rest.
|
|
159
|
+
|
|
160
|
+
Sign-in state lives in the profile, and session cookies — the kind a sign-in usually sets,
|
|
161
|
+
which Chrome would otherwise drop on exit — are saved next to it and restored on the next
|
|
162
|
+
launch, so a restart of the app does not sign the person out.
|
|
163
|
+
|
|
164
|
+
A system-prompt section (`browser-in-this-chat`) tells the model to reach for these tools
|
|
165
|
+
without asking first when someone wants something done on a site.
|
|
166
|
+
|
|
167
|
+
## Network
|
|
168
|
+
|
|
169
|
+
`Network.enable` is already on for the mirror. A request is kept when it is
|
|
170
|
+
sent and settled when its response arrives, so the list holds method, status,
|
|
171
|
+
type and address of what the page actually got — 120 rows, oldest dropped.
|
|
172
|
+
`sidecar_network` reads that list without touching the page, which is why it
|
|
173
|
+
can answer while a dialog is open.
|
|
174
|
+
|
|
175
|
+
## Downloads
|
|
176
|
+
|
|
177
|
+
Chrome is pointed at `<profile>/downloads` when the page starts, so a click
|
|
178
|
+
that saves a file writes it there instead of nowhere. The folder belongs to
|
|
179
|
+
the profile: it is the same one on the next launch, and nothing outside the
|
|
180
|
+
profile is touched. `sidecar_downloads` lists what is on disk, newest first;
|
|
181
|
+
a `.crdownload` still in flight is not listed.
|
|
182
|
+
|
|
183
|
+
## Frames and popups
|
|
184
|
+
|
|
185
|
+
A page is not one document. Chrome keeps a table of frames and gives each one the
|
|
186
|
+
JavaScript context its scripts run in; the plugin listens for those contexts as they
|
|
187
|
+
are created and destroyed, so a read can be sent to the context that owns the
|
|
188
|
+
controls it wants.
|
|
189
|
+
|
|
190
|
+
- A read walks the frame tree main frame first and sends one snapshot per frame, so a
|
|
191
|
+
form inside an iframe is numbered with the page's own controls.
|
|
192
|
+
- Refs from a frame are offset: `900000 + slot * 100000 + local`. The slot is the
|
|
193
|
+
frame's position in the tree, so the number says which context to resolve in, and a
|
|
194
|
+
frame's refs never collide with the page's.
|
|
195
|
+
- A click on a framed control resolves in that frame, then adds the frame's rectangle
|
|
196
|
+
to the point it found, because input is always dispatched in viewport coordinates.
|
|
197
|
+
- A frame Chrome runs in another process is a target of its own: the plugin keeps the
|
|
198
|
+
session Chrome hands it, so a read can be sent there and its controls still get a slot.
|
|
199
|
+
Its corner is asked from the page, through the frame's owner element, because the
|
|
200
|
+
frame's own process cannot see where it sits.
|
|
201
|
+
|
|
202
|
+
A click can open a tab. A page target gets no handle on its own popups, so after an
|
|
203
|
+
action the plugin asks the DevTools address list for page targets other than the one it
|
|
204
|
+
drives. If one appears, the mirror navigates to its address and the popup is closed with
|
|
205
|
+
the target HTTP endpoints. A person watching the column sees where the click went,
|
|
206
|
+
and there is still one page to drive.
|
|
207
|
+
|
|
208
|
+
## Files
|
|
209
|
+
|
|
210
|
+
| File | Half |
|
|
211
|
+
| --- | --- |
|
|
212
|
+
| `index.js` | Server: routes, tools, CDP page reads, system prompt. |
|
|
213
|
+
| `sidecar.js` | Server: Chrome launch, attach, screencast, input, shutdown. |
|
|
214
|
+
| `client.js` | Page: the column, its stream, the arrow, the address row. |
|
|
215
|
+
| `locale/*.json` | Page: pane labels. |
|
|
216
|
+
|
|
217
|
+
Data on disk is confined to `~/.dsh/inner-browser-use/`: `chrome/<profile>` for the browser,
|
|
218
|
+
`screenshots/` for `sidecar_shot`.
|
package/docs/BENCH.md
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
<!-- ui-copy-ignore -->
|
|
2
|
+
# Measured, not claimed
|
|
3
|
+
|
|
4
|
+
`node scripts/bench.mjs` on a local page, headless, profile of its own. One JSON
|
|
5
|
+
line per measurement; a phase is only done when its row improves.
|
|
6
|
+
|
|
7
|
+
Method: a dense page (20 inputs, 120 links, two buttons), a text page (~60
|
|
8
|
+
paragraphs), a light page. The dense page carries a viewport-fixed counter,
|
|
9
|
+
because Chrome sends screencast frames only for visible change — measuring an
|
|
10
|
+
idle page measures nothing.
|
|
11
|
+
|
|
12
|
+
## Baseline — 0.12.1, before phase 1
|
|
13
|
+
|
|
14
|
+
| What | Number |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| launch → first frame | 506 ms (cold profile) |
|
|
17
|
+
| steady mirror | 60 fps, 72 KB/frame |
|
|
18
|
+
| click → next frame | 13 ms |
|
|
19
|
+
| click round trip | 1 ms |
|
|
20
|
+
| key round trip | 13 ms |
|
|
21
|
+
| screenshot | 82 ms, 133 KB PNG |
|
|
22
|
+
| read: dense page | 4.7k chars ≈ **1383 tokens** |
|
|
23
|
+
| read: text page | 8.0k chars ≈ **2363 tokens** |
|
|
24
|
+
| read: light page | 69 chars ≈ 20 tokens |
|
|
25
|
+
|
|
26
|
+
Targets: dense read ≤ 1200 tokens, text read ≤ 1500 tokens with the useful part
|
|
27
|
+
kept, click → painted ≤ 120 ms (already 13 ms; the number to protect), and a
|
|
28
|
+
batch of five steps in one tool call.
|
|
29
|
+
|
|
30
|
+
## After phase 1 + 2 — read model v2 and batched steps
|
|
31
|
+
|
|
32
|
+
Same bench, same machine. Read numbers are what a model actually receives with
|
|
33
|
+
the default budget (4600 chars of page text).
|
|
34
|
+
|
|
35
|
+
| What | Baseline | Now |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| read: dense page | 1383 tokens | **436** tokens (90 controls, 1.5k chars) — `links` 514, `forms` 127, `full` 624 |
|
|
38
|
+
| read: text page | 2363 tokens | **1130** tokens (screenful only, budget 4600) |
|
|
39
|
+
| read: light page | 20 tokens | 17 tokens |
|
|
40
|
+
| read round trip | ~30 ms | **1–4 ms** |
|
|
41
|
+
| refs across reads | new numbers every read | **stable** (same refs, ticker-only text changes) |
|
|
42
|
+
| click round trip | 1 ms | 1 ms |
|
|
43
|
+
| key round trip | 13 ms | 0–2 ms |
|
|
44
|
+
| launch → first frame | 506 ms | 129–539 ms (cold/warm profile) |
|
|
45
|
+
| steady mirror | 60 fps, 72 KB/frame | 60 fps, 76 KB/frame |
|
|
46
|
+
| screenshot | 82 ms / 133 KB | 79–86 ms / 115 KB |
|
|
47
|
+
|
|
48
|
+
The win is structural: the old read took `document.body.innerText` (8k chars on
|
|
49
|
+
the text page); the new one walks the viewport, dedupes parent text, numbers
|
|
50
|
+
controls with refs that survive reads, and caps the text at the budget. Reading
|
|
51
|
+
again returns the same numbers, so a batch of steps costs one read instead of
|
|
52
|
+
one per step.
|
|
53
|
+
|
|
54
|
+
## What one step costs the model
|
|
55
|
+
|
|
56
|
+
Measured on the live tool surface (`/tmp/probe6.mjs`, 2026-10-27). Before this
|
|
57
|
+
pass every action answered with a full page read; now it answers with one line
|
|
58
|
+
and reads only when `read: true` is passed.
|
|
59
|
+
|
|
60
|
+
| step | reply | ≈ tokens |
|
|
61
|
+
| --- | --- | --- |
|
|
62
|
+
| click | `ok click 1 · https://… · Title` (60 chars) | 18 |
|
|
63
|
+
| type | `ok type · https://… · Title · focus email = a@b.c` (77) | 23 |
|
|
64
|
+
| key | `ok Enter · https://… · Title` (65) | 20 |
|
|
65
|
+
| check | `I agree is now on` (17) | 6 |
|
|
66
|
+
| select | `chose Beta · https://… · Title · focus …` (65) | 20 |
|
|
67
|
+
| wait | `saw hello` (9) | 3 |
|
|
68
|
+
| console / failed | `error: message` or `no request failed` (17–47) | 6–14 |
|
|
69
|
+
| dialog | `alert: sure?` (12) | 4 |
|
|
70
|
+
| cookie | one line per cookie, up to 40 | — |
|
|
71
|
+
| full read (dense page) | 1484 chars | 436 |
|
|
72
|
+
|
|
73
|
+
A ten-step batch therefore spends ~150 tokens on confirmations instead of
|
|
74
|
+
~4 400, and the page itself is read once when it changes.
|
|
75
|
+
|
|
76
|
+
## Notes
|
|
77
|
+
|
|
78
|
+
- Screencast frames are change-driven. A page with nothing moving sends nothing;
|
|
79
|
+
that is Chrome behaving, not the mirror failing. After an agent action the host
|
|
80
|
+
should force one frame so the person sees the result of a step that changed
|
|
81
|
+
nothing visible.
|
|
82
|
+
- A tall page whose only animation is below the fold produces no frames: the
|
|
83
|
+
viewport is what is captured.
|
|
84
|
+
|
|
85
|
+
## Phase 3 — frames and popups, 0.13.0
|
|
86
|
+
|
|
87
|
+
Same bench, same page. Two changes landed: controls are capped at the read's own
|
|
88
|
+
`max` after viewport-first sorting (the cap was applied to candidates, not to the
|
|
89
|
+
answer), and a read now also asks for the frame tree.
|
|
90
|
+
|
|
91
|
+
| What | Before | After |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| read: dense, `full` | 3118 chars, 142 controls ≈ 917 tokens | 2128 chars, 90 controls ≈ **626 tokens** |
|
|
94
|
+
| read: dense, `text` | 1510 chars, 142 controls ≈ 444 tokens | 1510 chars, 90 controls ≈ 444 tokens |
|
|
95
|
+
| read: text page | 13 ms | 13 ms (one frame-tree call, ~1 ms) |
|
|
96
|
+
| launch → first frame | 506 ms | 479 ms |
|
|
97
|
+
| click round trip | 1 ms | 2 ms |
|
|
98
|
+
| key round trip | 13 ms | 2 ms |
|
|
99
|
+
| screenshot | 82 ms, 133 KB | 77 ms, 113 KB |
|
|
100
|
+
|
|
101
|
+
`sidecar_extract` on a 30-row list: 1106 chars for all 30 rows (≈330 tokens),
|
|
102
|
+
against 1291 chars for the same page read as text — and at a 500-char read
|
|
103
|
+
budget the text read stops around row 12 while the list still returns 30.
|
|
104
|
+
|
|
105
|
+
Live run (`npm run live`), 8 checks, all true on the open internet: open
|
|
106
|
+
example.com in 566 ms, read it for 785 chars, click the language link on
|
|
107
|
+
wikipedia.org and land on en.wikipedia.org in 2.3 s, go back to
|
|
108
|
+
www.wikipedia.org, extract ten list rows from the Web scraping article in 3 ms
|
|
109
|
+
(1813 chars), visit a host that cannot resolve (1.3 s to the browser's own
|
|
110
|
+
error page, where a read, a click and a batch all refuse instead of quoting it),
|
|
111
|
+
recover from there to a real page, and type into a search box and reach a
|
|
112
|
+
results URL in 1.3 s. These are wall-clock end to end including the site's own
|
|
113
|
+
load; the local numbers below are where the driver's own cost lives. Two hosts
|
|
114
|
+
were unusable from this machine and are not plugin problems: Hacker News (12 s
|
|
115
|
+
connect timeout, confirmed with curl) and Wikipedia's search redirect, which
|
|
116
|
+
answers on the home page and times out on the query — the search check runs on
|
|
117
|
+
Bing now, driven by the box's words rather than a control number.
|
|
118
|
+
|
|
119
|
+
Breadth run (`npm run breadth`), 33 checks, all true: open shadow DOM read and
|
|
120
|
+
click, form typing, select, checkbox, submit, `confirm()` seen and accepted,
|
|
121
|
+
iframe read, iframe typing, iframe click, cross-site frame read and click,
|
|
122
|
+
popup followed, download saved, network responses listed, a file uploaded
|
|
123
|
+
into a file input, the page printed to a PDF, a changed-only read, names from `for=` and `aria-labelledby`, a click found by its words, a login filled by field names in one call,
|
|
124
|
+
stale ref refused, a crash picked back up on the same page, a list read as 30 rows, a click under a full-screen cookie banner clears the banner and lands on the
|
|
125
|
+
control, typing into a field under that banner works the same way, and an
|
|
126
|
+
overlay that offers to delete the account is reported but never accepted.
|
|
127
|
+
|
|
128
|
+
## Phase 4 — what a change costs
|
|
129
|
+
|
|
130
|
+
| What | Number |
|
|
131
|
+
| --- | --- |
|
|
132
|
+
| change in the page → frame ready for the column | 7 ms median, 7 ms max (12 samples) |
|
|
133
|
+
| `changed_only` after a field changes, dense page | 58 chars ≈ **17 tokens** (whole page: 444) |
|
|
134
|
+
| read: text page, `changed_only` | 42 chars ≈ 12 tokens (whole page: 1517) |
|
|
135
|
+
| renderer crashed → readable page again | **1.39 s** (detect, fresh tab, same address) |
|
|
136
|
+
|
|
137
|
+
The mirror's delay is measured from the moment the page is told to change to
|
|
138
|
+
the frame object the column would draw — capture, JPEG, and the CDP hop. It is
|
|
139
|
+
the part of the latency that belongs to this side; what a person sees adds the
|
|
140
|
+
browser's compositing and the SSE hop, both smaller.
|
|
141
|
+
|
|
142
|
+
A cross-site frame (Chrome runs it in its own process) costs one more round
|
|
143
|
+
trip than a same-process one: its controls arrive in the same read, and the
|
|
144
|
+
frame's corner is one `DOM.getFrameOwner` plus one call on the owner element.
|
|
145
|
+
|
|
146
|
+
## Phase 5 — what an overlay costs
|
|
147
|
+
|
|
148
|
+
| What | Number |
|
|
149
|
+
| --- | --- |
|
|
150
|
+
| the "what is on top of this control" probe | **1 ms median, 2 ms max** (5 samples) — the same `Runtime.evaluate` hop, no round trip added |
|
|
151
|
+
| `sidecar_click` on an uncovered control | 460 ms median (3 runs, page-load settle included) |
|
|
152
|
+
| `sidecar_click` on the same control under a cookie banner | 630 ms median (3 runs) — the banner's own Accept click, a 150 ms beat, a re-probe, then the intended click |
|
|
153
|
+
| the same under a modal that offers to delete the account | 456 ms — reported, nothing pressed |
|
|
154
|
+
| one `sidecar_do` step on an uncovered control | 458 ms median (3 runs) |
|
|
155
|
+
| the same step under the cookie banner | 633 ms median (3 runs) — cleared, then the step ran |
|
|
156
|
+
|
|
157
|
+
A batch pays it once per step that has something over it, not once per call: a
|
|
158
|
+
banner cleared by step 1 is gone for steps 2..n, so a five-step flow through a
|
|
159
|
+
cookie wall costs one extra step's worth of time, not five.
|
|
160
|
+
|
|
161
|
+
The probe costs nothing when nothing is in the way, which is the common case;
|
|
162
|
+
the covered case pays about 170 ms to clear the banner and land the click in
|
|
163
|
+
the same call, instead of making the model spend a whole extra turn (a read, a
|
|
164
|
+
click, another read) telling it to close the banner first.
|
|
165
|
+
|
|
166
|
+
## Phase 6 — what a stale number costs, 0.16.0
|
|
167
|
+
|
|
168
|
+
A ref is a number for an element. A page that redraws a list, a table, a card
|
|
169
|
+
grid — React, Vue, Svelte, or plain `innerHTML` — throws every element away and
|
|
170
|
+
makes new ones, so the numbers from the last read stop pointing anywhere. The
|
|
171
|
+
control is still on screen, saying the same words, usually in the same place.
|
|
172
|
+
|
|
173
|
+
Before this phase that cost two round trips: the call came back `That control is
|
|
174
|
+
gone`, the model read the page again to get a new number, then used it. The work
|
|
175
|
+
is now done inside the call, by looking the control up by what it is.
|
|
176
|
+
|
|
177
|
+
| What | Number |
|
|
178
|
+
| --- | --- |
|
|
179
|
+
| `sidecar_click` on a control the page just redrew | **462 ms median, 483 ms max** (8 samples) — one extra read of the page's visible text, 600 chars, to find the control again |
|
|
180
|
+
| `sidecar_click` on a control that was not redrawn | 445 ms median, 446 ms max (8 runs) — the probe costs nothing when the number is still good |
|
|
181
|
+
| stale refs recovered this way | **8/8** |
|
|
182
|
+
| a control that really left the page | still refused, `Call sidecar_view and use the new number` — 2/2 in the breadth suite |
|
|
183
|
+
|
|
184
|
+
So the recovery costs about **17 ms** when it is needed and nothing when it is
|
|
185
|
+
not, against a model round trip of two calls plus the tokens of a fresh read.
|
|
186
|
+
|
|
187
|
+
What counts as the same control: the same words and the same kind. A button does
|
|
188
|
+
not become a field; a field never passes for the button beside it; a link keeps
|
|
189
|
+
its destination, because the same words pointing somewhere else is a different
|
|
190
|
+
thing to click. The words have to appear **exactly once** on the page — two
|
|
191
|
+
"Delete" buttons in two rows are a choice only the person can make, so that case
|
|
192
|
+
still asks for a read.
|
|
193
|
+
|
|
194
|
+
The call says what it did: `(the page redrew, so "Learn more" has a new number — used where it is now)`, so the model knows its old number is not the one to keep
|
|
195
|
+
using. On the live network the same thing happened on example.com: a link
|
|
196
|
+
replaced by an identical clone, one click, 4.4 s including the iana.org page
|
|
197
|
+
load, straight to `https://www.iana.org/help/example-domains`.
|
|
198
|
+
|
|
199
|
+
## Phase 7 — what changing the shape of the browser costs, 0.17.0
|
|
200
|
+
|
|
201
|
+
A site that refuses a windowless browser is the one case where the shape has to
|
|
202
|
+
change mid-session. Changing it restarts the browser, so it costs a cold start:
|
|
203
|
+
|
|
204
|
+
| what | median | notes |
|
|
205
|
+
| --- | --- | --- |
|
|
206
|
+
| windowless → windowed, same profile and page | 21.6 s | one process, cold start |
|
|
207
|
+
| windowed → windowless, same profile and page | 22.1 s | one process, cold start |
|
|
208
|
+
| asking for the shape it already has | 0 ms | nothing is restarted |
|
|
209
|
+
|
|
210
|
+
Measured through the tool layer (`sidecar_open` → `sidecar_state` → `ps` spawn
|
|
211
|
+
args), profile `probe-tool`, 1 run each; the same-shape row is the third call in
|
|
212
|
+
the same run. A browser start on this machine is 20–22 s either way, so the
|
|
213
|
+
switch is not what costs — the process is.
|
|
214
|
+
|
|
215
|
+
Reading a check page costs nothing: the sentence is built from the title already
|
|
216
|
+
in the read, and a normal page gets `''` for one comparison.
|
|
217
|
+
|
|
218
|
+
## Phase 8 — saying it when nobody is drawing, 0.18.0
|
|
219
|
+
|
|
220
|
+
The mirror is drawn by the tab in the chatting column, and that tab attaches
|
|
221
|
+
while the page is loading. Waiting for it costs nothing when it arrives, and a
|
|
222
|
+
bounded wait is the difference between a silent `viewers 0` and a sentence:
|
|
223
|
+
|
|
224
|
+
| what | cost |
|
|
225
|
+
| --- | --- |
|
|
226
|
+
| `sidecar_open` with a tab watching | 0 ms |
|
|
227
|
+
| `sidecar_open` with nothing watching | ≤ 1.2 s, and the line says so |
|
|
228
|
+
| `sidecar_open` when the chat has no column to draw into | 0 ms (no wait) |
|
|
229
|
+
| building the sentence on a normal state read | 0 ms, one comparison |
|
|
230
|
+
|
|
231
|
+
Measured with `/tmp/probe-unseen.mjs` (tool layer, example.com, profile
|
|
232
|
+
`probe-tool`): 1344 ms with nothing watching and no column asked for, 3236 ms in
|
|
233
|
+
the first version of the same wait that did not skip the case where no column was
|
|
234
|
+
ever requested — the whole difference was the wasted 1.2 s.
|