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.
@@ -1,172 +1,218 @@
1
1
  # Architecture
2
2
 
3
- Two halves, one wire.
3
+ Two halves in two processes, and a Chrome that neither of them is.
4
4
 
5
5
  ```
6
- Host (plugin index.js) Client (client.js, in the page)
7
- ────────────────────── ────────────────────────────────
8
- tools.register(browser_*) the product's browser tab → <webview>
9
- │ │
10
- │ bridge.call(chat, op, args) │ executeJavaScript(RUNTIME)
11
- ▼ ▼
12
- POST /push ────── SSE ──────► dispatch op, reply via POST /uplink
13
- ▲ │
14
- └────────── /uplink ◄───────────────────┘
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 the Host needs a wire at all
17
+ ## Why a wire
18
18
 
19
- The sidebar browser is a Client object. The `<webview>` is created in the product
20
- document, leased from the Desktop main process, and it only exists while the tab
21
- is mounted. A tool, however, executes on the Host. Nothing in the Host can reach
22
- a DOM element that lives in another process, so the two halves talk.
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
- ## The transport
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
- The Client `fetch`es relative URLs under `/dsh-inner-browser-use/`; the Host
27
- registers that prefix on its web server.
28
+ ## Chrome lives outside the product
28
29
 
29
- Same-origin is the whole trick, and it holds on both builds:
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
- - **Web build:** the page already sits on the Host origin.
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
- | Route | Direction | Purpose |
38
- | --- | --- | --- |
39
- | `GET /downlink?session=` | Host → tab | Server-sent events, one long-lived response per open tab. |
40
- | `POST /uplink?session=` | tab → Host | Replies and lifecycle frames. |
41
- | `POST /push?session=` | Host → tab | Diagnostic push without a waiting call. Not used by tools. |
42
- | `GET /watch` | Host → page | Server-sent events, one long-lived response per page (not per tab): the channel the page uses to learn that some chat needs a tab. |
43
- | `GET /state` | either | Which chats have a tab, how many calls are in flight, last client heartbeat. |
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
- ## The guest runtime
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
- `client.js` keeps the whole in-page side as one string, evaluated per call:
50
+ ## Attach and lifetime
108
51
 
109
- ```js
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
- `view` numbers every control it can see (`a`, `button`, `input`, `select`,
119
- `textarea`, `[role=…]`, `[onclick]`, `[tabindex]`) in document order, trimming
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.
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
- `click` and `type` scroll the target into view, move the pointer there, ripple,
125
- and dispatch real `MouseEvent`s (full `pointerdown/mousedown/mouseup/click`
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
- ## The lease
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
- Only the rollback path (`NATIVE_MODE = false`) uses this. In the shipped mode the
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
- On the rollback path the tab does not create its own browser. It asks the Desktop
136
- main process:
70
+ Everything hangs under `/dsh-inner-browser-use`, all same-origin, bodies capped at 1 MB.
137
71
 
138
- ```js
139
- const reservation = await dshDesktop.browser.acquire("cwd:/path/to/workspace")
140
- webview.name = reservation.lease
141
- webview.partition = reservation.partition
142
- webview.src = `about:blank#${reservation.lease}`
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
- All three attributes matter. The main process cancels `will-attach-webview` when
146
- the `src` hash does not match the lease it issued, so a webview configured
147
- loosely never attaches — it silently shows nothing. The partition is
148
- `dsh-sidebar-browser-<uuid>`, in memory, scoped to the workspace key: `cwd:<path>`
149
- for a chat in a workspace, `session:<id>` for an ungrouped one. Same key, same
150
- cookies as the built-in browser tab.
151
-
152
- In the shipped mode the partition is the product's own and outlives a restart, so
153
- no cookie bookkeeping is needed. On the rollback path that partition is in memory,
154
- so the page half files the cookies the page itself can read under `~/.dsh/inner-browser-use/cookies/<hash>.json`, mode 0600,
155
- keyed by the same workspace key: name, value, and the origin the page reported,
156
- nothing else, on this machine only. When that workspace's pane opens again, its
157
- remembered page loads, the kept cookies for that origin are written back, and the
158
- page loads once more — a cookie set from the page cannot affect a document that is
159
- already open. A workspace holds several sites, so a save replaces only the origin
160
- the page is on.
161
-
162
- The debugger is not an option for this: a `<webview>` element in the shipped app
163
- does not expose one, and the main process keeps the guest session to itself.
164
- Cookies marked `HttpOnly` are invisible to the page, so a login that relies only on
165
- those does not survive; anything the page reads from `document.cookie` does.
166
-
167
- ## Failure modes, by design
168
-
169
- - **No tab:** the call asks for one, waits, and fails with one sentence if none opens.
170
- - **No Desktop:** the body renders "Desktop app only"; nothing else registers.
171
- - **Page refuses:** the error string reaches the model as a tool error.
172
- - **Plugin stops:** every seat closes, every waiter rejects.
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.