dsh-inner-browser-use 0.27.0 → 0.34.95

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/docs/BENCH.md CHANGED
@@ -1,3 +1,402 @@
1
+ ## Flash 镜像按栏改窗口(0.34.48,Electron Flash 运行时,右栏 747×1196)
2
+
3
+ Flash 路径原来采集一个固定的 1440×900 窗口,竖高右栏里上下留黑边。现在 `setViewport` 直接改窗口大小,采集源与栏同比例。
4
+
5
+ | 状态 | 窗口 | 画面占栏 | 单帧 |
6
+ |---|---|---|---|
7
+ | 0.34.47 | 1440×900 | 约 39% | 126 KB |
8
+ | 0.34.48 | 747×1196 | 100% | 111 KB |
9
+
10
+ 轮询帧率 9 fps 不变(Flash 桥上限),改的是画面比例与单帧体积。
11
+
12
+ ## 动画页下行节流(0.34.47,真实 Chrome headless,720×880,canvas 60 fps 动画)
13
+
14
+ | 状态 | 帧率 | 单帧 | 下行带宽 |
15
+ | --- | --- | --- | --- |
16
+ | 0.34.46 全推 | 60 fps | 11 KB | 654 KB/s |
17
+ | 0.34.47 空闲节流 | 30 fps | 11 KB | 326 KB/s |
18
+ | 0.34.47 指针活动(1.5 s 内) | 58 fps | 11 KB | 631 KB/s |
19
+
20
+ 节流只作用于下行:`latest`、`seq`、`still()` 与输入路径不变;`browser_shot` 仍走独立整帧截图。
21
+
22
+ ## 坏页读取成本(0.34.46,构造页:`querySelectorAll` 抛错被读 150 字符)
23
+
24
+ | 读取结果 | 字符 | 约 token |
25
+ | --- | --- | --- |
26
+ | 0.34.46 一行故障说明 | 150 | 45 |
27
+ | 0.34.45 同一页读成 0 控件 0 字 | 20 | 6(但模型会重试或猜内容,整轮更贵) |
28
+
29
+ ## 点击与多步延迟(0.34.45,headless Chrome,本地页)
30
+
31
+ | 场景 | 0.34.45 | 0.34.44 |
32
+ | --- | --- | --- |
33
+ | `browser_do` 单步 | 73 / 83 ms | 98 / 112 ms |
34
+ | 遮挡点击(自动清掉横幅) | 72 / 77 ms | 97 / 112 ms |
35
+ | `browser_do` 20 步 | 2035 ms(102 ms/步) | 2151 ms |
36
+ | 崩溃恢复(换新标签页) | 1390 ms | ~1.5 s |
37
+
38
+ 做法:`Page.frameStartedLoading` / `frameStoppedLoading` 打时间戳,点击后若页面已 `complete` 且没有新加载开始,`settle()` 一次求值就返回;预算不变(点击 ≤ 220、遮挡点击 ≤ 240、单步 ≤ 240、每步 ≤ 280、崩溃恢复 ≤ 2600 ms)。
39
+
40
+ ## 真实站点读页成本(0.34.44,headless Chrome,720×880 栏宽,`scripts/readcost.mjs`)
41
+
42
+ | 页面 | 读法 | 字符 | 估算 token | 控件 |
43
+ | --- | --- | --- | --- | --- |
44
+ | GitHub 仓库首页(rust-lang/rust) | text | 2562 | 754 | 90 |
45
+ | 同上 | links | 3763 | 1107 | 90 |
46
+ | GitHub Topics · computer-use | links | 2590 | 762 | 90 |
47
+ | 同上 | text | 1939 | 570 | 90 |
48
+ | GitHub 登录表单 | forms | 218 | 64 | 4 |
49
+ | 同上 | text | 533 | 157 | 13 |
50
+
51
+ - 读页本身 1–21 ms;`max_chars` 只对长页生效(links 4600→2200:3763→2800 字符,1107→824 token),短页两种预算结果一致。
52
+ - 空读取重试:内容在 700 ms 后才写入的页面,一次 `browser_view` 用 614 ms 拿到内容(此前直接读到空页)。正常页面不进入该分支,延迟与 token 不变。
53
+
54
+ ## 故障归因的 token 成本(0.34.43)
55
+
56
+ | 场景 | 读取结果多出的内容 | 代价 |
57
+ | --- | --- | --- |
58
+ | 页面正常 | 0 字符 | 0 |
59
+ | 1 个 404 请求 + 1 个脚本错误 | 1 行,约 130 字符 | ≈33 token,替代 2 次排查调用(通常 ≥600 字符) |
60
+ | 同一页面再读 | 0 字符(已报过) | 0 |
61
+
62
+ ## 点击与多步延迟(0.34.42,headless Chrome,本地页)
63
+
64
+ `node scripts/bench.mjs` 复现;末行 `budget` 是纳入退出码的预算断言。
65
+
66
+ | 场景 | 0.34.42 | 0.34.40 |
67
+ | --- | --- | --- |
68
+ | `browser_click` 普通页 | 97 ms | 439 ms |
69
+ | `browser_click` 横幅遮挡、自动清掉 | 107 ms | 593 ms |
70
+ | 被弹窗拦住的点击(拒绝) | 2 ms | — |
71
+ | `browser_do` 单步 | 95 ms | 440 ms |
72
+ | `browser_do` 20 步 | 2151 ms(108 ms/步) | 9067 ms |
73
+ | 崩溃恢复(换新标签页) | ~1.5 s | — |
74
+
75
+ 预算:点击 ≤ 220 ms、遮挡点击 ≤ 240 ms、单步 ≤ 240 ms、20 步每步 ≤ 280 ms、崩溃恢复 ≤ 2600 ms。
76
+
77
+ ## 点击与多步延迟(0.34.41,headless Chrome,本地页)
78
+
79
+ | 场景 | 耗时 |
80
+ | --- | --- |
81
+ | `browser_click` 普通页 | 141 ms |
82
+ | `browser_click` 横幅遮挡、自动清掉 | 235 ms |
83
+ | 被弹窗拦住的点击(拒绝) | 2 ms |
84
+ | `browser_do` 单步 | 131 ms |
85
+ | `browser_do` 20 步 | 3073 ms(154 ms/步) |
86
+ | 跟随点击开出的弹窗 | 22 ms |
87
+
88
+ `node scripts/bench.mjs` 复现;对照 0.34.40 的 439 / 593 / 440 / 9067 ms。
89
+
90
+ ## 广度回归(0.34.40,headless Chrome,本地构造页)
91
+
92
+ `node scripts/breadth.mjs` — 66 项操作全通过:shadow DOM 读/点、表单输入/退格/清空、按字段名输入、下拉、勾选、提交、只读变化、画布编号与点选、截图尺寸、遮挡弹窗拒绝、失效 ref 跟随、断线恢复。
93
+
94
+ ## 真实站点读页成本(0.34.39,headless Chrome,720×880 栏宽)
95
+
96
+ | 页面 | 字符 | 估算 token | 控件 |
97
+ | --- | --- | --- | --- |
98
+ | example.com | 976 | 305 | 1 |
99
+ | GitHub Topics · computer-use | 约 1.5k | 约 470 | 90 |
100
+
101
+ 读页脚本此前因正则转义缺失整段无法解析,同一测例读出 0 控件 0 字符;修好后为上面数字。
102
+
103
+ ## 读页 token 成本(0.34.38,构造页:300 行链接,字符预算 24000 以排除截断)
104
+
105
+ | 读法 | 字符 | 估算 token |
106
+ | --- | --- | --- |
107
+ | 列出全部控件(旧) | 9153 | 2860 |
108
+ | 控件折叠到 140 行(新) | 4277 | 1337 |
109
+
110
+ 正文不再被编号挤到预算之外;被折叠的控件滚动后重新出现。
111
+
112
+ ## 长文本输入延迟(0.34.37,真实 Chrome headless)
113
+
114
+ | 目标 | 方式 | 314 字符耗时 | 文本一致 |
115
+ | --- | --- | --- | --- |
116
+ | `<textarea>` | 逐键 `dispatchKeyEvent` | 1686 ms | 是 |
117
+ | `<textarea>` | 一次 `Input.insertText` | 2 ms | 是 |
118
+ | `contenteditable` | 一次 `Input.insertText` | 13 ms | 是 |
119
+
120
+ ## 右栏尺寸下的镜像带宽(0.34.36,真实 Chrome headless,720×880)
121
+
122
+ | 页面 | 3–4 s 内帧数 | 帧率 | 平均单帧 | 带宽 |
123
+ | --- | --- | --- | --- | --- |
124
+ | 静态真实站点(example.com) | 3 | <1 fps | 47 KB | 47 KB/s |
125
+ | 注入 `requestAnimationFrame` 动画 | 181 / 3 s | ~60 fps | 15 KB | 875 KB/s |
126
+
127
+ 结论:静止页面几乎不花带宽(Chrome 的 screencast 只在合成器变化时发帧);满帧动画按列宽排版后单帧只有 15 KB,60 fps 总计约 875 KB/s,走本机 SSE 无压力,不设帧率上限以免牺牲延迟。「同一张图不重复发」这道闸真正省掉的是 Flash 轮询分支与 `still()` 连发(单测:3 次连发 → 1 张)。
128
+
129
+ ## 镜像按右栏尺寸排版(0.34.35,真实 Chrome,`--headless=new`)
130
+
131
+ 页面不再按 1440×900 排版,而是按右栏实测的尺寸排版;下表的 `inner` 是页面自己报告的 `innerWidth×innerHeight`,证明不是缩放图片而是重新排版。
132
+
133
+ | 请求尺寸 | 页面 inner | `Emulation.setDeviceMetricsOverride` + 重开 screencast | 一张整帧 `captureScreenshot` | 整帧 JPEG(纯色测试页) |
134
+ | --- | --- | --- | --- | --- |
135
+ | 720×880 | 720×880 | 41 ms | 39 ms | 11.7 KB |
136
+ | 520×1000 | 520×1000 | 34 ms | 33 ms | 11.3 KB |
137
+ | 1440×900 | 1440×900 | 49 ms | 47 ms | 17.4 KB |
138
+
139
+ 冷启动到可排版 827 ms。点击链路在同一尺寸下实测:两次 `Input.dispatchMouseEvent`(按下+抬起)13 ms,页面 `mousedown` 处理器已计数,订阅后下一帧 8 ms 内到达。
140
+
141
+ ## Phase 46 — 开玩前的体检,0.34.30
142
+
143
+ `npm run ready [url]` 跑新工具 `browser_prepare`,整条链路一次调用,自带 Chrome、跑完关掉:
144
+
145
+ | 页面 | 调用耗时 | 找到的输入面 | 帧率 | 按键 |
146
+ | --- | --- | --- | --- | --- |
147
+ | 4399《怪物小汽车》 | 3297 ms | frame 748×336(在屏幕外,先滚进来再点) | 页面 58 fps / 框架 59 fps | 没进到框架,工具直说「先点进框架一次」 |
148
+ | 奥拉星 `http://aoqi.100bt.com/` | 573 ms | — | — | —,地址已连不上,返回 Chrome 错误页说明 |
149
+
150
+ `browser_prepare` 的判断顺序:连不上就直说 → 读页面 → 挑画布/框架(优先屏幕内的,否则滚进视野)→ 有 Flash 影片就说这个浏览器放不了 → 数帧率(页面与最大的框架各数一遍)→ 派一个真键看它是否真的到达框架 → 一行结论。
151
+
152
+ ## Phase 45 — 两种操作方式的实际差别,0.34.29
153
+
154
+ 同一张本地页(按钮 + 输入框,页面在捕获阶段记下每个事件的类型和 `isTrusted`),`npm run modes`:
155
+
156
+ | 路径 | 受信任事件 | pointerdown | click | keydown | input | 写进值 |
157
+ | --- | --- | --- | --- | --- | --- | --- |
158
+ | human 点按钮 | 3 | 1 | 1 | – | – | – |
159
+ | dom 点按钮 | 0 | 1 | 1 | – | – | – |
160
+ | human 输入 abc | 11 | – | – | 3 | 3 | abc |
161
+ | dom 输入 abc | 0 | – | – | 0 | 1 | abc |
162
+
163
+ `trustedEvents` 是这张表里唯一真正的分界:真人路径全为 true,`dom` 路径全为 false。要过反自动化、要看指针轨迹、要按键有 `code` 的游戏,只有 human 能走;被浮层盖住、被脚本接管了按键的登录框,`dom` 一次就过。
164
+
165
+ ## Phase 44 — 真站页游场地,0.34.28
166
+
167
+ `node scripts/check-game.mjs <url>`,真 Chrome、自己的 profile、跑完自己关掉。两个真站:
168
+
169
+ | 站点 | 空闲时页面刷新 | 全屏截图(`browser_shot` 同类)| 按住 `d` 200 ms 到达页面 |
170
+ | --- | --- | --- | --- |
171
+ | 奥拉星 `http://aoqi.100bt.com/` | **27 fps** | 中位数 **68 ms** | 是 |
172
+ | 4399 `https://www.4399.com/` | **59.8 fps** | 中位数 **148 ms** | 是 |
173
+
174
+ 两个落地页都**没有 canvas**:游戏本体在链接后面,要么登录要么再点进去一层,所以这组数字说明的是「真站能不能实时跑」,不是「游戏画面已经能读」。
175
+
176
+ 诚实的一层:按键到达页面这件事在真站已经证实(页面自己记录的 `keydown`/`keyup`),但 4399 这种整屏重绘的页面,一张全屏图 1.8 MB、148 ms —— 做实时画面要压、要裁、要降缩放,这一层还没做,别拿它当「已经能玩」。
177
+
178
+ ## Phase 43 — 页游级输入:按住、组合键、按住鼠标,0.34.26
179
+
180
+ 本地画布页(每帧重绘,页面自己记录 `keydown`/`keyup`/`pointerdown`/`pointerup` 时间戳),真 Chrome,`node scripts/bench.mjs`:
181
+
182
+ | 项 | 数字 |
183
+ | --- | --- |
184
+ | `browser_key` 带 `hold_ms: 300` 整次调用 | **305 ms**(含 300 ms 按住,收尾 5 ms) |
185
+ | 页面测到的按住时长 | **301 ms** |
186
+ | `ArrowLeft+ArrowUp` | 两键都收到 `keydown`,都收到 `keyup`,顺序为倒序松开 |
187
+ | `browser_click` 带 `hold_ms: 200` 页面测到 | **211 ms** |
188
+
189
+ 对照 0.34.25 之前:同样的按住被 `settle` 拖到 727 ms,等于 0.7 s 才走一步——页游里这就是掉帧。
190
+
191
+ ## Phase 42 — the reload path, proved by two instances, 0.34.25
192
+
193
+ `npm run adopt`(真 Chrome,临时 profile `adopt-check`,临时状态目录):
194
+
195
+ | 项 | 数字 |
196
+ | --- | --- |
197
+ | 第一个实例冷启动到页面可用 | 675 ms |
198
+ | 第二个实例(模拟重载后的半边)接管 | 162 ms |
199
+ | 第二个实例自己起的 Chrome | `null`(没有起第二个) |
200
+ | 浏览器进程 | 同一个 pid,接管后仍活着 |
201
+ | 页签与标题 | `title: "kept"`,内容原样 |
202
+
203
+ 这就是重载后「接回同一个浏览器、不重启、不丢页签和登录」的机器证据;之前只有手工重载才能走到这条路。
204
+
205
+ ## Phase 41 — the read tool that had never run, 0.34.23
206
+
207
+ `node scripts/bench.mjs`(本机 headless 页面,Chrome 复用 `bench` profile):
208
+
209
+ | 项 | 数字 |
210
+ | --- | --- |
211
+ | 首次画面(复用已在跑的浏览器) | 133 ms |
212
+ | 首帧字节 | 76 KB |
213
+ | 帧率 | 60 fps |
214
+ | 镜像延迟 | 中位 8 ms / 最大 8 ms |
215
+ | 遮挡检测 | 中位 3 ms / 最大 15 ms |
216
+ | 点一下(普通页 / 有遮罩,含读回) | 439 / 590 ms,遮罩那次 `cleared: true` |
217
+ | 一步 `browser_do` | 440 / 590 ms |
218
+ | 截图 | 73 ms / 115 KB |
219
+ | 读 dense 页 text 档 | 6 ms / 1484 字 / ≈436 token |
220
+ | 读 dense 页 forms 档 | 1 ms / 432 字 / ≈127 token |
221
+ | 再读同一页 | 1–2 ms,给出「没有变化」一行 |
222
+ | `changed_only` | 58 字 / ≈17 token(整页 1505 字) |
223
+
224
+ 这轮 bench 的作用不是数字,而是**它第一次跑到 `browser_view` 就报 `ReferenceError` 退出**:那个工具在真实会话里同样会失败,而单测只测了它的纯函数部分。修完之后 bench 一次性跑完,数字在上面。
225
+
226
+ ## Phase 40 — the poll that names its chat, 0.34.19
227
+
228
+ The adoption rule from 0.34.16 never fired for the column itself: the pane
229
+ polled `/sidecar/state` without a session, the host treated that as anonymous,
230
+ and a reload could still leave the mirror empty over a live browser. The pane now
231
+ names its chat, and asks again the moment a claim succeeds.
232
+
233
+ No performance change was expected; the full bench was re-run anyway so the
234
+ numbers under it are current (commit 0.34.19, local pages):
235
+
236
+ | Label | Value |
237
+ | --- | --- |
238
+ | launch → first frame | 1029 ms |
239
+ | steady stream | 60 fps, 76 KB per frame |
240
+ | mirror delay (12 samples) | median 9 ms, max 13 ms |
241
+ | dense page, text | 6 ms, 1484 chars, ≈436 tokens |
242
+ | dense page, forms | 2 ms, 432 chars, ≈127 tokens |
243
+
244
+ ## Phase 39 — count what is on the screen, not what is in the DOM, 0.34.17
245
+
246
+ `readFrames` counts child frames; the page's own count filters to frames that
247
+ occupy the screen. Taking the larger made a hidden 1×1 frame claim a blind spot.
248
+
249
+ - `blindSpots` now lets the page's visible count win; the reader's count is the
250
+ fallback for when the page could not count.
251
+ - Live: two frames (400×300 and 1×1) → `blind.frames` 1, exactly one line.
252
+
253
+ ## Phase 38 — a reload must not blank the mirror, 0.34.16
254
+
255
+ `/sidecar/state` answered from the new plugin instance, which owns nothing until
256
+ something calls `open`: after a reload the owning chat's own poll said
257
+ `alive:false`, hence "no page open" in the column while Chrome was fine.
258
+
259
+ - The owning chat's state poll adopts the running browser and the tab remembered
260
+ for it before answering.
261
+ - Live: `/dsh-unlag/reload` 200 → state `alive True`, url `https://www.doubao.com/chat/`,
262
+ Chrome pid 12833 with 11 m uptime, i.e. attached rather than relaunched.
263
+ - Non-owning chats and empty `session` polls still answer without spawning anything.
264
+
265
+ ## Phase 37 — an observation that admits its blind spot, 0.34.15
266
+
267
+ A text read and a screenshot claim to show the page. Both are lossy, and the loss
268
+ was silent: a chart wired as `<canvas>`, a generated image with no alt text, a
269
+ video, a cross-origin frame — nothing in the text said "there is something here
270
+ you cannot read". A model then planned as if the page were fully described.
271
+
272
+ - `readPage` counts the unreadable visible things on the page and in frames, and `renderPage` prints exactly one line when the count is non-zero; zero adds no line (measured token cost of the feature is 0 on pages that have nothing to hide).
273
+ - `chrome-error://` is no longer rendered as an empty page: it says the page did not load and names `browser_nav`.
274
+ - `navigate` retries that error page once (300 ms + 8 s cap). Cold launch on a fresh profile: first navigation recovered on the retry; an unreachable host still returns `bad` in well under the cap.
275
+ - `browser_shot` without an image channel appends what the result is missing and which tool fills it; file path alone is no longer the whole answer.
276
+ - Suite: 101 tests, 0 failures.
277
+
278
+ ## Phase 36 — a reload must not cost the browser, 0.34.14
279
+
280
+ - 根因:交接看门狗 `while kill -0 ${process.pid}` 盯的是插件实例 pid,`dsh-unlag` 重载换 pid 后看门狗立刻 kill 共享 Chrome —— 镜像白屏、会话掉回 about:blank。
281
+ - 改为盯应用 pid(`ps -o ppid=` 向上走到顶)+ `watcher.json` 去重;实测 node 14185 的应用 pid 是 13663。
282
+ - `ensure()` 无活页面时回落到 `rememberedTab(id)`,首开即回原地址。
283
+ - 测试 97 条全绿。
284
+
285
+ ## Phase 35 — a review round that can be checked off, 0.34.13## Phase 35 — a review round that can be checked off, 0.34.13
286
+
287
+ - `markRound()` 追加 verdict 行,`lastRound()` 折回 `marks`;`brief({already})` 只保留 open 条目,实测提问里出现「Already handled from your previous review」,已处理点不再重复。
288
+ - 工具面新增 `browser_review_verdict`(`mark: [{point,status,evidence}]`,状态枚举 fixed/verified/invalid/open);越界编号丢弃、未知状态记为 open。
289
+ - 测试 92 → 96 条全绿。
290
+
291
+ ## Phase 34 — one gate in front of every write, 0.34.12## Phase 34 — one gate in front of every write, 0.34.12
292
+
293
+ - `acting()` 统一 400(未命名会话)与 403(非持有者);实测匿名 `POST /sidecar/nav` → 400 `which chat asked for it`,非持有者 `POST /sidecar/stop` → 403,持有者 `forget` → 200。
294
+ - 测试 90 → 92 条,全绿;文案与样式 lint 无 error。
295
+
296
+ ## Phase 33 — a lost picture comes back in one click, 0.34.11
297
+
298
+ - 实测「切走 / 重载后画面没了」的下一步:`/sidecar/state?session=…` 与画面流 hello 都给出 `chat.remembered`(该会话最后地址)。
299
+ - 右栏在没有画面且 `remembered` 有值时显示「恢复上次的页面」,点击 POST `/sidecar/open` 并重连画面流。
300
+ - 同一会话第二次 open 命中已有标签页:107 ms(三次实测 2.08 s / 0.107 s / 0.108 s,首次含导航)。
301
+
302
+ ## Phase 32 — a call log belongs to its chat, 0.34.10
303
+
304
+ `GET /sidecar/state` returned the last 40 mirror calls of every chat, each with
305
+ an eight-character session id: one conversation could read where another had
306
+ been browsing. The list is now filtered to the caller's own chat. The owner and
307
+ the list of listening chats stay global, because those are what the column needs
308
+ to claim the mirror.
309
+
310
+ ## Phase 31 — a reload keeps the mirror, 0.34.9
311
+
312
+ `ctx.effect` cleanup ran `sidecar.shutdown()`, so each plugin reload closed
313
+ Chrome. The page disappeared from the column and the next open was a cold
314
+ start again. Cleanup now hands the browser to the next driver: cookies and a
315
+ login snapshot hit disk, the browser keeps running, and a detached watcher
316
+ closes it only after the app process is gone.
317
+
318
+ | after a `dsh-unlag` reload | before | now |
319
+ |---|---|---|
320
+ | browser | closed, page lost | still running, page kept |
321
+ | next interaction | cold launch (≈0.6 s, was 21 s) | attach, no launch |
322
+
323
+ ## Phase 30 — a brand-new chat's first tab, 0.34.8
324
+
325
+ A fresh profile with no memory of a page means `openTab` asks for `about:blank`.
326
+ `navigate` then polled for a non-blank address, which a blank page never becomes,
327
+ and spent all 200 tries. Every first open of a new chat paid it.
328
+
329
+ | cold start, fresh profile (`about:blank`) | before | now |
330
+ |---|---|---|
331
+ | round 1 | 21 322 ms | 817 ms |
332
+ | round 2 | 20 882 ms | 607 ms |
333
+ | round 3 | 20 900 ms | 516 ms |
334
+ | page settings round trips | 4 | 1 |
335
+
336
+ Measured with three launches of `Sidecar#ensure` against a throwaway profile;
337
+ the first launch is now about 26× faster than before.
338
+
339
+ ## Phase 29 — a stale session cookie is not written back, 0.34.7
340
+
341
+ `restoreSessionCookies` replayed every kept session cookie on launch, including
342
+ ones the browser already had. A rotated `sessionid` from the note would replace
343
+ the current one, and the site answers a retired session with a sign-in page.
344
+
345
+ | case | before | now |
346
+ |---|---|---|
347
+ | note holds sessionid, browser holds rotated one | old value written | nothing written |
348
+ | note holds a cookie the browser lost | written | written |
349
+ | cookie key | — | domain + path + name |
350
+
351
+ 86 tests complete the suite in about 2 s.
352
+
353
+ ## Phase 28 — one chat, one tab, even in parallel, 0.34.6
354
+
355
+ `tabFor` checked `pages`, then did async work, then wrote `pages`: two calls that
356
+ started together both missed the map and both opened a tab. The orphan tab kept
357
+ a live page nobody could see or stop.
358
+
359
+ | case | before | now |
360
+ |---|---|---|
361
+ | two parallel calls, cold chat | 2 tabs, 1 orphan | 1 tab |
362
+ | later call with another address | its own tab | same tab, navigated |
363
+ | leftover pending note | — | cleared in `finally` |
364
+
365
+ The fix is a per-chat promise in `opening`, joined by the second caller.
366
+
367
+ ## Phase 27 — one gate for the mirror, 0.34.5
368
+
369
+ Three routes each carried their own copy of the ownership check. Copies drift:
370
+ a route added later is exactly the one nobody remembers to guard.
371
+
372
+ | case | result |
373
+ |---|---|
374
+ | rival chat → `/sidecar/input` | 403, owner named |
375
+ | rival chat → `/sidecar/nav` | 403, owner named |
376
+ | rival chat → `/sidecar/frames` | 403, no stream opened |
377
+ | unit tests | 84 |
378
+
379
+ The gate reads one function, so the question "may this chat act?" has one answer
380
+ in one place.
381
+
382
+ ## Phase 26 — a screenshot the model can see, 0.34.3
383
+
384
+ Observation was structured text only: `browser_shot` wrote a PNG and returned
385
+ a path, which is a file name the model cannot look at. Codex and Claude Code
386
+ both hand screenshots back as image content, and that is the difference
387
+ between planning from the picture and guessing.
388
+
389
+ | case | before | now |
390
+ |---|---|---|
391
+ | route with image input | path only | path + image block, same call |
392
+ | route without image input | path | path, no image block, no error |
393
+ | no attachment store | path | path |
394
+ | read state across chats | one shared object | one per chat |
395
+ | unit tests | 79 | 81 |
396
+
397
+ Same-call cost: the PNG is already in memory for the file write, so the image
398
+ block adds one attachment save, no extra round trip and no second screenshot.
399
+
1
400
  <!-- ui-copy-ignore -->
2
401
  # Measured, not claimed
3
402
 
@@ -98,7 +497,7 @@ answer), and a read now also asks for the frame tree.
98
497
  | key round trip | 13 ms | 2 ms |
99
498
  | screenshot | 82 ms, 133 KB | 77 ms, 113 KB |
100
499
 
101
- `sidecar_extract` on a 30-row list: 1106 chars for all 30 rows (≈330 tokens),
500
+ `browser_extract` on a 30-row list: 1106 chars for all 30 rows (≈330 tokens),
102
501
  against 1291 chars for the same page read as text — and at a 500-char read
103
502
  budget the text read stops around row 12 while the list still returns 30.
104
503
 
@@ -148,10 +547,10 @@ frame's corner is one `DOM.getFrameOwner` plus one call on the owner element.
148
547
  | What | Number |
149
548
  | --- | --- |
150
549
  | 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 |
550
+ | `browser_click` on an uncovered control | 460 ms median (3 runs, page-load settle included) |
551
+ | `browser_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
552
  | 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) |
553
+ | one `browser_do` step on an uncovered control | 458 ms median (3 runs) |
155
554
  | the same step under the cookie banner | 633 ms median (3 runs) — cleared, then the step ran |
156
555
 
157
556
  A batch pays it once per step that has something over it, not once per call: a
@@ -176,10 +575,10 @@ is now done inside the call, by looking the control up by what it is.
176
575
 
177
576
  | What | Number |
178
577
  | --- | --- |
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 |
578
+ | `browser_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 |
579
+ | `browser_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
580
  | 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 |
581
+ | a control that really left the page | still refused, `Call browser_view and use the new number` — 2/2 in the breadth suite |
183
582
 
184
583
  So the recovery costs about **17 ms** when it is needed and nothing when it is
185
584
  not, against a model round trip of two calls plus the tokens of a fresh read.
@@ -207,7 +606,7 @@ change mid-session. Changing it restarts the browser, so it costs a cold start:
207
606
  | windowed → windowless, same profile and page | 22.1 s | one process, cold start |
208
607
  | asking for the shape it already has | 0 ms | nothing is restarted |
209
608
 
210
- Measured through the tool layer (`sidecar_open` → `sidecar_state` → `ps` spawn
609
+ Measured through the tool layer (`browser_open` → `browser_state` → `ps` spawn
211
610
  args), profile `probe-tool`, 1 run each; the same-shape row is the third call in
212
611
  the same run. A browser start on this machine is 20–22 s either way, so the
213
612
  switch is not what costs — the process is.
@@ -223,9 +622,9 @@ bounded wait is the difference between a silent `viewers 0` and a sentence:
223
622
 
224
623
  | what | cost |
225
624
  | --- | --- |
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) |
625
+ | `browser_open` with a tab watching | 0 ms |
626
+ | `browser_open` with nothing watching | ≤ 1.2 s, and the line says so |
627
+ | `browser_open` when the chat has no column to draw into | 0 ms (no wait) |
229
628
  | building the sentence on a normal state read | 0 ms, one comparison |
230
629
 
231
630
  Measured with `/tmp/probe-unseen.mjs` (tool layer, example.com, profile
@@ -255,7 +654,7 @@ reverse-contains (1).
255
654
 
256
655
  | what the caller has | what it used to take | now |
257
656
  | --- | --- | --- |
258
- | a name for the field, no number | read the page, find the number, then type | one `sidecar_type` with `field`, one round trip |
657
+ | a name for the field, no number | read the page, find the number, then type | one `browser_type` with `field`, one round trip |
259
658
  | the field's words live in a div | click landed on the div, text went wherever focus was | resolved to the field, `typed into "Work email"` |
260
659
  | a banner over the field | typed under the banner | banner cleared first, then typed |
261
660
 
@@ -265,7 +664,7 @@ Breadth check `type-by-words` on the `/bare` form: value lands in
265
664
  ## Phase 11 — choosing a dropdown by name, 0.21.0
266
665
 
267
666
  `/bare` now carries a `select[name=seats]` whose only name on screen is a div
268
- saying `Seats`. One `sidecar_select` with `field: "Seats"`, `value: "Two"`:
667
+ saying `Seats`. One `browser_select` with `field: "Seats"`, `value: "Two"`:
269
668
 
270
669
  | what | result |
271
670
  | --- | --- |
@@ -299,7 +698,7 @@ Exact equality still wins over the squeezed form, so `Sign in` never lands on
299
698
  | what | before | now |
300
699
  | --- | --- | --- |
301
700
  | `{field: "Birthday"}` | not found, fell back to clicking the div | found the hidden input, clicked its visible box |
302
- | value after one `sidecar_do` step | whatever already had focus | `2001-02-03` in `#bd`, `typed into "Birthday"` |
701
+ | value after one `browser_do` step | whatever already had focus | `2001-02-03` in `#bd`, `typed into "Birthday"` |
303
702
  | extra round trips | read the page, guess a number | none |
304
703
 
305
704
  The visible-box rule: a candidate is usable when it, or its nearest visible
@@ -312,7 +711,7 @@ something the person can see.
312
711
 
313
712
  | what | result |
314
713
  | --- | --- |
315
- | `sidecar_grab` | 6 ms, `picture-check.png`, 140x90 |
714
+ | `browser_grab` | 6 ms, `picture-check.png`, 140x90 |
316
715
  | before | three round trips: list the images, ask for the full address, fetch it |
317
716
 
318
717
  A picture page has no text at all, so a read of it costs one line and no tokens;
@@ -322,7 +721,7 @@ the picture comes back as a file path the person can open.
322
721
 
323
722
  Two 140x90 PNGs: one a gradient, one a single colour.
324
723
 
325
- | page | what `sidecar_grab` answers | time |
724
+ | page | what `browser_grab` answers | time |
326
725
  | --- | --- | --- |
327
726
  | `/pic` | `... 140x90 a picture with detail` | 21 ms (first, page cold) |
328
727
  | `/flat` | `... 140x90 mostly one colour` | 4 ms |
@@ -334,10 +733,162 @@ travels in the same line as the path.
334
733
 
335
734
  | page | read line | extra round trips |
336
735
  | --- | --- | --- |
337
- | `/pic` (gradient, no words) | `picture 140x90 a picture with detail "Chart" — sidecar_grab saves it as a file` | 0 |
736
+ | `/pic` (gradient, no words) | `picture 140x90 a picture with detail "Chart" — browser_grab saves it as a file` | 0 |
338
737
  | `/flat` (single colour) | `picture 140x90 mostly one colour "Chart" — …` | 0 |
339
738
  | `/words` (text page) | no picture line | 0 |
340
739
 
341
740
  The sampling is 32x32 inside the page, in the same call that reads the page, so a
342
741
  blank chart is visible before anything is saved.
343
742
 
743
+ ## Phase 18 — the picture that never arrived, 0.28.0
744
+
745
+ | page | read line | time |
746
+ | --- | --- | --- |
747
+ | `/broken` (320x240 `<img>` pointing at a 404, no words) | `picture 320x240 "Chart" did not load — the page may need a reload, or the picture is gone` | 123 ms (cold page) |
748
+
749
+ A failed image collapses to an error icon, so the declared `width`/`height` is
750
+ what the page meant to show; the read reports that size, not the icon's.
751
+
752
+ ## Phase 19 — the page said again, 0.29.0
753
+
754
+ | read | characters | time |
755
+ | --- | --- | --- |
756
+ | first read of `/dense` (40 paragraphs) | 2057 | — |
757
+ | second read, nothing changed | 105 | 1 ms |
758
+
759
+ The comparison is free: both reads already happened. Only the report shrinks.
760
+ A changed scroll offset is still reported, so "nothing changed" is never a lie.
761
+
762
+ ## Phase 20 — one call carries the question and the answer, 0.30.0
763
+
764
+ | step | before | `browser_ask` |
765
+ | --- | --- | --- |
766
+ | type the message | `browser_type` + `submit` | same, inside the call |
767
+ | read the page | `browser_view` | — |
768
+ | read it again until the answer stops growing | 2–6 more `browser_view` calls | — |
769
+ | **turns for a chat reply** | 3–7 | **1** |
770
+ | measured `browser_ask` on `/chat` (answer over 2 arrivals, 700 ms tail) | — | 2.5 s, one turn |
771
+
772
+ ## Phase 21 — a review round that closes the loop, 0.31.0
773
+
774
+ | step | before | `browser_review` |
775
+ | --- | --- | --- |
776
+ | write the request (repo, commit, focus) | by hand, every round | built from three fields |
777
+ | send it, wait for the answer | `browser_ask` | same call |
778
+ | turn the answer into a list | read it, retype it | parsed, numbering kept |
779
+ | know what the last round was about | — | round log, with the commit and the time |
780
+ | **turns for a review round** | 3–7 + bookkeeping | **1** |
781
+ | measured round on `/chat` (echoed question + 2-item list) | — | 2.6 s, 2 points |
782
+
783
+ ## Phase 21b — an answer that is not an answer, 0.31.1
784
+
785
+ A chat box on a page that is not signed in still accepts text, still redraws,
786
+ and still looks busy. Three cheap checks keep that from being reported as a
787
+ review.
788
+
789
+ | what the page does | before | now | cost |
790
+ | --- | --- | --- | --- |
791
+ | keeps the words in the box | waits 4 s, then reads the page | says so at 0.5 s | 1 evaluate |
792
+ | redraws but never shows the message | the new words were the answer | says the page did not take it | none (text compare) |
793
+ | shows the message, no reply | "answered in prose" | "has the message but has not answered yet" | none (text compare) |
794
+ | real page, signed out (Doubao) | a review-shaped paragraph of menu chips | the sign-in is named, 2.6 s | — |
795
+
796
+ ## Phase 22 — the change travels with the request, 0.32.0
797
+
798
+ The reviewer inside the column cannot open a repository. Asking it to review
799
+ "the newest commit" of a repository it cannot see is asking for a guess.
800
+
801
+ | request | what the reviewer gets | cost |
802
+ | --- | --- | --- |
803
+ | repo + commit | an address and a hash it cannot open | 1 message |
804
+ | repo + commit + diff | the change itself, capped at 5000 characters | +diff characters in that one message |
805
+
806
+ Measured: `review-carries-diff` green in 2.6 s; the recorded round carries
807
+ `+const cap = 60` verbatim.
808
+
809
+ ## Phase 22b — the mirror outlives the visit, 0.33.0
810
+
811
+ A column tab only exists while the chat is on screen. The ask used to be a
812
+ single frame pushed at request time: if that chat had no stream, the frame was
813
+ lost and the mirror never appeared on return.
814
+
815
+ | situation | before | now |
816
+ | --- | --- | --- |
817
+ | the chat is on screen | tab opens | tab opens |
818
+ | the chat is elsewhere, then returns | nothing | tab is waiting |
819
+ | the page was reloaded | nothing | tab is waiting |
820
+ | the ask is 3 minutes old | nothing | nothing (spent) |
821
+ | a different chat comes back | nothing | nothing (not its mirror) |
822
+
823
+ Data flow never needed the column: measured with zero viewers — boot 626 ms,
824
+ click and read back 114 ms, read 4 ms, shot 48 ms / 12.5 KB.
825
+
826
+ ## Phase 22c — a duplicate cannot take the pane down, 0.33.2
827
+
828
+ A scan of the six shipped modules finds no repeated top-level name, no module
829
+ imported twice, no two tools with one name, one tab id and one style sheet.
830
+ The two places a reload can still meet itself are guarded instead of trusted:
831
+
832
+ | case | before | now |
833
+ | --- | --- | --- |
834
+ | same tool name registered again | thrown error, surface down | warning, first one kept |
835
+ | same tab mounted again | possible duplicate registration | no-op |
836
+ | style sheet re-applied | second sheet stacked | replaced by id |
837
+ | two mount entry points | possible double effect | one entry per half |
838
+
839
+ Guards are covered by `test/no-duplicate.test.mjs` plus a double-mount test:
840
+ 65 unit tests, 66 breadth checks, 20 packaged files.
841
+
842
+ ## Phase 25 — one chat cannot take another chat's page, 0.34.2
843
+
844
+ The audit on 0.34.0 named five things a driver written for one chat got wrong.
845
+ Each was reproduced against 0.34.1 first: two were already fixed (the
846
+ `sessionScope`/`KEYS` import, and every `ensure()` route already carrying a
847
+ session), three were real.
848
+
849
+ | What | Before | Now |
850
+ | --- | --- | --- |
851
+ | popup lookup | any non-main tab, then navigated or closed | only a target that is neither this page nor another chat's (`isForeign`) |
852
+ | focused password in a result line | echoed the `.value` (`DUMMY_SECRET_123`) | name only, value always empty |
853
+ | `browser_do` on a step that did not land | kept going, typed into the wrong page | stops at that step, says to re-plan |
854
+ | overlay that would not clear | clicked the control under it anyway | refuses the click/typing, names the overlay |
855
+ | `changed_only` twice | repeated the same change | diff becomes the new baseline |
856
+ | `/sidecar/state` | top-level `url` could be another chat's tab | `?session=` adds `chat.url/title/viewers` (live: doubao page, viewers 1) |
857
+ | suite | 77 pass | 79 pass (isolation, leak) |
858
+
859
+ ## Phase 24 — a number that is checked before it is used, 0.34.1
860
+
861
+ | What | Before | Now |
862
+ | --- | --- | --- |
863
+ | ref on a control whose words changed | pressed the new control | refused, says both words (0 ms extra on an unchanged ref) |
864
+ | sidecar call after a hot reload | `sessionScope is not defined` | works; the Host half is checked by a test |
865
+
866
+ ## Phase 23 — one tab per chat, typing that looks like hands, 0.34.0
867
+
868
+ The browser used to be one page for the whole app, so any chat could navigate
869
+ the page another chat was reading. Tabs are now per chat; one windowless Chrome
870
+ and one profile still back all of them, so a login is shared and an address is
871
+ not.
872
+
873
+ | what | measured |
874
+ | --- | --- |
875
+ | first chat, browser cold | 725 ms to a driven page |
876
+ | second chat, own tab | 1 round trip, no second Chrome |
877
+ | chat A navigates | chat B's URL and the first tab unchanged |
878
+ | frames per chat | `/sidecar/frames?session=` streams only that tab |
879
+ | paste, 46 chars | 5 ms |
880
+ | human typing, 46 chars | 1965–2238 ms (43–49 ms per char) |
881
+ | human typing, >160 chars | 8-char bursts, 16–58 ms gaps, ≈6 ms per char |
882
+ | headless UA | desktop Chrome UA, no `HeadlessChrome` |
883
+ | suite | 70 pass, breadth 66, ui-copy clean |
884
+
885
+ ## Flash 分支(Electron 11.3.0 / Chromium 87,1440×900,Apple 芯片 + Rosetta)
886
+
887
+ | what | measured |
888
+ | --- | --- |
889
+ | cold start to a driven page | 1389 ms(热启 545–927 ms) |
890
+ | first painted frame | 8–10 s(SWF 自己的加载;之前是 11 KB 白帧) |
891
+ | one frame via capturePage | 16 ms |
892
+ | frame size, JPEG q72 | 96–103 KB |
893
+ | mirror SSE | 15 fps(66 ms 一轮),4 s 收 60 帧,均值 97.8 KB |
894
+ | switch cost | 一次浏览器重启(`browser_open({flash:true})` → `browser_open({flash:false})`) |