dsh-inner-browser-use 0.27.0 → 0.34.28

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/client.js CHANGED
@@ -32,7 +32,8 @@ window.__ModuleLoader__.load({
32
32
  const DICT = {
33
33
  zh: {
34
34
  'side.label': '后台浏览器',
35
- 'side.empty': '还没有打开的页面',
35
+ 'side.empty': '还没有打开的页面,在上方地址栏输入网址',
36
+ 'side.restore': '恢复上次的页面',
36
37
  'side.connecting': '正在连接…',
37
38
  'side.failed': '画面没有连上',
38
39
  'side.foreign': '这个浏览器属于发起它的会话,回到那个会话继续。',
@@ -44,7 +45,8 @@ window.__ModuleLoader__.load({
44
45
  },
45
46
  en: {
46
47
  'side.label': 'Background browser',
47
- 'side.empty': 'No page open',
48
+ 'side.empty': 'No page open; type an address above',
49
+ 'side.restore': 'Reopen the last page',
48
50
  'side.connecting': 'Connecting…',
49
51
  'side.failed': 'The picture did not connect',
50
52
  'side.foreign': 'This browser belongs to the chat that opened it.',
@@ -73,6 +75,8 @@ window.__ModuleLoader__.load({
73
75
  on screen at once, and moving the mouse never takes the arrow over. */
74
76
  '.dshib-side-ptr{position:absolute;width:20px;height:20px;pointer-events:none;z-index:2;transform:translate(-2px,-2px);transition:left 60ms linear,top 60ms linear;filter:drop-shadow(0 1px 2px rgba(0,0,0,.45))}',
75
77
  '.dshib-side-note{font-size:12px;color:var(--dsw-alias-label-tertiary);padding:0 16px;text-align:center}',
78
+ '.dshib-side-back{border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;padding:6px 12px;cursor:pointer}',
79
+ '.dshib-side-back:hover{background:var(--dsw-alias-bg-subtle)}',
76
80
  ].join('')
77
81
 
78
82
  return {
@@ -84,6 +88,9 @@ window.__ModuleLoader__.load({
84
88
  /* one locale service or none */
85
89
  }
86
90
  ctx.effect(() => {
91
+ /* A reload keeps one sheet, not one per lifetime: the same id is
92
+ replaced instead of stacked. */
93
+ document.getElementById?.(STYLE_ID)?.remove()
87
94
  const style = document.createElement('style')
88
95
  style.id = STYLE_ID
89
96
  /* A sheet is keyed by the plugin that brought it. */
@@ -109,6 +116,12 @@ window.__ModuleLoader__.load({
109
116
  }
110
117
  }
111
118
  const closeHere = (sessionId) => {
119
+ /* A tab the person closed is not a tab they want back on return. */
120
+ try {
121
+ void fetch(`${ROUTE}/sidecar/forget?session=${encodeURIComponent(sessionId)}`, { method: 'POST' }) // ui-copy-ignore
122
+ } catch {
123
+ /* a page that cannot hear it keeps its note */
124
+ }
112
125
  let records = []
113
126
  try {
114
127
  records = surface?.tabsIn?.(sessionId) ?? []
@@ -138,14 +151,23 @@ window.__ModuleLoader__.load({
138
151
  /* a column that refuses keeps its own tabs */
139
152
  }
140
153
  }
141
- disposers.push(tabs.register({
154
+ /* The same rule on this side: a second mount of the same tab is a
155
+ no-op, not a thrown error that takes the pane down. */
156
+ const safely = (register) => {
157
+ try {
158
+ return register()
159
+ } catch (error) {
160
+ return () => {}
161
+ }
162
+ }
163
+ disposers.push(safely(() => tabs.register({
142
164
  id: SIDE_ID, // ui-copy-ignore
143
165
  kind: SIDE_KIND, // ui-copy-ignore
144
166
  keepMounted: true,
145
167
  single: true,
146
168
  priority: 'extension',
147
169
  title: () => L('side.label'),
148
- }))
170
+ })))
149
171
  disposers.push(scope.slots.inject(SLOT, () => scope.slots.register({
150
172
  name: SLOT, // ui-copy-ignore
151
173
  key: SIDE_ID, // ui-copy-ignore
@@ -177,7 +199,10 @@ window.__ModuleLoader__.load({
177
199
  }
178
200
  const syncAsk = () => {
179
201
  const who = mountedOf()
180
- if (who === askWho) return
202
+ /* A stream the browser gave up on keeps its address here, so the
203
+ next tick has to notice it is dead and open a fresh one, or this
204
+ chat never hears an ask again. */
205
+ if (who === askWho && askSource !== null && Number(askSource.readyState) !== 2) return
181
206
  closeAsk()
182
207
  if (who === '') return
183
208
  try {
@@ -251,6 +276,9 @@ window.__ModuleLoader__.load({
251
276
  const [ptr, setPtr] = React.useState(null)
252
277
  const [phase, setPhase] = React.useState('connecting')
253
278
  const [note, setNote] = React.useState('')
279
+ /* The address this chat had before its tab went away: the pane reopens
280
+ that address, so a lost picture costs one click. */
281
+ const [back, setBack] = React.useState('')
254
282
  const [url, setUrl] = React.useState('')
255
283
  const [draft, setDraft] = React.useState('')
256
284
  const [editing, setEditing] = React.useState(false)
@@ -263,13 +291,48 @@ window.__ModuleLoader__.load({
263
291
  React.useEffect(() => {
264
292
  let alive = true
265
293
  let dropped = false
294
+ /* The tab is still in this chat's column, so this chat may say the
295
+ mirror is its own: a reload forgets the owner, and a pane that only
296
+ waited would stay dark until some tool ran. */
297
+ const reclaim = () => {
298
+ const id = String(props.sessionId ?? '')
299
+ if (id === '') return
300
+ void fetch(`${ROUTE}/sidecar/claim`, { // ui-copy-ignore
301
+ method: 'POST',
302
+ headers: { 'content-type': 'application/json' },
303
+ body: JSON.stringify({ session: id }),
304
+ })
305
+ .then((reply) => (reply.ok === true ? reply.json() : null))
306
+ .then((info) => {
307
+ if (alive !== true) return
308
+ if (info?.ok === true) {
309
+ setOwner(id)
310
+ /* The owner was just named, so the next poll may adopt the
311
+ browser: ask now instead of up to a poll later. */
312
+ setTimeout(read, 0)
313
+ }
314
+ })
315
+ .catch(() => {})
316
+ }
266
317
  const read = () => {
267
- void fetch(`${ROUTE}/sidecar/state`, { cache: 'no-store' }) // ui-copy-ignore
318
+ const who = String(props.sessionId ?? '')
319
+ /* Naming the chat lets a browser that outlived a reload be picked
320
+ up for us; an anonymous poll cannot, and the column would sit on
321
+ an empty stage with the browser running behind it. */
322
+ void fetch(`${ROUTE}/sidecar/state?session=${encodeURIComponent(who)}`, { cache: 'no-store' }) // ui-copy-ignore
268
323
  .then((reply) => (reply.ok === true ? reply.json() : null))
269
324
  .then((info) => {
270
325
  if (alive !== true) return
271
326
  const next = String(info?.owner ?? '')
327
+ const asked = String(props.sessionId ?? '')
328
+ /* Nobody owns it, or this chat does but the note was lost: ask
329
+ back instead of showing a note for a browser this column shows. */
330
+ if (asked !== '' && next !== asked) reclaim()
272
331
  setOwner((prev) => (prev === next ? prev : next))
332
+ /* A browser that is gone still remembers where this chat was:
333
+ keep the address so the pane can offer to reopen it. */
334
+ const was = String(info?.chat?.remembered ?? '')
335
+ if (was !== '') setBack((prev) => (prev === was ? prev : was))
273
336
  /* Another chat holds the browser now: this tab goes away instead
274
337
  of sitting here as a note. */
275
338
  if (dropped !== true && next !== '' && next !== String(props.sessionId ?? '')) {
@@ -297,9 +360,15 @@ window.__ModuleLoader__.load({
297
360
  this chat keeps only a note. // ui-copy-ignore */
298
361
  const unknown = owner === null
299
362
  const mine = unknown !== true && owner === asked
363
+ /* A picture that stops arriving has to come back by itself: the pane
364
+ re-opens its stream instead of sitting on an empty stage until the
365
+ person reloads the page. */
366
+ const [attempt, setAttempt] = React.useState(0)
367
+ const drawnAt = React.useRef(0)
300
368
 
301
369
  React.useEffect(() => {
302
370
  if (mine !== true) return undefined
371
+ drawnAt.current = Date.now()
303
372
  const who = encodeURIComponent(String(props.sessionId ?? ''))
304
373
  const source = new EventSource(`${ROUTE}/sidecar/frames?session=${who}`)
305
374
  /* Bursts arrive faster than a picture can decode: only the newest frame
@@ -316,6 +385,7 @@ window.__ModuleLoader__.load({
316
385
  setFrame(message)
317
386
  setPtr(message.pointer ?? null)
318
387
  setPhase('live')
388
+ drawnAt.current = Date.now()
319
389
  if (typeof message.url === 'string' && message.url !== '') setUrl(message.url)
320
390
  }
321
391
  const draw = (message) => {
@@ -324,6 +394,11 @@ window.__ModuleLoader__.load({
324
394
  if (typeof requestAnimationFrame === 'function') raf = requestAnimationFrame(paint)
325
395
  else paint()
326
396
  }
397
+ const stuck = setInterval(() => {
398
+ /* Hello alone is not a picture: six quiet seconds means the stream is
399
+ dead, so ask for a new one. */
400
+ if (Date.now() - drawnAt.current > 6000) setAttempt((n) => n + 1)
401
+ }, 3000)
327
402
  source.onmessage = (event) => {
328
403
  let message
329
404
  try {
@@ -333,7 +408,9 @@ window.__ModuleLoader__.load({
333
408
  }
334
409
  if (message.type === 'hello') {
335
410
  setPhase('live')
411
+ drawnAt.current = Date.now()
336
412
  setUrl(String(message.url ?? ''))
413
+ setBack(String(message.chat?.remembered ?? ''))
337
414
  } else if (message.type === 'failed') {
338
415
  setPhase('failed')
339
416
  setNote(String(message.text ?? ''))
@@ -347,10 +424,11 @@ window.__ModuleLoader__.load({
347
424
  }
348
425
  source.onerror = () => setPhase('lost')
349
426
  return () => {
427
+ clearInterval(stuck)
350
428
  if (raf !== 0 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf)
351
429
  source.close()
352
430
  }
353
- }, [props.sessionId, mine])
431
+ }, [props.sessionId, mine, attempt])
354
432
 
355
433
  /* The address field follows the page except while the person is editing it. */
356
434
  React.useEffect(() => {
@@ -510,7 +588,24 @@ window.__ModuleLoader__.load({
510
588
  onWheel,
511
589
  onKeyDown,
512
590
  },
513
- frame === null ? h('div', { className: 'dshib-side-note' }, noteText) : null,
591
+ frame === null && back !== ''
592
+ ? h('button', {
593
+ className: 'dshib-side-back',
594
+ type: 'button', // ui-copy-ignore
595
+ onClick: () => { // ui-copy-ignore
596
+ void fetch(`${ROUTE}/sidecar/open`, { // ui-copy-ignore
597
+ method: 'POST',
598
+ headers: { 'content-type': 'application/json' },
599
+ body: JSON.stringify({ session: who, url: back }), // ui-copy-ignore
600
+ })
601
+ .then((reply) => (reply.ok === true ? reply.json() : null))
602
+ .then((info) => {
603
+ if (info?.ok === true) setAttempt((n) => n + 1)
604
+ })
605
+ .catch(() => {})
606
+ },
607
+ }, L('side.restore'))
608
+ : frame === null ? h('div', { className: 'dshib-side-note' }, noteText) : null,
514
609
  h('textarea', {
515
610
  className: 'dshib-side-ime',
516
611
  ref: ime,
@@ -0,0 +1,19 @@
1
+ # 文件落在哪里
2
+
3
+ 浏览器产生的东西——截图、抓下来的图、打印的 PDF——不落在项目根目录,也不散在 home 下。固定规则:
4
+
5
+ ```
6
+ ~/.dsh-inner-browser-use/artifacts/<项目>/<会话>/<种类>/
7
+ ```
8
+
9
+ - **项目**:会话工作目录的文件夹名,加路径哈希前 6 位,例如 `dsh-inner-browser-use-a1b2c3`。同名文件夹、不同路径不会混。
10
+ - **会话**:会话 id 去掉 `session-` 后的前 8 位,例如 `72557d6e`。同一项目里两个会话各存各的。
11
+ - **种类**:`shots/` 是 `sidecar_shot` 的截图,`pictures/` 是 `sidecar_grab` 抓的图,`pdf/` 是 `sidecar_pdf` 打印的文档。
12
+
13
+ 文件名默认带时间戳(`shot-20261028-124500.png`),给了 `name` 就用它;名字里的分隔符、连续点、开头点都会被去掉,跳不出这个目录。
14
+
15
+ 每个种类保留最新 40 个文件,旧的自动删,免得跑一天的会话留下一个没人翻得动的文件夹。开着的文件不会被删掉(尽力删除,失败就留着)。
16
+
17
+ 调用环境里没有项目或会话信息时,落到 `no-project/no-chat/<种类>/`,不猜。
18
+
19
+ 测试里用 `DSH_INNER_BROWSER_ARTIFACTS` 指向临时目录,不写真实 home。
package/docs/BENCH.md CHANGED
@@ -1,3 +1,238 @@
1
+ ## Phase 44 — 真站页游场地,0.34.28
2
+
3
+ `node scripts/check-game.mjs <url>`,真 Chrome、自己的 profile、跑完自己关掉。两个真站:
4
+
5
+ | 站点 | 空闲时页面刷新 | 全屏截图(`sidecar_shot` 同类)| 按住 `d` 200 ms 到达页面 |
6
+ | --- | --- | --- | --- |
7
+ | 奥拉星 `http://aoqi.100bt.com/` | **27 fps** | 中位数 **68 ms** | 是 |
8
+ | 4399 `https://www.4399.com/` | **59.8 fps** | 中位数 **148 ms** | 是 |
9
+
10
+ 两个落地页都**没有 canvas**:游戏本体在链接后面,要么登录要么再点进去一层,所以这组数字说明的是「真站能不能实时跑」,不是「游戏画面已经能读」。
11
+
12
+ 诚实的一层:按键到达页面这件事在真站已经证实(页面自己记录的 `keydown`/`keyup`),但 4399 这种整屏重绘的页面,一张全屏图 1.8 MB、148 ms —— 做实时画面要压、要裁、要降缩放,这一层还没做,别拿它当「已经能玩」。
13
+
14
+ ## Phase 43 — 页游级输入:按住、组合键、按住鼠标,0.34.26
15
+
16
+ 本地画布页(每帧重绘,页面自己记录 `keydown`/`keyup`/`pointerdown`/`pointerup` 时间戳),真 Chrome,`node scripts/bench.mjs`:
17
+
18
+ | 项 | 数字 |
19
+ | --- | --- |
20
+ | `sidecar_key` 带 `hold_ms: 300` 整次调用 | **305 ms**(含 300 ms 按住,收尾 5 ms) |
21
+ | 页面测到的按住时长 | **301 ms** |
22
+ | `ArrowLeft+ArrowUp` | 两键都收到 `keydown`,都收到 `keyup`,顺序为倒序松开 |
23
+ | `sidecar_click` 带 `hold_ms: 200` 页面测到 | **211 ms** |
24
+
25
+ 对照 0.34.25 之前:同样的按住被 `settle` 拖到 727 ms,等于 0.7 s 才走一步——页游里这就是掉帧。
26
+
27
+ ## Phase 42 — the reload path, proved by two instances, 0.34.25
28
+
29
+ `npm run adopt`(真 Chrome,临时 profile `adopt-check`,临时状态目录):
30
+
31
+ | 项 | 数字 |
32
+ | --- | --- |
33
+ | 第一个实例冷启动到页面可用 | 675 ms |
34
+ | 第二个实例(模拟重载后的半边)接管 | 162 ms |
35
+ | 第二个实例自己起的 Chrome | `null`(没有起第二个) |
36
+ | 浏览器进程 | 同一个 pid,接管后仍活着 |
37
+ | 页签与标题 | `title: "kept"`,内容原样 |
38
+
39
+ 这就是重载后「接回同一个浏览器、不重启、不丢页签和登录」的机器证据;之前只有手工重载才能走到这条路。
40
+
41
+ ## Phase 41 — the read tool that had never run, 0.34.23
42
+
43
+ `node scripts/bench.mjs`(本机 headless 页面,Chrome 复用 `bench` profile):
44
+
45
+ | 项 | 数字 |
46
+ | --- | --- |
47
+ | 首次画面(复用已在跑的浏览器) | 133 ms |
48
+ | 首帧字节 | 76 KB |
49
+ | 帧率 | 60 fps |
50
+ | 镜像延迟 | 中位 8 ms / 最大 8 ms |
51
+ | 遮挡检测 | 中位 3 ms / 最大 15 ms |
52
+ | 点一下(普通页 / 有遮罩,含读回) | 439 / 590 ms,遮罩那次 `cleared: true` |
53
+ | 一步 `sidecar_do` | 440 / 590 ms |
54
+ | 截图 | 73 ms / 115 KB |
55
+ | 读 dense 页 text 档 | 6 ms / 1484 字 / ≈436 token |
56
+ | 读 dense 页 forms 档 | 1 ms / 432 字 / ≈127 token |
57
+ | 再读同一页 | 1–2 ms,给出「没有变化」一行 |
58
+ | `changed_only` | 58 字 / ≈17 token(整页 1505 字) |
59
+
60
+ 这轮 bench 的作用不是数字,而是**它第一次跑到 `sidecar_view` 就报 `ReferenceError` 退出**:那个工具在真实会话里同样会失败,而单测只测了它的纯函数部分。修完之后 bench 一次性跑完,数字在上面。
61
+
62
+ ## Phase 40 — the poll that names its chat, 0.34.19
63
+
64
+ The adoption rule from 0.34.16 never fired for the column itself: the pane
65
+ polled `/sidecar/state` without a session, the host treated that as anonymous,
66
+ and a reload could still leave the mirror empty over a live browser. The pane now
67
+ names its chat, and asks again the moment a claim succeeds.
68
+
69
+ No performance change was expected; the full bench was re-run anyway so the
70
+ numbers under it are current (commit 0.34.19, local pages):
71
+
72
+ | Label | Value |
73
+ | --- | --- |
74
+ | launch → first frame | 1029 ms |
75
+ | steady stream | 60 fps, 76 KB per frame |
76
+ | mirror delay (12 samples) | median 9 ms, max 13 ms |
77
+ | dense page, text | 6 ms, 1484 chars, ≈436 tokens |
78
+ | dense page, forms | 2 ms, 432 chars, ≈127 tokens |
79
+
80
+ ## Phase 39 — count what is on the screen, not what is in the DOM, 0.34.17
81
+
82
+ `readFrames` counts child frames; the page's own count filters to frames that
83
+ occupy the screen. Taking the larger made a hidden 1×1 frame claim a blind spot.
84
+
85
+ - `blindSpots` now lets the page's visible count win; the reader's count is the
86
+ fallback for when the page could not count.
87
+ - Live: two frames (400×300 and 1×1) → `blind.frames` 1, exactly one line.
88
+
89
+ ## Phase 38 — a reload must not blank the mirror, 0.34.16
90
+
91
+ `/sidecar/state` answered from the new plugin instance, which owns nothing until
92
+ something calls `open`: after a reload the owning chat's own poll said
93
+ `alive:false`, hence "no page open" in the column while Chrome was fine.
94
+
95
+ - The owning chat's state poll adopts the running browser and the tab remembered
96
+ for it before answering.
97
+ - Live: `/dsh-unlag/reload` 200 → state `alive True`, url `https://www.doubao.com/chat/`,
98
+ Chrome pid 12833 with 11 m uptime, i.e. attached rather than relaunched.
99
+ - Non-owning chats and empty `session` polls still answer without spawning anything.
100
+
101
+ ## Phase 37 — an observation that admits its blind spot, 0.34.15
102
+
103
+ A text read and a screenshot claim to show the page. Both are lossy, and the loss
104
+ was silent: a chart wired as `<canvas>`, a generated image with no alt text, a
105
+ video, a cross-origin frame — nothing in the text said "there is something here
106
+ you cannot read". A model then planned as if the page were fully described.
107
+
108
+ - `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).
109
+ - `chrome-error://` is no longer rendered as an empty page: it says the page did not load and names `sidecar_nav`.
110
+ - `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.
111
+ - `sidecar_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.
112
+ - Suite: 101 tests, 0 failures.
113
+
114
+ ## Phase 36 — a reload must not cost the browser, 0.34.14
115
+
116
+ - 根因:交接看门狗 `while kill -0 ${process.pid}` 盯的是插件实例 pid,`dsh-unlag` 重载换 pid 后看门狗立刻 kill 共享 Chrome —— 镜像白屏、会话掉回 about:blank。
117
+ - 改为盯应用 pid(`ps -o ppid=` 向上走到顶)+ `watcher.json` 去重;实测 node 14185 的应用 pid 是 13663。
118
+ - `ensure()` 无活页面时回落到 `rememberedTab(id)`,首开即回原地址。
119
+ - 测试 97 条全绿。
120
+
121
+ ## 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
122
+
123
+ - `markRound()` 追加 verdict 行,`lastRound()` 折回 `marks`;`brief({already})` 只保留 open 条目,实测提问里出现「Already handled from your previous review」,已处理点不再重复。
124
+ - 工具面新增 `sidecar_review_verdict`(`mark: [{point,status,evidence}]`,状态枚举 fixed/verified/invalid/open);越界编号丢弃、未知状态记为 open。
125
+ - 测试 92 → 96 条全绿。
126
+
127
+ ## Phase 34 — one gate in front of every write, 0.34.12## Phase 34 — one gate in front of every write, 0.34.12
128
+
129
+ - `acting()` 统一 400(未命名会话)与 403(非持有者);实测匿名 `POST /sidecar/nav` → 400 `which chat asked for it`,非持有者 `POST /sidecar/stop` → 403,持有者 `forget` → 200。
130
+ - 测试 90 → 92 条,全绿;文案与样式 lint 无 error。
131
+
132
+ ## Phase 33 — a lost picture comes back in one click, 0.34.11
133
+
134
+ - 实测「切走 / 重载后画面没了」的下一步:`/sidecar/state?session=…` 与画面流 hello 都给出 `chat.remembered`(该会话最后地址)。
135
+ - 右栏在没有画面且 `remembered` 有值时显示「恢复上次的页面」,点击 POST `/sidecar/open` 并重连画面流。
136
+ - 同一会话第二次 open 命中已有标签页:107 ms(三次实测 2.08 s / 0.107 s / 0.108 s,首次含导航)。
137
+
138
+ ## Phase 32 — a call log belongs to its chat, 0.34.10
139
+
140
+ `GET /sidecar/state` returned the last 40 mirror calls of every chat, each with
141
+ an eight-character session id: one conversation could read where another had
142
+ been browsing. The list is now filtered to the caller's own chat. The owner and
143
+ the list of listening chats stay global, because those are what the column needs
144
+ to claim the mirror.
145
+
146
+ ## Phase 31 — a reload keeps the mirror, 0.34.9
147
+
148
+ `ctx.effect` cleanup ran `sidecar.shutdown()`, so each plugin reload closed
149
+ Chrome. The page disappeared from the column and the next open was a cold
150
+ start again. Cleanup now hands the browser to the next driver: cookies and a
151
+ login snapshot hit disk, the browser keeps running, and a detached watcher
152
+ closes it only after the app process is gone.
153
+
154
+ | after a `dsh-unlag` reload | before | now |
155
+ |---|---|---|
156
+ | browser | closed, page lost | still running, page kept |
157
+ | next interaction | cold launch (≈0.6 s, was 21 s) | attach, no launch |
158
+
159
+ ## Phase 30 — a brand-new chat's first tab, 0.34.8
160
+
161
+ A fresh profile with no memory of a page means `openTab` asks for `about:blank`.
162
+ `navigate` then polled for a non-blank address, which a blank page never becomes,
163
+ and spent all 200 tries. Every first open of a new chat paid it.
164
+
165
+ | cold start, fresh profile (`about:blank`) | before | now |
166
+ |---|---|---|
167
+ | round 1 | 21 322 ms | 817 ms |
168
+ | round 2 | 20 882 ms | 607 ms |
169
+ | round 3 | 20 900 ms | 516 ms |
170
+ | page settings round trips | 4 | 1 |
171
+
172
+ Measured with three launches of `Sidecar#ensure` against a throwaway profile;
173
+ the first launch is now about 26× faster than before.
174
+
175
+ ## Phase 29 — a stale session cookie is not written back, 0.34.7
176
+
177
+ `restoreSessionCookies` replayed every kept session cookie on launch, including
178
+ ones the browser already had. A rotated `sessionid` from the note would replace
179
+ the current one, and the site answers a retired session with a sign-in page.
180
+
181
+ | case | before | now |
182
+ |---|---|---|
183
+ | note holds sessionid, browser holds rotated one | old value written | nothing written |
184
+ | note holds a cookie the browser lost | written | written |
185
+ | cookie key | — | domain + path + name |
186
+
187
+ 86 tests complete the suite in about 2 s.
188
+
189
+ ## Phase 28 — one chat, one tab, even in parallel, 0.34.6
190
+
191
+ `tabFor` checked `pages`, then did async work, then wrote `pages`: two calls that
192
+ started together both missed the map and both opened a tab. The orphan tab kept
193
+ a live page nobody could see or stop.
194
+
195
+ | case | before | now |
196
+ |---|---|---|
197
+ | two parallel calls, cold chat | 2 tabs, 1 orphan | 1 tab |
198
+ | later call with another address | its own tab | same tab, navigated |
199
+ | leftover pending note | — | cleared in `finally` |
200
+
201
+ The fix is a per-chat promise in `opening`, joined by the second caller.
202
+
203
+ ## Phase 27 — one gate for the mirror, 0.34.5
204
+
205
+ Three routes each carried their own copy of the ownership check. Copies drift:
206
+ a route added later is exactly the one nobody remembers to guard.
207
+
208
+ | case | result |
209
+ |---|---|
210
+ | rival chat → `/sidecar/input` | 403, owner named |
211
+ | rival chat → `/sidecar/nav` | 403, owner named |
212
+ | rival chat → `/sidecar/frames` | 403, no stream opened |
213
+ | unit tests | 84 |
214
+
215
+ The gate reads one function, so the question "may this chat act?" has one answer
216
+ in one place.
217
+
218
+ ## Phase 26 — a screenshot the model can see, 0.34.3
219
+
220
+ Observation was structured text only: `sidecar_shot` wrote a PNG and returned
221
+ a path, which is a file name the model cannot look at. Codex and Claude Code
222
+ both hand screenshots back as image content, and that is the difference
223
+ between planning from the picture and guessing.
224
+
225
+ | case | before | now |
226
+ |---|---|---|
227
+ | route with image input | path only | path + image block, same call |
228
+ | route without image input | path | path, no image block, no error |
229
+ | no attachment store | path | path |
230
+ | read state across chats | one shared object | one per chat |
231
+ | unit tests | 79 | 81 |
232
+
233
+ Same-call cost: the PNG is already in memory for the file write, so the image
234
+ block adds one attachment save, no extra round trip and no second screenshot.
235
+
1
236
  <!-- ui-copy-ignore -->
2
237
  # Measured, not claimed
3
238
 
@@ -341,3 +576,144 @@ travels in the same line as the path.
341
576
  The sampling is 32x32 inside the page, in the same call that reads the page, so a
342
577
  blank chart is visible before anything is saved.
343
578
 
579
+ ## Phase 18 — the picture that never arrived, 0.28.0
580
+
581
+ | page | read line | time |
582
+ | --- | --- | --- |
583
+ | `/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) |
584
+
585
+ A failed image collapses to an error icon, so the declared `width`/`height` is
586
+ what the page meant to show; the read reports that size, not the icon's.
587
+
588
+ ## Phase 19 — the page said again, 0.29.0
589
+
590
+ | read | characters | time |
591
+ | --- | --- | --- |
592
+ | first read of `/dense` (40 paragraphs) | 2057 | — |
593
+ | second read, nothing changed | 105 | 1 ms |
594
+
595
+ The comparison is free: both reads already happened. Only the report shrinks.
596
+ A changed scroll offset is still reported, so "nothing changed" is never a lie.
597
+
598
+ ## Phase 20 — one call carries the question and the answer, 0.30.0
599
+
600
+ | step | before | `sidecar_ask` |
601
+ | --- | --- | --- |
602
+ | type the message | `sidecar_type` + `submit` | same, inside the call |
603
+ | read the page | `sidecar_view` | — |
604
+ | read it again until the answer stops growing | 2–6 more `sidecar_view` calls | — |
605
+ | **turns for a chat reply** | 3–7 | **1** |
606
+ | measured `sidecar_ask` on `/chat` (answer over 2 arrivals, 700 ms tail) | — | 2.5 s, one turn |
607
+
608
+ ## Phase 21 — a review round that closes the loop, 0.31.0
609
+
610
+ | step | before | `sidecar_review` |
611
+ | --- | --- | --- |
612
+ | write the request (repo, commit, focus) | by hand, every round | built from three fields |
613
+ | send it, wait for the answer | `sidecar_ask` | same call |
614
+ | turn the answer into a list | read it, retype it | parsed, numbering kept |
615
+ | know what the last round was about | — | round log, with the commit and the time |
616
+ | **turns for a review round** | 3–7 + bookkeeping | **1** |
617
+ | measured round on `/chat` (echoed question + 2-item list) | — | 2.6 s, 2 points |
618
+
619
+ ## Phase 21b — an answer that is not an answer, 0.31.1
620
+
621
+ A chat box on a page that is not signed in still accepts text, still redraws,
622
+ and still looks busy. Three cheap checks keep that from being reported as a
623
+ review.
624
+
625
+ | what the page does | before | now | cost |
626
+ | --- | --- | --- | --- |
627
+ | keeps the words in the box | waits 4 s, then reads the page | says so at 0.5 s | 1 evaluate |
628
+ | redraws but never shows the message | the new words were the answer | says the page did not take it | none (text compare) |
629
+ | shows the message, no reply | "answered in prose" | "has the message but has not answered yet" | none (text compare) |
630
+ | real page, signed out (Doubao) | a review-shaped paragraph of menu chips | the sign-in is named, 2.6 s | — |
631
+
632
+ ## Phase 22 — the change travels with the request, 0.32.0
633
+
634
+ The reviewer inside the column cannot open a repository. Asking it to review
635
+ "the newest commit" of a repository it cannot see is asking for a guess.
636
+
637
+ | request | what the reviewer gets | cost |
638
+ | --- | --- | --- |
639
+ | repo + commit | an address and a hash it cannot open | 1 message |
640
+ | repo + commit + diff | the change itself, capped at 5000 characters | +diff characters in that one message |
641
+
642
+ Measured: `review-carries-diff` green in 2.6 s; the recorded round carries
643
+ `+const cap = 60` verbatim.
644
+
645
+ ## Phase 22b — the mirror outlives the visit, 0.33.0
646
+
647
+ A column tab only exists while the chat is on screen. The ask used to be a
648
+ single frame pushed at request time: if that chat had no stream, the frame was
649
+ lost and the mirror never appeared on return.
650
+
651
+ | situation | before | now |
652
+ | --- | --- | --- |
653
+ | the chat is on screen | tab opens | tab opens |
654
+ | the chat is elsewhere, then returns | nothing | tab is waiting |
655
+ | the page was reloaded | nothing | tab is waiting |
656
+ | the ask is 3 minutes old | nothing | nothing (spent) |
657
+ | a different chat comes back | nothing | nothing (not its mirror) |
658
+
659
+ Data flow never needed the column: measured with zero viewers — boot 626 ms,
660
+ click and read back 114 ms, read 4 ms, shot 48 ms / 12.5 KB.
661
+
662
+ ## Phase 22c — a duplicate cannot take the pane down, 0.33.2
663
+
664
+ A scan of the six shipped modules finds no repeated top-level name, no module
665
+ imported twice, no two tools with one name, one tab id and one style sheet.
666
+ The two places a reload can still meet itself are guarded instead of trusted:
667
+
668
+ | case | before | now |
669
+ | --- | --- | --- |
670
+ | same tool name registered again | thrown error, surface down | warning, first one kept |
671
+ | same tab mounted again | possible duplicate registration | no-op |
672
+ | style sheet re-applied | second sheet stacked | replaced by id |
673
+ | two mount entry points | possible double effect | one entry per half |
674
+
675
+ Guards are covered by `test/no-duplicate.test.mjs` plus a double-mount test:
676
+ 65 unit tests, 66 breadth checks, 20 packaged files.
677
+
678
+ ## Phase 25 — one chat cannot take another chat's page, 0.34.2
679
+
680
+ The audit on 0.34.0 named five things a driver written for one chat got wrong.
681
+ Each was reproduced against 0.34.1 first: two were already fixed (the
682
+ `sessionScope`/`KEYS` import, and every `ensure()` route already carrying a
683
+ session), three were real.
684
+
685
+ | What | Before | Now |
686
+ | --- | --- | --- |
687
+ | popup lookup | any non-main tab, then navigated or closed | only a target that is neither this page nor another chat's (`isForeign`) |
688
+ | focused password in a result line | echoed the `.value` (`DUMMY_SECRET_123`) | name only, value always empty |
689
+ | `sidecar_do` on a step that did not land | kept going, typed into the wrong page | stops at that step, says to re-plan |
690
+ | overlay that would not clear | clicked the control under it anyway | refuses the click/typing, names the overlay |
691
+ | `changed_only` twice | repeated the same change | diff becomes the new baseline |
692
+ | `/sidecar/state` | top-level `url` could be another chat's tab | `?session=` adds `chat.url/title/viewers` (live: doubao page, viewers 1) |
693
+ | suite | 77 pass | 79 pass (isolation, leak) |
694
+
695
+ ## Phase 24 — a number that is checked before it is used, 0.34.1
696
+
697
+ | What | Before | Now |
698
+ | --- | --- | --- |
699
+ | ref on a control whose words changed | pressed the new control | refused, says both words (0 ms extra on an unchanged ref) |
700
+ | sidecar call after a hot reload | `sessionScope is not defined` | works; the Host half is checked by a test |
701
+
702
+ ## Phase 23 — one tab per chat, typing that looks like hands, 0.34.0
703
+
704
+ The browser used to be one page for the whole app, so any chat could navigate
705
+ the page another chat was reading. Tabs are now per chat; one windowless Chrome
706
+ and one profile still back all of them, so a login is shared and an address is
707
+ not.
708
+
709
+ | what | measured |
710
+ | --- | --- |
711
+ | first chat, browser cold | 725 ms to a driven page |
712
+ | second chat, own tab | 1 round trip, no second Chrome |
713
+ | chat A navigates | chat B's URL and the first tab unchanged |
714
+ | frames per chat | `/sidecar/frames?session=` streams only that tab |
715
+ | paste, 46 chars | 5 ms |
716
+ | human typing, 46 chars | 1965–2238 ms (43–49 ms per char) |
717
+ | human typing, >160 chars | 8-char bursts, 16–58 ms gaps, ≈6 ms per char |
718
+ | headless UA | desktop Chrome UA, no `HeadlessChrome` |
719
+ | suite | 70 pass, breadth 66, ui-copy clean |