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 +103 -8
- package/docs/ARTIFACTS.md +19 -0
- package/docs/BENCH.md +376 -0
- package/docs/ROADMAP.md +88 -0
- package/index.js +320 -29
- package/package.json +5 -2
- package/read.js +111 -10
- package/review.js +213 -0
- package/sidecar.js +717 -40
- package/tools.js +635 -83
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 |
|