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.
@@ -1,172 +1,204 @@
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.
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
- 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.
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
- ## The guest runtime
50
+ ## Attach and lifetime
106
51
 
107
- `client.js` keeps the whole in-page side as one string, evaluated per call:
108
-
109
- ```js
110
- el.executeJavaScript(`(async function(){ ${RUNTIME}; return await run(${op}, ${args}) })()`)
111
- ```
52
+ The browser outlives the plugin:
112
53
 
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.
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
- `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.
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
- `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.
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
- ## The lease
68
+ ## Routes
129
69
 
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.
70
+ Everything hangs under `/dsh-inner-browser-use`, all same-origin, bodies capped at 1 MB.
134
71
 
135
- On the rollback path the tab does not create its own browser. It asks the Desktop
136
- main process:
137
-
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
- ```
144
-
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.
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.