dsh-inner-browser-use 0.11.44 → 0.13.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 +108 -75
- package/README.zh.md +88 -45
- package/client.js +473 -3276
- package/docs/ARCHITECTURE.md +183 -151
- package/docs/BENCH.md +124 -0
- package/docs/COMPATIBILITY.md +72 -102
- package/docs/ROADMAP.md +108 -0
- package/docs/VERIFY.md +80 -417
- package/index.js +227 -1117
- package/locale/en.json +11 -22
- package/locale/zh.json +11 -22
- package/package.json +9 -5
- package/scripts/doctor.mjs +89 -0
- package/sidecar.js +1323 -0
- package/docs/upstream/cookie-persistence.md +0 -52
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,172 +1,204 @@
|
|
|
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.
|
|
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. |
|
|
100
45
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
104
49
|
|
|
105
|
-
##
|
|
50
|
+
## Attach and lifetime
|
|
106
51
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
```js
|
|
110
|
-
el.executeJavaScript(`(async function(){ ${RUNTIME}; return await run(${op}, ${args}) })()`)
|
|
111
|
-
```
|
|
52
|
+
The browser outlives the plugin:
|
|
112
53
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
the
|
|
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.
|
|
117
59
|
|
|
118
|
-
`
|
|
119
|
-
|
|
120
|
-
invisible and zero-sized nodes, and returns the first 2400 characters of visible
|
|
121
|
-
text. The numbers are only valid for the page the model last read, which is why
|
|
122
|
-
every mutating tool returns a fresh `view` afterwards.
|
|
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.
|
|
123
62
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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.
|
|
127
67
|
|
|
128
|
-
##
|
|
68
|
+
## Routes
|
|
129
69
|
|
|
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.
|
|
70
|
+
Everything hangs under `/dsh-inner-browser-use`, all same-origin, bodies capped at 1 MB.
|
|
134
71
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
the
|
|
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
|
-
|
|
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.
|
|
84
|
+
|
|
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 25 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
|
+
An action answers with one line — address, title, focused field — and reads the page only when
|
|
140
|
+
`read: true` is passed. That is what keeps a long batch cheap (see `BENCH.md`).
|
|
141
|
+
|
|
142
|
+
Calls that touch the browser are marked not concurrency-safe; reading state, console and failed
|
|
143
|
+
requests is. `sidecar_open`, `sidecar_view`, `sidecar_wait` and `sidecar_shot` carry longer
|
|
144
|
+
timeouts than the rest.
|
|
145
|
+
|
|
146
|
+
Sign-in state lives in the profile, and session cookies — the kind a sign-in usually sets,
|
|
147
|
+
which Chrome would otherwise drop on exit — are saved next to it and restored on the next
|
|
148
|
+
launch, so a restart of the app does not sign the person out.
|
|
149
|
+
|
|
150
|
+
A system-prompt section (`browser-in-this-chat`) tells the model to reach for these tools
|
|
151
|
+
without asking first when someone wants something done on a site.
|
|
152
|
+
|
|
153
|
+
## Network
|
|
154
|
+
|
|
155
|
+
`Network.enable` is already on for the mirror. A request is kept when it is
|
|
156
|
+
sent and settled when its response arrives, so the list holds method, status,
|
|
157
|
+
type and address of what the page actually got — 120 rows, oldest dropped.
|
|
158
|
+
`sidecar_network` reads that list without touching the page, which is why it
|
|
159
|
+
can answer while a dialog is open.
|
|
160
|
+
|
|
161
|
+
## Downloads
|
|
162
|
+
|
|
163
|
+
Chrome is pointed at `<profile>/downloads` when the page starts, so a click
|
|
164
|
+
that saves a file writes it there instead of nowhere. The folder belongs to
|
|
165
|
+
the profile: it is the same one on the next launch, and nothing outside the
|
|
166
|
+
profile is touched. `sidecar_downloads` lists what is on disk, newest first;
|
|
167
|
+
a `.crdownload` still in flight is not listed.
|
|
168
|
+
|
|
169
|
+
## Frames and popups
|
|
170
|
+
|
|
171
|
+
A page is not one document. Chrome keeps a table of frames and gives each one the
|
|
172
|
+
JavaScript context its scripts run in; the plugin listens for those contexts as they
|
|
173
|
+
are created and destroyed, so a read can be sent to the context that owns the
|
|
174
|
+
controls it wants.
|
|
175
|
+
|
|
176
|
+
- A read walks the frame tree main frame first and sends one snapshot per frame, so a
|
|
177
|
+
form inside an iframe is numbered with the page's own controls.
|
|
178
|
+
- Refs from a frame are offset: `900000 + slot * 100000 + local`. The slot is the
|
|
179
|
+
frame's position in the tree, so the number says which context to resolve in, and a
|
|
180
|
+
frame's refs never collide with the page's.
|
|
181
|
+
- A click on a framed control resolves in that frame, then adds the frame's rectangle
|
|
182
|
+
to the point it found, because input is always dispatched in viewport coordinates.
|
|
183
|
+
- A frame Chrome runs in another process is a target of its own: the plugin keeps the
|
|
184
|
+
session Chrome hands it, so a read can be sent there and its controls still get a slot.
|
|
185
|
+
Its corner is asked from the page, through the frame's owner element, because the
|
|
186
|
+
frame's own process cannot see where it sits.
|
|
187
|
+
|
|
188
|
+
A click can open a tab. A page target gets no handle on its own popups, so after an
|
|
189
|
+
action the plugin asks the DevTools address list for page targets other than the one it
|
|
190
|
+
drives. If one appears, the mirror navigates to its address and the popup is closed with
|
|
191
|
+
the target HTTP endpoints. A person watching the column sees where the click went,
|
|
192
|
+
and there is still one page to drive.
|
|
193
|
+
|
|
194
|
+
## Files
|
|
195
|
+
|
|
196
|
+
| File | Half |
|
|
197
|
+
| --- | --- |
|
|
198
|
+
| `index.js` | Server: routes, tools, CDP page reads, system prompt. |
|
|
199
|
+
| `sidecar.js` | Server: Chrome launch, attach, screencast, input, shutdown. |
|
|
200
|
+
| `client.js` | Page: the column, its stream, the arrow, the address row. |
|
|
201
|
+
| `locale/*.json` | Page: pane labels. |
|
|
202
|
+
|
|
203
|
+
Data on disk is confined to `~/.dsh/inner-browser-use/`: `chrome/<profile>` for the browser,
|
|
204
|
+
`screenshots/` for `sidecar_shot`.
|
package/docs/BENCH.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
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–492 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
|
+
Breadth run (`npm run breadth`), 25 checks, all true: open shadow DOM read and
|
|
102
|
+
click, form typing, select, checkbox, submit, `confirm()` seen and accepted,
|
|
103
|
+
iframe read, iframe typing, iframe click, cross-site frame read and click,
|
|
104
|
+
popup followed, download saved, network responses listed, a file uploaded
|
|
105
|
+
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,
|
|
106
|
+
stale ref refused, a crashed renderer picked back up on the same page.
|
|
107
|
+
|
|
108
|
+
## Phase 4 — what a change costs
|
|
109
|
+
|
|
110
|
+
| What | Number |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| change in the page → frame ready for the column | 9 ms median, 17 ms max (12 samples) |
|
|
113
|
+
| `changed_only` after a field changes, dense page | 58 chars ≈ **17 tokens** (whole page: 444) |
|
|
114
|
+
| read: text page, `changed_only` | 42 chars ≈ 12 tokens (whole page: 1517) |
|
|
115
|
+
| renderer crashed → readable page again | **1.4 s** (detect, fresh tab, same address) |
|
|
116
|
+
|
|
117
|
+
The mirror's delay is measured from the moment the page is told to change to
|
|
118
|
+
the frame object the column would draw — capture, JPEG, and the CDP hop. It is
|
|
119
|
+
the part of the latency that belongs to this side; what a person sees adds the
|
|
120
|
+
browser's compositing and the SSE hop, both smaller.
|
|
121
|
+
|
|
122
|
+
A cross-site frame (Chrome runs it in its own process) costs one more round
|
|
123
|
+
trip than a same-process one: its controls arrive in the same read, and the
|
|
124
|
+
frame's corner is one `DOM.getFrameOwner` plus one call on the owner element.
|